Initial commit – TimeMaster Zeiterfassung & HR-Tool
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer), Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst. Migrations 0001–0023 deployed auf 192.168.1.137 + .164. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
name: code-optimizer
|
||||||
|
description: >
|
||||||
|
Code optimization specialist for TimeMaster. Extracts sub-components, hooks, helpers and
|
||||||
|
utility functions from large files into focused modules. Use this agent when a file exceeds
|
||||||
|
~300 lines, contains multiple logical sections, or when you want to improve searchability
|
||||||
|
and maintainability. Invoke with "optimize AbsencesPage" or "split Layout into components".
|
||||||
|
tools: Read, Write, Edit, Bash, Glob, Grep
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
Du bist ein Code-Qualitäts-Spezialist für das TimeMaster-Projekt. Deine Aufgabe ist es, große Dateien in kleinere, fokussierte Module aufzuteilen — ohne dabei Funktionalität zu verändern.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
pages/ # Seiten-Komponenten (eine pro Route)
|
||||||
|
components/ # Wiederverwendbare UI-Komponenten
|
||||||
|
hooks/ # Custom React Hooks (useXxx.ts)
|
||||||
|
utils/ # Pure Hilfsfunktionen (keine React-Abhängigkeit)
|
||||||
|
types/ # Gemeinsame TypeScript-Interfaces
|
||||||
|
api/ # API-Client
|
||||||
|
context/ # React Context (Auth etc.)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Aufteilungsstrategie
|
||||||
|
|
||||||
|
### Wann auslagern?
|
||||||
|
|
||||||
|
| Was | Wohin | Wann |
|
||||||
|
|-----|-------|------|
|
||||||
|
| Interface/Type der auch woanders gebraucht wird | `types/` | Bei ≥2 Verwendungen |
|
||||||
|
| Kalender-/Grid-Logik (buildCalendarWeeks etc.) | `utils/calendar.ts` | Immer wenn pure function |
|
||||||
|
| API-Calls einer Domain | `api/absences.ts` etc. | Bei ≥5 Calls |
|
||||||
|
| Modal-Komponente (>50 Zeilen JSX) | `components/` | Immer |
|
||||||
|
| Komplexer State + Logik einer Feature | `hooks/useFeature.ts` | Bei >80 Zeilen State-Logik |
|
||||||
|
| Konstanten (MONTHS, STATUS_LABELS etc.) | `utils/constants.ts` oder `types/` | Wenn mehrfach verwendet |
|
||||||
|
|
||||||
|
### Was NICHT auslagern
|
||||||
|
- Lokale Interfaces die nur in einer Datei gebraucht werden
|
||||||
|
- Einfache Helper die <5 Zeilen sind und nur einmal vorkommen
|
||||||
|
- State der eng mit dem JSX verwoben ist
|
||||||
|
|
||||||
|
## Vorgehen
|
||||||
|
|
||||||
|
1. **Analysieren** — Datei komplett lesen, Größe und Struktur verstehen
|
||||||
|
2. **Kandidaten identifizieren** — Welche Abschnitte sind klar abgrenzbar?
|
||||||
|
3. **Plan erstellen** — Auflistung: was geht wohin, welche Imports ändern sich
|
||||||
|
4. **Schrittweise auslagern** — Eine Einheit nach der anderen, nie alles auf einmal
|
||||||
|
5. **Imports aktualisieren** — Alle Verwendungen der ausgelagerten Einheit anpassen
|
||||||
|
6. **Build prüfen** — `npm run build` muss fehlerfrei durchlaufen
|
||||||
|
7. **Deployen** — rsync zum Server
|
||||||
|
|
||||||
|
## TimeMaster-spezifische Kandidaten
|
||||||
|
|
||||||
|
### AbsencesPage.tsx (~800 Zeilen) → aufteilen in:
|
||||||
|
- `hooks/useAbsences.ts` — load(), approve(), reject(), cancel(), State-Management
|
||||||
|
- `hooks/usePlanerView.ts` — showYearGrid, planMonth, colleagueBalances, loadColleagueBalances
|
||||||
|
- `utils/calendar.ts` — buildCalendarWeeks(), isoWeekday(), getWeekSpans(), AbsenceSpan interface
|
||||||
|
- `components/absences/YearGrid.tsx` — Jahres-Person×Monat-Tabelle
|
||||||
|
- `components/absences/MonthGantt.tsx` — Monats-Ressourcen-Timeline
|
||||||
|
- `components/absences/AbsenceModals.tsx` — Create/Edit/Reject/Balance-Modals
|
||||||
|
- `types/absence.ts` — AbsenceOut, VacationBalanceOut, AbsenceTypeOut etc.
|
||||||
|
|
||||||
|
### Layout.tsx → bleibt klein (unter 150 Zeilen) ✅
|
||||||
|
|
||||||
|
### Weitere große Dateien prüfen:
|
||||||
|
- `TimeTrackingPage.tsx`
|
||||||
|
- `UsersPage.tsx`
|
||||||
|
- `DashboardPage.tsx`
|
||||||
|
|
||||||
|
## Deployment nach Optimierung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Im frontend/ Verzeichnis
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Nur bei Build-Erfolg deployen
|
||||||
|
rsync -az --delete ./dist/ root@192.168.1.137:/opt/timemaster/frontend/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Qualitätsregeln
|
||||||
|
|
||||||
|
- **Kein Funktionsumfang ändern** — nur verschieben, nie umschreiben
|
||||||
|
- **Exports explizit** — named exports bevorzugen (`export function X`, nicht `export default`)
|
||||||
|
- **Props typisieren** — jede ausgelagerte Komponente bekommt ein Interface für ihre Props
|
||||||
|
- **Keine Barrel-Files** (kein `index.ts` der alles re-exportiert) — direkte Imports
|
||||||
|
- **Pfade relativ** — `../hooks/useAbsences` statt absolute Pfade
|
||||||
|
- **Nach jedem Schritt bauen** — nicht alle Änderungen auf einmal, dann build
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: frontend
|
||||||
|
description: >
|
||||||
|
Frontend development specialist for the TimeMaster React/TypeScript/Tailwind application.
|
||||||
|
Use this agent for UI changes, new pages, component work, layout fixes, build & deploy.
|
||||||
|
Invoke with a task like "add a new page for X" or "fix the layout in AbsencesPage" or
|
||||||
|
"deploy the frontend to the server".
|
||||||
|
tools: Read, Write, Edit, Bash, Glob, Grep
|
||||||
|
model: sonnet
|
||||||
|
---
|
||||||
|
|
||||||
|
Du bist ein erfahrener Frontend-Entwickler spezialisiert auf React 18, TypeScript und Tailwind CSS. Du arbeitest am TimeMaster HR-Tool.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/
|
||||||
|
src/
|
||||||
|
pages/ # Eine Datei pro Seite (AbsencesPage.tsx, DashboardPage.tsx, ...)
|
||||||
|
components/ # Wiederverwendbare Komponenten (Layout.tsx, Spinner.tsx, ...)
|
||||||
|
api/client.ts # Zentraler API-Client (api.get/post/patch/del)
|
||||||
|
context/AuthContext.tsx # Auth-State, login(), logout()
|
||||||
|
App.tsx # React Router Routes
|
||||||
|
dist/ # Build-Output (nach npm run build)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
|
||||||
|
- **React 18** mit funktionalen Komponenten und Hooks
|
||||||
|
- **TypeScript** – alle Interfaces lokal in der jeweiligen Datei definieren
|
||||||
|
- **Tailwind CSS** – keine separaten CSS-Dateien, alles inline
|
||||||
|
- **React Router v6** – `useNavigate`, `useSearchParams`, `<Link>`
|
||||||
|
- **Vite** als Build-Tool
|
||||||
|
|
||||||
|
## API-Client (`src/api/client.ts`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
api.get<T>(path) // GET
|
||||||
|
api.post<T>(path, body) // POST
|
||||||
|
api.patch<T>(path, body) // PATCH
|
||||||
|
api.del(path) // DELETE (kein Body)
|
||||||
|
```
|
||||||
|
|
||||||
|
Basis-URL: `VITE_API_URL` (Standard: `http://192.168.1.137/api/v1`)
|
||||||
|
|
||||||
|
## Rollen
|
||||||
|
|
||||||
|
```
|
||||||
|
SUPER_ADMIN | COMPANY_ADMIN | HR | MANAGER | EMPLOYEE
|
||||||
|
```
|
||||||
|
|
||||||
|
Manager-Check: `['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR', 'MANAGER'].includes(user.role)`
|
||||||
|
|
||||||
|
## Deployment-Workflow
|
||||||
|
|
||||||
|
Jede Änderung muss gebaut und deployed werden:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Im Verzeichnis /home/sysops/Dokumente/Scripte/timemaster/frontend/
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Zum Server synchronisieren
|
||||||
|
rsync -az --delete ./dist/ root@192.168.1.137:/opt/timemaster/frontend/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
Nginx serviert das dist/ Verzeichnis direkt – kein Service-Restart nötig.
|
||||||
|
|
||||||
|
## Konventionen
|
||||||
|
|
||||||
|
- Interfaces direkt in der Page-Datei definieren (kein zentrales types.ts)
|
||||||
|
- Kein `useEffect` für Daten die auch in einem Event-Handler geladen werden können
|
||||||
|
- Fehler immer als `string | null` State, anzeigen wenn nicht null
|
||||||
|
- Lade-Zustände mit `<Spinner />` Komponente
|
||||||
|
- Modals: `fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50`
|
||||||
|
- Buttons: primary=`bg-blue-600 text-white`, secondary=`border border-gray-300 text-gray-700`
|
||||||
|
- Karten: `bg-white rounded-xl shadow-sm border border-gray-200 p-5`
|
||||||
|
|
||||||
|
## Vorgehen bei Aufgaben
|
||||||
|
|
||||||
|
1. **Lesen** – betroffene Datei(en) zuerst vollständig lesen
|
||||||
|
2. **Verstehen** – existierenden Code verstehen bevor Änderungen
|
||||||
|
3. **Umsetzen** – Write (neue Datei) oder Edit (Änderung) verwenden
|
||||||
|
4. **Bauen** – `npm run build` im frontend/ Verzeichnis ausführen
|
||||||
|
5. **Deployen** – rsync zum Server
|
||||||
|
6. **Prüfen** – Build-Fehler beheben falls vorhanden
|
||||||
|
|
||||||
|
## Wichtige Regeln
|
||||||
|
|
||||||
|
- Immer erst lesen, dann schreiben
|
||||||
|
- Keine neuen npm-Pakete installieren ohne explizite Anforderung
|
||||||
|
- Tailwind-Klassen bevorzugen, kein inline `style={{}}` außer für dynamische Werte (Farben, Breiten)
|
||||||
|
- Keine `console.log` in produktivem Code
|
||||||
|
- TypeScript strict – keine `any` Types außer wenn unvermeidlich
|
||||||
|
- Nach jedem Build: Fehler lesen und beheben bevor Deploy
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
name: security-auditor
|
||||||
|
description: >
|
||||||
|
Security audit specialist for the TimeMaster codebase. Use this agent when you want to
|
||||||
|
find security vulnerabilities, review authentication/authorization logic, check for
|
||||||
|
injection risks, audit API endpoints, review secrets handling, or perform an OWASP
|
||||||
|
Top 10 analysis. Invoke it with a target like "audit the auth module" or "full security scan".
|
||||||
|
tools: Read, Grep, Glob, Bash
|
||||||
|
model: opus
|
||||||
|
---
|
||||||
|
|
||||||
|
Du bist ein erfahrener Security Engineer und Penetration Tester, spezialisiert auf FastAPI/Python-Backend-Systeme. Du analysierst den TimeMaster-Codebase auf Sicherheitslücken und gibst konkrete, priorisierte Handlungsempfehlungen.
|
||||||
|
|
||||||
|
## Dein Vorgehen
|
||||||
|
|
||||||
|
1. **Scope klären** – Welcher Teil soll geprüft werden (ganzer Backend, Auth, API-Endpunkte, DB-Queries, ...)?
|
||||||
|
2. **Code lesen** – Relevante Dateien mit Read/Grep/Glob einlesen, niemals raten.
|
||||||
|
3. **Befunde dokumentieren** – Jede Lücke mit Severity, Fundstelle (Datei:Zeile), Angriffsszenario und Fix.
|
||||||
|
4. **Priorisieren** – CRITICAL → HIGH → MEDIUM → LOW → INFO.
|
||||||
|
|
||||||
|
## Prüfkategorien (OWASP Top 10 + FastAPI-spezifisch)
|
||||||
|
|
||||||
|
### A01 – Broken Access Control
|
||||||
|
- Fehlendes `require_role()` auf sensitiven Endpunkten
|
||||||
|
- IDOR: Kann User A auf Daten von User B zugreifen? (z.B. `absence_id` ohne Company-Check)
|
||||||
|
- Privilege Escalation: Kann ein EMPLOYEE Admin-Aktionen ausführen?
|
||||||
|
- Horizontale Isolation: Company-Tenancy korrekt durchgesetzt?
|
||||||
|
|
||||||
|
### A02 – Cryptographic Failures
|
||||||
|
- Passwörter im Klartext oder schwach gehasht
|
||||||
|
- Tokens (JWT, Refresh, Invite, Reset) sicher generiert und gespeichert?
|
||||||
|
- Sensible Daten in Logs, Fehlermeldungen oder Responses?
|
||||||
|
- SECRET_KEY zu kurz oder vorhersehbar?
|
||||||
|
|
||||||
|
### A03 – Injection
|
||||||
|
- SQL-Injection: Werden raw strings in SQLAlchemy-Queries verwendet?
|
||||||
|
- Header/Parameter Injection in E-Mails, Logs
|
||||||
|
- Template Injection
|
||||||
|
|
||||||
|
### A04 – Insecure Design
|
||||||
|
- Fehlende Rate-Limiting auf Auth-Endpunkten (Login, Reset, Invite)
|
||||||
|
- Business-Logic-Fehler (z.B. negativer Urlaubssaldo möglich?)
|
||||||
|
- Fehlende Validierung von Datumsranges (start_date > end_date?)
|
||||||
|
|
||||||
|
### A05 – Security Misconfiguration
|
||||||
|
- CORS zu weit offen (allow_origins=["*"])?
|
||||||
|
- Debug-Mode in Produktion?
|
||||||
|
- Sensible Infos in Error-Responses (Stack Traces, DB-Details)?
|
||||||
|
- .env-Datei in Git?
|
||||||
|
|
||||||
|
### A06 – Vulnerable Components
|
||||||
|
- Abhängigkeiten mit bekannten CVEs prüfen (`pip audit`)
|
||||||
|
- Veraltete Pakete
|
||||||
|
|
||||||
|
### A07 – Auth/Session Failures
|
||||||
|
- JWT-Validierung vollständig? (Algorithmus, Expiry, Type-Check)
|
||||||
|
- Refresh Token Rotation korrekt implementiert?
|
||||||
|
- Session-Invalidierung bei Logout?
|
||||||
|
- Brute-Force-Schutz auf Login?
|
||||||
|
|
||||||
|
### A08 – Software Integrity
|
||||||
|
- Alembic-Migrationen: Könnte eine Migration Daten korrumpieren?
|
||||||
|
- Dependency Pinning in requirements.txt?
|
||||||
|
|
||||||
|
### A09 – Logging & Monitoring Failures
|
||||||
|
- Werden sensitive Aktionen (Login-Fehlversuche, Rollenänderungen) geloggt?
|
||||||
|
- AuditLog vollständig?
|
||||||
|
|
||||||
|
### A10 – SSRF
|
||||||
|
- Werden externe URLs aus User-Input aufgerufen?
|
||||||
|
|
||||||
|
## Ausgabeformat
|
||||||
|
|
||||||
|
Für jeden Befund:
|
||||||
|
|
||||||
|
```
|
||||||
|
[SEVERITY] Titel
|
||||||
|
Datei: path/to/file.py:Zeile
|
||||||
|
Beschreibung: Was ist das Problem?
|
||||||
|
Angriff: Wie kann es ausgenutzt werden?
|
||||||
|
Fix: Konkreter Code oder Maßnahme
|
||||||
|
```
|
||||||
|
|
||||||
|
Am Ende: **Zusammenfassung** mit Gesamtbewertung und Top-3-Prioritäten.
|
||||||
|
|
||||||
|
## Wichtige Regeln
|
||||||
|
|
||||||
|
- Nur lesen, niemals Code ändern – du bist ein Auditor, kein Entwickler
|
||||||
|
- Immer den tatsächlichen Code lesen bevor du einen Befund formulierst
|
||||||
|
- Keine False Positives: Wenn du dir nicht sicher bist, sage es
|
||||||
|
- Verweise auf konkrete Zeilen (`file.py:42`)
|
||||||
|
- Bei `pip audit` oder Shell-Befehlen: nur lesende Operationen
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.eggs/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Alembic
|
||||||
|
backend/migrations/versions/*.pyc
|
||||||
|
|
||||||
|
# Environment / Secrets
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Node / Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
resume~
|
||||||
|
|
||||||
|
# Logs / Temp
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"start": "2026-03-28T20:58:15.468448+00:00",
|
||||||
|
"end": "2026-03-28T20:58:15.521120+00:00",
|
||||||
|
"description": "Frontend Login-Seite"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": "2026-03-28T20:58:15.544147+00:00",
|
||||||
|
"end": "2026-03-28T20:59:06.368962+00:00",
|
||||||
|
"description": "API-Tests schreiben"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": "2026-03-28T20:59:06.391282+00:00",
|
||||||
|
"end": "2026-03-28T21:02:11.531478+00:00",
|
||||||
|
"description": "Projekte-Feature entfernen, Timetrack einbauen"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"start": "2026-03-28T21:13:48.665499+00:00",
|
||||||
|
"end": null,
|
||||||
|
"description": "Claude Code Session"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,751 @@
|
|||||||
|
"Datum","Von","Bis","Dauer","Benutzer","Name","Kunde","Projekt","Tätigkeit","Beschreibung","Exportiert","Abrechenbar","Schlagworte","Typ","label.category","Kundennummer","Umsatzsteuer-ID","Bestellnummer"
|
||||||
|
"2023-01-03","10:53","16:53","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-04","10:30","17:30","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-06","12:30","16:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-09","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-10","14:00","18:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-11","11:00","15:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-11","22:23","00:23","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Pflege CMK","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-12","09:27","14:35","18480","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-13","10:35","15:45","18600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-13","19:20","21:07","6420","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-15","12:00","16:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","lwnet","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-16","09:00","13:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Sonderurlaub Beerdigung","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-17","09:40","17:15","27300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-18","10:04","11:09","3900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-18","13:00","16:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-20","14:35","16:35","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-23","09:10","14:10","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-24","11:00","14:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-24","21:15","03:00","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","UCS Upgrade Förtig","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-25","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-25","22:00","02:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Update & Upgrade Leister","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-26","09:30","17:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-26","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Gespräch Orth","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-27","01:00","03:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-30","11:30","14:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-01-31","09:45","15:00","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-01","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-02","16:00","23:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-06","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-07","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-08","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-09","16:30","19:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","install PVE marktgolbach","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-13","11:30","14:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-14","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-16","18:00","21:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-18","14:00","17:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hilfestellung SW Friedberg","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-20","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-21","18:00","21:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-22","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Mit Nofalleinsatz bei den SW Friedberg","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-23","17:00","20:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-02-28","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-01","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-01","14:53","17:53","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-02","00:00","00:30","1800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","cmk durchsicht","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-02","09:08","10:38","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Call Alvaro","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-03","20:05","00:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-04","00:35","03:05","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-06","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Betreuung SW-Friedberg bei Serverumzug","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-07","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ab 13 Uhr Workshop","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-08","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Notfalleinsatz PMH","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-11","13:00","20:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","UCS Upgrade Hubrich","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-13","12:00","15:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-14","01:30","02:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","cmk durchsicht","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-14","10:00","13:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-14","22:44","05:59","26100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Upgrade Alptekin","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-15","14:00","15:45","6300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-16","17:00","21:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-19","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-20","10:00","12:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-22","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","PVE Goldbach","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-23","00:00","01:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","pve goldbach","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-27","11:15","14:15","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-27","23:00","00:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-28","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-29","10:30","14:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-29","15:30","17:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-03-31","17:00","19:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-03","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-04","10:00","12:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-04","20:50","21:50","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Noteinsatz LWNET","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-05","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-10","16:00","18:25","8700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Sonn- und Feiertage","Notfalleinsatz SEDC","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-11","17:00","06:50","49800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","UCS Update LEISTER","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-17","14:15","18:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","kontrolle cmk/zammad","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-17","19:15","21:15","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","kontrolle cmk/zammad","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-18","11:00","14:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-19","10:00","14:15","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-19","14:45","16:45","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-22","23:23","01:53","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","sysops OPNsense Fehler","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-24","14:33","18:03","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-25","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-26","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-26","16:00","17:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-04-28","15:30","20:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-03","11:00","13:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-03","14:00","17:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-04","22:40","00:20","6000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-05","15:00","18:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-09","09:00","14:30","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-10","10:00","12:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-10","13:45","16:45","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-12","15:30","19:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC Firewall","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-12","22:00","02:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-15","12:00","14:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-15","20:30","00:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Ansible Einrichtung","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-16","10:00","14:31","16260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-17","12:00","14:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-17","20:00","23:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Schulung Ansible","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-19","17:00","21:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","SEDC","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-22","11:15","14:15","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-23","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-24","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-24","14:00","17:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-25","18:35","20:35","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-26","08:30","10:00","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-30","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-05-31","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-01","21:00","02:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-02","16:30","21:25","17700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-06","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-07","10:00","14:15","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-08","19:00","20:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-09","09:00","10:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","telco markus","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-12","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-13","10:30","18:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-14","10:20","13:20","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-15","00:00","01:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-15","17:30","21:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-19","08:15","08:45","1800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-19","11:15","12:15","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-21","09:30","17:31","28860","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-23","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-26","09:25","14:25","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-27","09:25","14:25","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-28","09:25","13:40","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-06-29","17:00","20:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-03","11:30","13:05","5700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-03","22:00","01:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-04","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-04","23:57","04:27","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC Backup","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-05","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-07","15:00","18:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-10","09:30","13:58","16080","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-10","22:00","00:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","ticket","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-11","09:45","13:55","15000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-12","09:30","15:15","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-13","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-14","13:00","17:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-17","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-17","14:05","19:00","17700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-18","09:15","13:30","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-18","22:45","01:45","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hubrich repl auf neuen server; Achenbach PVEAIB Plattentausch","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-19","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-19","15:15","16:45","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-19","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Telco Markus PVE Tierärzte","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-20","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-24","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-24","15:45","20:00","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-25","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-26","10:15","18:00","27900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-27","09:30","13:15","13500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-27","18:30","19:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Allgöwer RaidController","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-28","09:15","12:45","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-07-31","10:00","14:21","15660","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-01","09:30","12:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-02","09:30","13:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-02","17:20","22:20","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-04","10:30","20:30","36000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-06","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hilfestellung bei Univention Update","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-07","09:15","13:45","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-08","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hubrich , Umstellung und Inbetriebnahme mit Chriz","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-09","09:45","13:15","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-09","14:15","17:15","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Telco Markus Helmke Ansible Weiterentwicklung","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-14","10:30","14:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-15","09:35","13:05","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-16","09:15","10:15","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-16","13:00","17:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-17","10:00","11:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-19","17:00","23:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","UCS UPGRADE Huebnethal","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-21","11:45","14:45","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-23","09:30","17:30","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-28","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-29","09:15","13:45","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-29","21:45","02:25","16800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","SEDC REPL Fehlerbeseitigung","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-08-30","10:15","17:28","25980","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-04","11:15","14:54","13140","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-05","09:00","09:45","2700","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-05","11:00","14:35","12900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-05","22:25","00:40","8100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-06","09:15","10:15","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-06","12:15","17:15","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-11","13:00","15:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-12","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","mit 1h Workshop war bis 15:30 dabei","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-13","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-18","11:15","15:00","13500","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-19","09:35","14:25","17400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-20","09:05","13:14","14940","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-21","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-22","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-25","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-26","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-27","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-27","14:30","15:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-09-29","16:00","22:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Datenübertrag Hirdes von alt nach neu mit Rummel AG","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-02","11:15","13:15","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-02","13:25","15:25","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-04","09:15","13:00","13500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-09","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-10","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-11","09:45","13:35","13800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-11","14:00","17:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-12","16:30","20:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-16","11:45","13:45","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-17","12:15","16:00","13500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-18","10:00","14:35","16500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","45 min Telco Chriz","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-19","11:15","14:05","10200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-19","16:30","19:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-20","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-23","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-23","14:30","15:00","1800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-24","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-25","09:30","15:15","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Chriz RZ, Goldbach HA Cluster fixen","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-25","15:00","17:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","1,5 Std Telco Chriz RZ Bespräche nach Umbau","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-26","09:00","10:45","6300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Telco Markus wegen Alptekin","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-27","16:00","18:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco treml Pachmayr","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-30","11:35","16:15","16800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","3 h Einrichtung OPNsense mit Philipp Kress Firewall","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-10-31","09:05","14:54","20940","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-01","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise nach Aschaffenburg","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-02","10:15","18:15","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Arbeit in Aschaffenburg","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-03","10:15","16:30","22500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Heimreise von Aschaffenburg","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-06","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-07","09:15","15:00","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ab 13:30 Beratung DIMA Datentechnik","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-08","10:00","12:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Beratung DIMA Datentechnik","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-09","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-14","09:15","13:45","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-20","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-21","10:15","14:30","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-21","23:50","00:50","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-22","09:45","13:15","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-22","15:00","16:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-23","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-24","00:45","02:45","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Sicherheitsupdate ZFS - SEDC","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-24","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-27","09:30","12:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-27","23:00","01:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","PVE Update Fincare","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-28","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-11-29","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-04","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-05","23:45","01:00","4500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","PVE Updates Achenbach","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-06","10:30","17:00","23400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-07","15:30","17:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-11","11:15","14:15","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","OPNsense Einrichtung Kress mit Chriz","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-12","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-13","12:00","13:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","KRESS OPNsense mit Chriz","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-13","13:30","16:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kress OPNsense mit Chriz","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-18","09:30","13:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-19","10:00","13:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-19","13:00","17:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","WORKSHOP TRMM","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-20","10:00","13:54","14040","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-21","21:46","22:46","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-22","09:06","17:27","30060","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-27","10:30","16:35","21900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-28","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise AB","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-29","10:30","12:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Community Server RZ EWG","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-29","12:45","18:00","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2023-12-30","12:00","16:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise nach Heimatanschrift","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-02","10:00","15:34","20040","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-03","10:15","18:05","28200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-04","10:30","16:30","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","OPNsense Einrichtung und in Betriebnahme","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-05","09:15","13:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Nacharbeiten und Doku von gestern, vorerst Abschluss Martens","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-05","20:51","00:42","13860","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Doku Wideflex","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-07","21:30","23:32","7320","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Sonn- und Feiertage","Sicherung Wideflex","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-08","09:12","10:57","6300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kontrolle Wideflex Backups und Anpassung","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-08","15:15","17:15","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-09","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk ausmisten && frage hubrich nextcloud","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-10","10:00","15:15","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Telco DIMA ZFS 3.5h && Telco 45min Markus","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-11","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Telco Winzig 2h , telco Markus","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-11","20:00","23:45","13500","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Update PVE FFW Goldbach mit Markus","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-12","14:00","19:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk ausmissten & ticktes & 2.5h Nextcloud Hubrich","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk ausmissten","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-16","09:20","14:50","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk & Orth Ticket 4.75h","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-16","21:20","01:20","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Ortho","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-17","10:15","13:45","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","orth","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-18","16:30","20:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-22","11:30","14:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Orth Repl Einrichtung PVE2 und VM Umzug","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-23","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Prüfung Daten PVE2 Orth 1,5h && Ticktes","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-24","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1,5 h Orth Umzug && 2h Dima Beratung","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-24","15:00","17:34","9240","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Orth","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-26","15:08","19:08","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-27","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-29","10:00","14:40","16800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-30","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kontrolle Repl Orth 0.5 & 1.5 h telco mc alice","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-30","22:30","00:57","8820","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-31","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","cmk","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-01-31","15:00","17:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","cmk ausmisten","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-02","15:30","21:15","20700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Wideflex & tickets & cmk","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-03","15:41","16:48","4020","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-05","11:05","15:04","14340","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-06","10:00","14:56","17760","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-06","22:45","23:45","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-07","10:00","12:15","8100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","2h envy","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-07","13:00","17:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","2 h envy","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-08","16:15","21:45","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-09","15:00","19:23","15780","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-12","10:00","12:41","9660","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-12","13:06","14:49","6180","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-13","10:00","12:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Beratung Backup und Betrachtung Kunden bei DIMA","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-14","09:45","13:00","11700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1.5h meeting mit Marcel und Björn","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-14","14:30","17:15","9900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-19","11:15","14:45","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-20","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-22","17:00","21:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-26","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-27","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1h Martens & 1,5h Telco Marcel u Björn & 1,5h Workshop","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-28","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco Marcel und Björn & 1h envy","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-02-28","14:00","17:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1h envy & 2h martens mit begleitet","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-04","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-05","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Krankenvertretung für Chriz","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-06","09:00","10:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Krankenvertretung für Chriz","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-06","11:00","18:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Krankenvertetung für Chriz","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-07","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Krankenvertretung für Chriz","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-08","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-11","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-12","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-13","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-14","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-18","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-19","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-20","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-21","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-22","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-25","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-26","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-03-27","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","Urlaub 2023","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-02","09:15","13:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-02","23:50","01:25","5700","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Martens && Achenbach","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-03","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-05","15:15","19:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-08","09:15","13:15","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-09","09:45","13:35","13800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-10","09:30","17:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-15","11:30","14:15","9900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-16","09:30","13:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","meeting marcel und björn aufräumen cmk","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-17","09:30","13:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-17","13:30","16:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Meeting Chriz und den Jungs Sicherheitscheckliste wahzu","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-18","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","3h Allgoewer","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-19","15:00","23:30","30600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-22","09:08","14:34","19560","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-23","09:30","13:45","15300","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco koeser && telco marcel && telco knaack 0.75h","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-23","14:15","16:05","6600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","telco chriz &marcel prüfung upgrade leister ts2022","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-24","10:00","13:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","telco marcel && alptekin surface in ad einbinden mit erklärung","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-24","14:00","17:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-29","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-04-30","09:45","13:45","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco marcel wegen leister && hirdes backup","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-02","18:00","21:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-06","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","check kontrolle & 3,5h Koser","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-06","22:15","00:11","6960","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","1,25 h koeser","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-10","09:35","13:35","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco knaack","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-10","14:00","18:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","telco knaack","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-13","11:30","14:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-15","10:00","13:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-15","14:30","17:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","renofloor","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-16","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ancora","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-17","15:30","00:30","32400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Koeser","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-18","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","koeser","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-21","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","normal Arbet & 1 h Workshop","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-22","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","3h CVC Teleco Hirdes","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-27","12:00","14:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-28","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-29","12:30","16:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-30","13:44","17:45","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-30","14:00","19:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Hyper V Ablöse Allgöwer","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-31","13:44","17:45","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-05-31","15:00","22:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Probelme mit dem Umzug bei Knaack","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-03","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","kontrolle CMK &Mail & telco schambach DT-Computer","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-04","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","0.5 telco chriz wegen sedc & 5.5 h sedc & 1h workshop zamba ad","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-10","09:35","13:00","12300","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-10","13:30","14:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-11","09:45","12:00","8100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-11","13:00","14:45","6300","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-12","09:34","17:34","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-14","16:15","18:45","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-16","12:30","16:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise nach AB","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-17","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Vorbereitung Workshop in AB","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-18","09:00","18:00","32400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Workshop","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-19","09:00","16:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Workshop","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-20","10:30","18:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","arbeiten im Loft","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-21","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","arbeiten im Loft","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-21","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise von AB nach MD","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-24","09:45","15:00","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","15 min Mails && 5 h sedc","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-25","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-06-26","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","sedc","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-01","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-02","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","sedc 4,5h","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-03","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","cmk &mails","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-04","11:00","15:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","renee cmk","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-05","15:00","19:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","sedc","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-15","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-16","17:00","21:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-17","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-18","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-22","10:00","14:54","17640","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-23","09:45","14:15","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-29","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-30","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-07-31","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-02","16:00","18:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-12","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-13","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-14","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-14","22:00","23:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Absprache mit Hubrich wegen OPnsense umstellung","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-15","16:30","18:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-19","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-21","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-22","02:44","04:45","7260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-26","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-27","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-28","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","OPNsense Renofloor","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-29","17:00","20:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-08-30","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-02","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","sedc ispconfig","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-03","12:01","16:02","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-04","12:01","19:02","25260","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-05","12:01","14:02","7260","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-06","12:01","16:02","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-09","12:02","16:02","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-10","12:02","16:02","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-11","12:02","19:02","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-12","12:02","14:02","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-13","12:02","16:02","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-16","10:30","14:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-17","10:30","14:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-18","10:30","14:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-23","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-24","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-25","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-26","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-27","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-30","10:00","15:15","18900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-09-30","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-01","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-02","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-03","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-04","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-07","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-08","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-09","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-10","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-11","13:51","17:52","14460","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-14","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-15","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-16","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-17","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-18","13:45","17:46","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-21","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-22","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-23","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-24","10:39","16:39","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-28","10:00","16:30","23400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-28","17:30","21:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Koeser","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-29","09:00","11:45","9900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-10-30","10:30","13:15","9900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-04","10:02","14:47","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-04","21:38","22:43","3900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-06","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","2h ancora 3,5h sedc","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-11","10:00","14:44","17040","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","check mk 1h, 1,5h Arbeitsanweisungen, 1 h Streaming einstellen","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-12","13:00","17:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Workshop Proxmox mit ZFS","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-13","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-14","13:00","18:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Workshop Proxmox mit ZFS","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-18","10:00","12:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","CMK Pflege","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-18","12:30","14:00","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","anacora","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-19","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-20","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-21","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-21","17:00","17:00","0","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-25","13:43","17:44","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-26","13:43","17:44","14460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-27","13:43","20:44","25260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-28","13:43","15:44","7260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-29","13:43","15:44","7260","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-11-30","13:43","14:44","3660","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-02","09:30","14:56","19560","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-03","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-04","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-05","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-05","20:00","23:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-06","15:30","19:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-08","19:00","23:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise von MD nach AB","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-09","10:04","18:04","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-10","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","ab 12:30 Workshop","Nein","Ja","Workshop","timesheet","work","","",""
|
||||||
|
"2024-12-11","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-12","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","ab 12:30 Workshop","Nein","Ja","Workshop","timesheet","work","","",""
|
||||||
|
"2024-12-13","10:30","15:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-13","15:30","19:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise von AB nach MD","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-16","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-17","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-18","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-19","16:00","20:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-20","14:45","17:00","8100","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2024-12-23","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-02","10:00","11:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Warten auf Chef, abarbeiten von cmk","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-02","17:00","20:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Tickets","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-03","15:00","20:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dokumentation Backup Revision","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-07","10:00","14:40","16800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-08","10:00","17:14","26040","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-09","13:10","17:10","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-10","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-13","09:46","14:46","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-14","09:50","14:57","18420","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-15","10:00","17:25","26700","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-16","15:45","19:45","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-17","15:00","19:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-20","09:30","14:45","18900","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-21","09:45","15:00","18900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-22","09:43","17:13","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-27","09:36","14:58","19320","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-28","09:44","14:44","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-29","10:05","17:10","25500","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-30","16:00","18:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-01-31","15:25","16:25","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-03","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-04","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-05","10:19","17:19","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-06","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-10","09:50","14:50","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Martens","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-11","09:55","14:00","14700","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-12","10:00","17:28","26880","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-13","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-14","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-17","09:30","14:53","19380","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-18","10:15","15:00","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-19","09:53","17:53","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-20","10:30","14:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-24","12:38","16:38","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-25","12:38","16:38","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-26","09:13","13:13","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-27","12:38","16:38","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-02-28","12:38","16:38","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-03","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-04","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-05","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-06","11:00","15:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-07","10:15","14:15","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-10","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-11","09:14","13:45","16260","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-12","09:00","15:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-13","10:15","16:15","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-14","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-17","10:15","14:45","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-18","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-19","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-20","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-24","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-25","13:15","18:45","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-26","10:30","17:30","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Komplett Einrichtung Aristhodos","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-26","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Ticket kontrolle","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-03-31","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-04-01","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-04-02","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-04-03","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-04-07","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-04-08","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-04-09","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-04-10","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-04-14","09:45","14:45","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-05","09:30","14:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-06","09:45","14:15","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-07","09:45","17:00","26100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-08","09:45","16:30","24300","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-09","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-12","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-13","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-14","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-15","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-16","16:30","17:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-19","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-20","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-20","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-22","09:39","17:00","26460","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-26","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-27","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-05-28","09:30","17:30","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-01","16:00","21:30","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Heimatadresse > Anreise Hotel Moers","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-02","08:30","17:00","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Anreise Lehrgang KRZN","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-02","21:18","22:48","5400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-03","08:30","17:00","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Anreise Lehrgang KRZN","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-03","21:15","23:15","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-04","08:30","17:00","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Anreise Lehrgang KRZN","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-04","21:00","23:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-05","08:30","17:00","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Anreise Lehrgang KRZN","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-05","23:18","00:18","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-06","08:30","14:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Abreise Lehrgang >Heimatadresse","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-09","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-10","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-11","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-12","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-15","17:00","21:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Heimatadresse > KRZN","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-15","22:00","23:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-16","08:00","17:00","32400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","KRZN Lehrgang","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-16","20:00","22:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-17","08:00","17:00","32400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","KRZN Lehrgang","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-18","09:00","15:30","23400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","KRZN > Heimtadresse","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-19","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Sonn- und Feiertage","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-23","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-24","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-25","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-26","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-27","11:00","16:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-06-30","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-01","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-02","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-03","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-04","15:00","16:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-06","17:00","22:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Heimatadresse > KRZN","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-07","08:00","17:00","32400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-07","22:00","23:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-08","08:00","18:00","36000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-08","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-09","08:00","16:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-10","08:00","16:30","30600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-10","22:00","23:30","5400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-11","10:00","15:30","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Mettmann >>> Heimatadresse","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-14","10:00","14:45","17100","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-07-15","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-04","10:00","14:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-06","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-07","12:30","17:00","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-08","18:00","02:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kahle","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-11","09:00","15:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-11","21:30","01:00","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Kontrollen","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-12","09:00","15:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-12","21:00","01:30","16200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Kress repl umstellung","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-13","09:00","17:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-13","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-14","09:30","17:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-18","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-18","22:00","00:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-19","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-19","22:00","01:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-20","09:30","17:30","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-20","23:00","01:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-21","10:00","16:30","23400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-24","21:00","04:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","KRESS TRMM WAZUH CMK, kontrollen","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-25","09:00","15:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-26","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-27","22:00","01:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","check mk ausmisten","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-08-28","09:00","14:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-01","09:15","14:15","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-01","21:45","00:30","9900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-02","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-02","22:00","01:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-03","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-03","15:30","18:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-04","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-04","15:30","18:00","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-08","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-09","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-10","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-11","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-12","15:05","19:05","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-15","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-16","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-17","09:45","17:15","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-18","11:30","13:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-18","15:30","18:30","10800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-19","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-22","08:30","12:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Heimatanschrift > ULM","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-22","12:30","17:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Heimatanschrift > ULM","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-23","08:30","12:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ULM","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-23","12:30","17:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","ULM","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-24","08:30","12:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ULM","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-24","12:30","18:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","ULM","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-25","08:30","12:30","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ULM","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-25","12:30","18:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","ULM","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-26","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","ULM > Heimatanschrift","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-09-26","14:00","17:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","ULM > Heimatanschrift","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-03","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-04","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-05","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-06","09:30","16:30","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-07","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-07","22:30","23:30","3600","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Überstunden oder Nachtarbeit","Koeser PVE9 Update","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-10","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-10","22:00","23:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Überstunden oder Nachtarbeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-11","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-12","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-13","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-14","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-17","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-18","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-19","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-20","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-21","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-24","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-25","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-26","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-11-27","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-01","00:00","04:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-02","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-03","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-04","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-05","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-08","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-09","22:51","02:51","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-10","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-12","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-15","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-16","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-17","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-18","10:00","18:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-19","15:00","16:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-22","10:00","13:30","12600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-22","16:00","18:30","9000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-23","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-24","10:00","12:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-26","09:00","13:00","14400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-26","10:00","15:00","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-29","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-30","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2025-12-30","21:00","22:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-01-02","00:03","04:03","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-01-05","10:00","14:00","14400","Patrick Perlbach","patrick","sysops","Urlaub","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-01-07","10:00","17:30","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-01-08","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-01-09","09:15","14:15","18000","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-01-09","17:00","00:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Allgoewer","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-02-17","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-02-17","15:30","17:30","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-02-17","21:00","23:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-02-23","09:00","17:00","28800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-02-24","10:00","17:00","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-02-25","08:30","17:30","32400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-02-26","08:35","18:05","34200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-02-27","10:00","16:00","21600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Dienstreise","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-02","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-03","09:15","16:15","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-03","19:00","22:00","10800","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kress Kontrolle Update KGPM237","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-04","09:15","17:00","27900","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-05","15:00","17:00","7200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-05","17:00","02:30","34200","Patrick Perlbach","patrick","sysops","Leistungen für Kunden","Reguläre Arbeitszeit","Kress Umbau Offenbach","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-06","16:00","17:00","3600","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-09","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-10","09:15","16:15","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-11","09:30","16:00","23400","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Chef Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-12","09:45","17:15","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","Chriz Urlaub","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-16","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-17","09:15","16:15","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-18","09:30","17:00","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-19","09:45","17:15","27000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-23","09:30","15:00","19800","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-24","09:30","16:30","25200","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-25","09:15","17:30","29700","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
"2026-03-26","09:30","14:30","18000","Patrick Perlbach","patrick","sysops","Leistungen für sysops","Reguläre Arbeitszeit","","Nein","Ja","","timesheet","work","","",""
|
||||||
|
@@ -0,0 +1,347 @@
|
|||||||
|
# TimeMaster – Projektkontext für Claude Code
|
||||||
|
|
||||||
|
## Was ist das?
|
||||||
|
Zeiterfassung & HR-Tool (ähnlich Timebutler.de). DSGVO-konform, EU-Server.
|
||||||
|
|
||||||
|
## Tech-Stack
|
||||||
|
- **Backend**: Python 3.12 · FastAPI · SQLAlchemy (async) · PostgreSQL 16 · Redis 7
|
||||||
|
- **Frontend**: React 18 · TypeScript · Tailwind CSS
|
||||||
|
- **Deployment**: Nativ auf Ubuntu (systemd + nginx) – kein Docker in Phase 1
|
||||||
|
- **Tests**: pytest + pytest-asyncio
|
||||||
|
|
||||||
|
## Aktueller Stand
|
||||||
|
|
||||||
|
### ✅ agent-01-auth (FERTIG)
|
||||||
|
- FastAPI App, CORS, Health-Endpoint
|
||||||
|
- JWT Access Token (30 min) + Refresh Token Rotation (30 Tage)
|
||||||
|
- 5 Rollen: SUPER_ADMIN, COMPANY_ADMIN, HR, MANAGER, EMPLOYEE
|
||||||
|
- `require_role()` FastAPI Dependency
|
||||||
|
- Models: Company, Department, User, Session, PasswordReset, AuditLog
|
||||||
|
- Routers: /api/v1/auth · /api/v1/users · /api/v1/companies
|
||||||
|
- Services: auth_service, user_service, email_service
|
||||||
|
- Alembic Migration: 0001_initial (alle Auth-Tabellen)
|
||||||
|
- 20+ pytest Tests
|
||||||
|
|
||||||
|
### 🚧 agent-02-zeiterfassung (TODO – Woche 3-4)
|
||||||
|
Dateien anlegen in:
|
||||||
|
- `backend/app/models/time_entry.py`
|
||||||
|
- `backend/app/models/work_schedule.py`
|
||||||
|
- `backend/app/schemas/time_entry.py`
|
||||||
|
- `backend/app/routers/time_entries.py`
|
||||||
|
- `backend/app/services/time_service.py` (inkl. ArbZG-Prüfung)
|
||||||
|
- `backend/migrations/versions/0002_time_entries.py`
|
||||||
|
- `backend/tests/test_time.py`
|
||||||
|
|
||||||
|
API-Endpunkte:
|
||||||
|
```
|
||||||
|
POST /api/v1/time/stamp-in
|
||||||
|
POST /api/v1/time/stamp-out
|
||||||
|
POST /api/v1/time/break-start
|
||||||
|
POST /api/v1/time/break-end
|
||||||
|
GET /api/v1/time/today
|
||||||
|
GET /api/v1/time/entries
|
||||||
|
POST /api/v1/time/entries
|
||||||
|
PATCH /api/v1/time/entries/{id}
|
||||||
|
POST /api/v1/time/entries/{id}/approve
|
||||||
|
POST /api/v1/time/entries/{id}/reject
|
||||||
|
GET /api/v1/time/balance/{user_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ✅ agent-03-abwesenheit (FERTIG – siehe weiter unten)
|
||||||
|
|
||||||
|
### 🚧 agent-02-kiosk (TODO – Woche 5, nach agent-02)
|
||||||
|
- `backend/app/models/kiosk_device.py`
|
||||||
|
- `backend/app/routers/kiosk.py`
|
||||||
|
- `backend/app/services/kiosk_service.py`
|
||||||
|
Auth-Methoden: PIN · NFC (Web NFC API) · QR (jsQR) · Mitarbeiter-Liste
|
||||||
|
|
||||||
|
### ✅ agent-03-abwesenheit (FERTIG – deployed)
|
||||||
|
- Models: AbsenceType, Absence, VacationBalance, PublicHoliday, OvertimeBalance
|
||||||
|
- Rollen-basierte Genehmigung (MANAGER/HR/ADMIN)
|
||||||
|
- Urlaubskonto: Grundurlaub + Sondertage + Resturlaub (auto carry-over)
|
||||||
|
- Frontend: AbsencesPage mit Liste-Tab + Jahresplaner (Person×Monat-Grid + Monats-Gantt)
|
||||||
|
- TOTP/2FA: pyotp, QR-Setup, partial JWT Login-Flow, Profil-Seite
|
||||||
|
- Migrationen: 0015 (TOTP), 0016 (special_days) deployed
|
||||||
|
|
||||||
|
### ✅ agent-05-krankmeldung (FERTIG – deployed 2026-05-06)
|
||||||
|
Erweiterung des Abwesenheits-Moduls speziell für Kranktage:
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `Company.sick_note_required_after_days` (Default 3) – Firmen-Default
|
||||||
|
- `AbsenceType.certificate_after_days` als Per-Typ-Override (Override gewinnt)
|
||||||
|
- Auto-Berechnung `certificate_required_by` beim Anlegen einer SICK-Absence
|
||||||
|
- `POST /absences/quick-sick` – Sofort-Krankmeldung (auto-approved, nutzt ersten aktiven SICK-Typ)
|
||||||
|
- `PATCH /absences/{id}/certificate` – HR/Admin markiert Attest als eingegangen
|
||||||
|
- `GET /absences/sick-stats?user_id=&ref_date=` – Bradford-Faktor (rolling 12 Monate), Episoden, Tage, AU-überfällig
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- 🤒 "Krank melden" Schnell-Button im AbsencesPage-Header (orange, neben "+ Antrag stellen")
|
||||||
|
- AU-überfällig-Badge in Liste (orange) + grüner "Attest ✓"-Badge wenn eingegangen
|
||||||
|
- HR/Admin-Action "Attest erhalten" in Liste (sichtbar für HR/COMPANY_ADMIN/SUPER_ADMIN, **nicht** MANAGER)
|
||||||
|
- ReportsPage: vierter Tab "Krankmeldungen" mit KPIs (⌀ Bradford, Σ Tage, Σ Episoden, AU offen) + sortierte Tabelle pro Mitarbeiter
|
||||||
|
- Bradford-Heatmap aufgeschoben
|
||||||
|
|
||||||
|
**Migration:** `0022_sick_note_config.py` – `companies.sick_note_required_after_days INTEGER DEFAULT 3` (chained on 0020, da 0017 nicht in der Alembic-Kette war)
|
||||||
|
|
||||||
|
**Tests:** 5 neue pytest-Cases in `test_absences.py` (quick_sick, cert auto-calc, type override, mark_certificate, bradford) – alle grün
|
||||||
|
|
||||||
|
### 🚧 agent-06-auditlog (TODO)
|
||||||
|
AuditLog-Einträge sind bereits in der DB (Tabelle `audit_logs`, Model vorhanden seit agent-01).
|
||||||
|
Fehlend: Backend-Endpunkt + Frontend-Seite für SUPER_ADMIN / COMPANY_ADMIN.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- Neuer Endpunkt: `GET /audit-logs/` mit Filtern:
|
||||||
|
- `user_id`, `action`, `entity_type`, `date_from`, `date_to`, `limit`, `offset`
|
||||||
|
- Nur COMPANY_ADMIN/SUPER_ADMIN, company-isoliert
|
||||||
|
- Felder die zurückgegeben werden: `id`, `user_id`, `user_name`, `action`, `entity_type`, `entity_id`, `old_value`, `new_value`, `ip_address`, `created_at`
|
||||||
|
- Router: `backend/app/routers/audit.py`
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- Neue Seite: `frontend/src/pages/AuditLogPage.tsx`
|
||||||
|
- Route: `/audit-log` (nur COMPANY_ADMIN, SUPER_ADMIN)
|
||||||
|
- Filter: Zeitraum, Benutzer, Aktion, Entity-Typ
|
||||||
|
- Tabelle: Zeitstempel, Benutzer, Aktion, Betroffenes Objekt, IP
|
||||||
|
- Detail-Expand: old_value / new_value als JSON-Diff
|
||||||
|
- Export als CSV
|
||||||
|
- Navigation: Eintrag in Einstellungen-Dropdown in Layout.tsx
|
||||||
|
|
||||||
|
### 🚧 agent-04-dashboard (TODO – Woche 6-7)
|
||||||
|
- Mitarbeiter-, Manager-, Admin-Dashboard
|
||||||
|
- Reports: Anwesenheit, Abwesenheit, Überstunden, Kranktage (Bradford-Faktor)
|
||||||
|
- Export: PDF (WeasyPrint) · CSV · XLSX (openpyxl)
|
||||||
|
|
||||||
|
### 🚧 agent-07-personalnummer (Phase 1 ✅ deployed 2026-05-05; Phase 2-5 offen)
|
||||||
|
Eindeutige Personalnummer pro Mitarbeiter, in allen API-Antworten, Exports und Kiosk-Login nutzbar.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `User.personnel_number: str(50) | None` – Format **nur Ziffern** (`^[0-9]+$`), max 50 Stellen
|
||||||
|
- Partial Unique Index: `UNIQUE (company_id, personnel_number) WHERE personnel_number IS NOT NULL`
|
||||||
|
- **Reservierung**: Bei Deaktivierung bleibt Nr. am User, wird nie wieder vergeben
|
||||||
|
- `Company`-Felder:
|
||||||
|
- `personnel_number_required: bool` (default false → Pflicht erst nach Aktivierung in Firmen-Settings; gilt nur für **neue** User, Bestandsuser bleiben)
|
||||||
|
- `personnel_number_mode: enum('manual'|'auto')` (default manual)
|
||||||
|
- `personnel_number_next: int` (Counter, beginnt bei 1, 4-stellig zero-padded → `0001`, kein Präfix wegen „nur Ziffern"-Regel)
|
||||||
|
- Auto-Modus erlaubt manuelles Override durch Admin (Counter läuft normal weiter)
|
||||||
|
- Race-Condition-Schutz: `UPDATE companies SET personnel_number_next = personnel_number_next + 1 RETURNING ...` (atomic)
|
||||||
|
- Endpoints:
|
||||||
|
- `GET /users/next-personnel-number` – Vorschlag basierend auf Counter
|
||||||
|
- `GET /users/by-personnel/{number}` – Lookup für externe Integrationen
|
||||||
|
- `GET /users/import-template.csv` – Vorlage für Bulk-Import
|
||||||
|
- `POST /users/import` (multipart CSV) – Bulk-Import:
|
||||||
|
- Doppelte E-Mail im Import (zwei Zeilen) → Fehler
|
||||||
|
- E-Mail existiert aktiv → Fehler
|
||||||
|
- E-Mail existiert aber User deaktiviert → **Reaktivieren mit neuen Daten**
|
||||||
|
- Leere Personalnr. → Auto-Vergabe (auch im Manuell-Modus)
|
||||||
|
- `GET /users` Query-Param `search` filtert auch Personalnr.
|
||||||
|
- AuditLog bei jeder Personalnr.-Änderung
|
||||||
|
- LDAP-Sync: bei Konflikt mit reservierter/vergebener Nr. → Fehler, LDAP-Wert wird verworfen (kein Override)
|
||||||
|
- Personalnr. in CSV/XLSX/PDF-Exports (`reports.py`), CalDAV-Template-Platzhalter `$personalnummer`, LDAP-Sync-Mapping `employeeNumber`
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
- `UsersPage`: Spalte "Pers.-Nr.", Suche schließt Personalnr. ein, Edit-Modal mit Live-Verfügbarkeitsprüfung
|
||||||
|
- Invite-Modal: Eingabefeld + "Vorschlagen"-Button (Auto-Modus)
|
||||||
|
- `ProfilePage`: Read-only Anzeige
|
||||||
|
- `CompanySettingsPage`: Toggles Pflicht/Modus/Präfix/Counter
|
||||||
|
- `ReportsPage` + Exports: Spalte ergänzt
|
||||||
|
- CSV-Import-UI mit Vorlagen-Download und Validierungs-Vorschau
|
||||||
|
|
||||||
|
**Migration:** `0020_personnel_number.py`
|
||||||
|
- `users.personnel_number VARCHAR(50) NULL` + Partial Unique Index + CHECK constraint `~ '^[0-9]+$'`
|
||||||
|
- `companies.personnel_number_required BOOLEAN DEFAULT FALSE`
|
||||||
|
- `companies.personnel_number_mode VARCHAR(10) DEFAULT 'manual'`
|
||||||
|
- `companies.personnel_number_next INTEGER DEFAULT 1`
|
||||||
|
|
||||||
|
**Tests**:
|
||||||
|
- pytest: Unit/Integration für Service-Logik, CSV-Import, Race-Condition (parallele Inserts)
|
||||||
|
- Playwright-Setup wird im Rahmen von agent-08 aufgesetzt – CSV-Upload-UI dann mit testen
|
||||||
|
|
||||||
|
### 🚧 agent-08-kiosk-haertung (TODO – kritisch vor Produktiv-Einsatz)
|
||||||
|
MITM-resistente Absicherung des Kiosk-Modus. Aktueller Token-basierter Auth ist nicht ausreichend gegen TLS-Inspection-Proxies und Insider-Angriffe (siehe Security-Audit).
|
||||||
|
|
||||||
|
**Architektur-Entscheidung: Browser bleibt Kiosk-Plattform** – native App mittelfristig, aber nicht in dieser Phase. Trust-Boundary: IT-Admin der Kiosk-Geräte.
|
||||||
|
|
||||||
|
**Krypto-Entscheidung: Ed25519-Public-Key statt mTLS+HMAC**
|
||||||
|
Statt mTLS-Client-Zertifikate + HMAC-Shared-Secret nutzt jedes Kiosk-Gerät ein **Ed25519-Keypair** (gleicher Algo wie moderne SSH-Keys). Public Key wird beim Server registriert, Private Key bleibt non-extractable im Kiosk-Browser. Vorteile: kein CA-Management, kein Shared Secret, Remote-Enrollment trivial via CLI auf Server, einfacheres nginx (kein mTLS), kryptographisch gleichwertig oder besser.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- `KioskDevice` erweitern:
|
||||||
|
- `status: enum('pending','approved','revoked')` (löst `is_active` ab)
|
||||||
|
- `public_key: text` – Ed25519 Public Key (PEM oder OpenSSH-Format)
|
||||||
|
- `key_algorithm: str default 'ed25519'` (Vorbereitung für spätere Algos)
|
||||||
|
- `last_heartbeat_at: datetime | None`
|
||||||
|
- `client_version: str | None`
|
||||||
|
- `current_user_id: uuid | null` (wer aktuell eingestempelt ist – DSGVO: pro Firma deaktivierbar)
|
||||||
|
- `offline_queue_size: int default 0`
|
||||||
|
- `ip_whitelist: text | null` (CIDR-Liste, in Production verpflichtend)
|
||||||
|
- `enrollment_token_hash: str | None` + `enrollment_expires_at` (Setup-URL, max 30 min – nicht Token, nur Geräte-ID-Bindung)
|
||||||
|
- `Company.kiosk_require_approval: bool default true`
|
||||||
|
- `Company.kiosk_track_current_user: bool default true` (DSGVO-Opt-Out)
|
||||||
|
|
||||||
|
**Sichere Stempel-Auth pro Request** (neue Dependency `verify_kiosk_request()`):
|
||||||
|
- TLS-Pflicht: nginx HTTPS-only + HSTS, App lehnt non-TLS in Production ab
|
||||||
|
- HTTP-Header pro Request:
|
||||||
|
- `X-Kiosk-Key-Id`: UUID des Geräts
|
||||||
|
- `X-Kiosk-Timestamp`: Unix-Sekunden (max 30s Drift; Server-Time autoritativ)
|
||||||
|
- `X-Kiosk-Nonce`: UUID, einmalig (Replay-Schutz)
|
||||||
|
- `X-Kiosk-Signature`: Base64(Ed25519-Signatur über `METHOD + PATH + TIMESTAMP + NONCE + sha256(BODY)`)
|
||||||
|
- Backend verifiziert mit gespeichertem Public Key (Python: `cryptography.hazmat.primitives.asymmetric.ed25519`)
|
||||||
|
- Nonce-Cache 60s in Redis (Replay-Window = Drift-Window)
|
||||||
|
- IP-Whitelist-Check pro Gerät
|
||||||
|
|
||||||
|
**CLI-Tool (neu: `backend/cli.py`, mit Typer)**:
|
||||||
|
```bash
|
||||||
|
# Auf Server ausführen (root@192.168.1.137)
|
||||||
|
timemaster kiosk add \
|
||||||
|
--company "Acme GmbH" \
|
||||||
|
--name "Eingang Berlin" \
|
||||||
|
--location "Hauptgebäude" \
|
||||||
|
--pubkey ~/.ssh/kiosk_berlin.pub \
|
||||||
|
--ip-whitelist 10.0.0.0/24
|
||||||
|
|
||||||
|
# Output: device_id + Setup-URL für Kiosk-Browser
|
||||||
|
# https://timemaster.example.com/kiosk/setup?id=<uuid>&token=<one-time>
|
||||||
|
|
||||||
|
timemaster kiosk list [--company X] [--status pending|approved|revoked]
|
||||||
|
timemaster kiosk approve <device_id>
|
||||||
|
timemaster kiosk revoke <device_id>
|
||||||
|
timemaster kiosk rotate-key <device_id> # Setup-URL für neuen Pubkey
|
||||||
|
```
|
||||||
|
|
||||||
|
CLI nutzt direkten DB-Zugriff über bestehende SQLAlchemy-Models, keine HTTP-API.
|
||||||
|
|
||||||
|
**Enrollment-Flow** (Remote-tauglich):
|
||||||
|
1. **Vor Ort/Remote**: IT-Person erzeugt auf Kiosk-Gerät ein Ed25519-Keypair (entweder im Browser via WebCrypto oder per `ssh-keygen -t ed25519` lokal). Private Key bleibt am Gerät.
|
||||||
|
2. **Public Key** wird an Admin gesendet (E-Mail/Chat reicht – ist public)
|
||||||
|
3. **Admin** SSH'ed auf Server, registriert per CLI: `timemaster kiosk add --pubkey ...`
|
||||||
|
4. CLI gibt **Setup-URL** + Geräte-ID zurück → Admin schickt URL an IT-Person
|
||||||
|
5. Kiosk-Browser öffnet Setup-URL → bindet bestehenden privaten Key an Geräte-ID, signiert ersten Test-Request
|
||||||
|
6. Admin sieht Gerät in WebGUI als `pending` → Klick „Approve" → ab jetzt aktiv
|
||||||
|
7. **Privater Key verlässt nie das Kiosk-Gerät**, **Server kennt nur Public Key**
|
||||||
|
|
||||||
|
Optional Web-Enrollment-Variante (für lokale Filiale, Admin steht daneben):
|
||||||
|
- Admin generiert in WebGUI Setup-URL → QR-Code → Kiosk scannt → Browser generiert Keypair → Public Key per HTTP-POST an Backend → Admin approved.
|
||||||
|
|
||||||
|
**Key-Rotation**: kein Auto-Rotate (Public Key altert nicht). Manueller Re-Enrollment via `timemaster kiosk rotate-key` falls Verdacht auf Kompromittierung.
|
||||||
|
|
||||||
|
**Heartbeat & Liveness**:
|
||||||
|
- Endpoint `POST /kiosk/heartbeat` alle 30s (signiert wie Stempel-Requests – sonst Fake-Online möglich)
|
||||||
|
- Body: `{ uptime_seconds, current_user_id?, browser_version, queued_offline_entries, client_version }`
|
||||||
|
- Server-Status: **online** (<90s), **stale** (<5min), **offline** (älter)
|
||||||
|
- Heartbeat-Intervall pro Firma einstellbar (default 30s, max 120s)
|
||||||
|
|
||||||
|
**Kiosk-Frontend (separate Route `/kiosk`)**:
|
||||||
|
- ServiceWorker übernimmt Ed25519-Signierung aller Requests
|
||||||
|
- WebCrypto Ed25519 mit `extractable: false` (XSS kann Key nicht exfiltrieren)
|
||||||
|
- Browser-Anforderung: Chrome 113+, Safari 16.4+, Firefox 130+ (alle ab 2023/2024)
|
||||||
|
- Heartbeat-Loop alle 30s
|
||||||
|
- Offline-Queue mit IndexedDB (Sync sobald wieder online) – Queue-Größe an Server reportet
|
||||||
|
- BroadcastChannel zur Tab-Koordination (nur ein Tab sendet Heartbeat)
|
||||||
|
- Kiosk-Login per Personalnummer + PIN als zusätzliche Methode (nur Kiosk, nicht Web)
|
||||||
|
- Server-Time wird im Heartbeat-Response zurückgeliefert → UI zeigt Server-Zeit, Stempel-Timestamp serverseitig erzwungen
|
||||||
|
|
||||||
|
**Frontend-Verwaltung (`KioskDevicesPage`)**:
|
||||||
|
- Tab "Wartet auf Freigabe" + Tab "Aktive Geräte" mit Live-Status (Auto-Refresh 30s)
|
||||||
|
- Status-Ampel-Spalte (🟢/🟡/🔴), letzter Heartbeat, aktueller User, Client-Version
|
||||||
|
- Public-Key-Fingerprint anzeigen (zur Verifikation)
|
||||||
|
- Sortier-/Filter-Option "nur offline"
|
||||||
|
- `Layout.tsx`: Health-Badge oben rechts für COMPANY_ADMIN + HR ("2/3 Kiosks online")
|
||||||
|
|
||||||
|
**Anomalie-Detection** (Phase 2 nach Grundgerüst):
|
||||||
|
- Stempel-Frequenz-Threshold pro Gerät
|
||||||
|
- IP-Sprung-Detection (Geo-Inkonsistenz)
|
||||||
|
- Ungewöhnlicher User-Mix pro Gerät → AuditLog-Alarm
|
||||||
|
|
||||||
|
**nginx-Konfiguration** (`nginx.conf`): keine mTLS-Änderungen nötig. Standard-HTTPS + HSTS reicht – Auth läuft komplett auf Application-Layer.
|
||||||
|
|
||||||
|
**Migration:** `0021_kiosk_security.py`
|
||||||
|
- `kiosk_devices`: neue Spalten + Drop von `is_active` (durch `status` ersetzt)
|
||||||
|
- Bestehende Geräte → Default-Status `revoked` (müssen neu enrolled werden, sicher)
|
||||||
|
- `companies.kiosk_require_approval`, `kiosk_track_current_user`, `kiosk_heartbeat_interval_sec`
|
||||||
|
|
||||||
|
**Sicherheits-Bewertung (Ed25519-Variante)**:
|
||||||
|
| Schutz | Erreicht | Restrisiko |
|
||||||
|
|---------------------------------|----------|------------|
|
||||||
|
| Passiver MITM (mitlesen) | ✅ TLS + Signaturen | Public Key + signierte Requests sichtbar (nicht ausnutzbar) |
|
||||||
|
| Aktiver MITM (Replay/Modify) | ✅ Signatur+Nonce+Timestamp | – |
|
||||||
|
| Shared-Secret-Diebstahl | ✅ entfällt – kein Secret | – |
|
||||||
|
| PrivKey-Diebstahl (XSS) | ✅ non-extractable WebCrypto | Key bleibt im Browser-Speicher |
|
||||||
|
| Enrollment-Phishing | ✅ Public Key offen austauschbar | – |
|
||||||
|
| Remote-Filialen-Enrollment | ✅ trivial via CLI | – |
|
||||||
|
| CA-Verlust = alle Kiosks tot | ✅ entfällt – keine CA | – |
|
||||||
|
| IT-Admin als Innentäter | ⚠️ teilweise | nur native App + Hardware-Keystore |
|
||||||
|
| Zeit-Manipulation am Kiosk | ✅ Server-Time autoritativ | – |
|
||||||
|
| Browser-Cache-Clear → Re-Enroll | ⚠️ ja | manueller Re-Enroll nötig (akzeptiert) |
|
||||||
|
|
||||||
|
**Aufteilung in PRs**:
|
||||||
|
1. Backend: Ed25519-Verifizierung + CLI-Tool + Migration (kein Frontend)
|
||||||
|
2. Frontend Kiosk-Mode: ServiceWorker + WebCrypto + Setup-Flow + Offline-Queue
|
||||||
|
3. Frontend Verwaltung: KioskDevicesPage-Erweiterung + Health-Badge im Layout
|
||||||
|
4. Phase 2: Anomalie-Detection
|
||||||
|
|
||||||
|
## Wichtige Konventionen
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Alle DB-Operationen async (SQLAlchemy `AsyncSession`)
|
||||||
|
- Services enthalten Business-Logik, Router nur HTTP-Handling
|
||||||
|
- Pydantic v2 Schemas mit `model_validate()` statt `.from_orm()`
|
||||||
|
- Fehler immer als `HTTPException` mit sprechendem `detail`
|
||||||
|
- Tokens nie im Klartext in DB – immer `hash_token()` aus `core/security.py`
|
||||||
|
- Kein Hard-Delete bei Users – nur `is_active = False`
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
- Passwort-Validierung: min. 8 Zeichen, 1 Großbuchstabe, 1 Ziffer
|
||||||
|
- Rate-Limiting für Auth-Endpunkte (TODO: slowapi einbauen)
|
||||||
|
- Audit-Log bei allen sensitiven Aktionen
|
||||||
|
|
||||||
|
### ArbZG-Regeln (für agent-02)
|
||||||
|
- Max. 8h Arbeitszeit / Tag (Ausnahme bis 10h möglich)
|
||||||
|
- Pause ab 6h: 30 min · ab 9h: 45 min
|
||||||
|
- Mindestruhezeit zwischen Schichten: 11h
|
||||||
|
- Warnung bei Überschreitung, kein hartes Blockieren
|
||||||
|
|
||||||
|
## Datenbankschema Status
|
||||||
|
- Migration 0001: companies, departments, users, sessions, password_resets, audit_logs ✅
|
||||||
|
- Migration 0002: time_entries, work_schedules (TODO)
|
||||||
|
- Migration 0003–0016: absence_types, absences, vacation_balances, public_holidays, overtime_balance, totp, special_days ✅
|
||||||
|
- Migration 0017: (übersprungen – Nummer war nie in der Alembic-Kette)
|
||||||
|
- Migration 0018: kiosk_devices ✅
|
||||||
|
- Migration 0019: manual_time_entry_permission ✅
|
||||||
|
- Migration 0020: personnel_number (users + companies) ✅ (agent-07 Phase 1 deployed)
|
||||||
|
- Migration 0021: kiosk_security (mTLS, HMAC, heartbeat, enrollment) – TODO (agent-08)
|
||||||
|
- Migration 0022: sick_note_config (companies.sick_note_required_after_days) ✅ (agent-05 deployed 2026-05-06)
|
||||||
|
|
||||||
|
## Umgebung
|
||||||
|
|
||||||
|
### Lokal (Claude Code läuft hier)
|
||||||
|
- Entwicklung & Dateien bearbeiten: `~/projects/timemaster/`
|
||||||
|
- Tests lokal ausführen: `pytest -v`
|
||||||
|
- Kein lokaler Python/Postgres nötig – alles läuft auf dem Server
|
||||||
|
|
||||||
|
### Deployment-Server
|
||||||
|
- **Host**: `root@192.168.1.137`
|
||||||
|
- **Projektpfad**: `/opt/timemaster/`
|
||||||
|
- **venv**: `/opt/timemaster/backend/venv/`
|
||||||
|
- **Service**: `systemctl restart timemaster`
|
||||||
|
- **Logs**: `journalctl -u timemaster -f`
|
||||||
|
|
||||||
|
### Deployment-Workflow (nach jeder Änderung)
|
||||||
|
```bash
|
||||||
|
# Dateien auf Server synchronisieren
|
||||||
|
rsync -avz --exclude='__pycache__' --exclude='*.pyc' --exclude='.env' \
|
||||||
|
~/projects/timemaster/backend/ \
|
||||||
|
root@192.168.1.137:/opt/timemaster/backend/
|
||||||
|
|
||||||
|
# Migration ausführen (falls neue Alembic-Version)
|
||||||
|
ssh root@192.168.1.137 "cd /opt/timemaster/backend && source venv/bin/activate && alembic upgrade head"
|
||||||
|
|
||||||
|
# Service neu starten
|
||||||
|
ssh root@192.168.1.137 "systemctl restart timemaster"
|
||||||
|
|
||||||
|
# Logs prüfen
|
||||||
|
ssh root@192.168.1.137 "journalctl -u timemaster -n 50"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Erstes Setup auf dem Server (einmalig)
|
||||||
|
```bash
|
||||||
|
ssh root@192.168.1.137 "bash /opt/timemaster/setup_server.sh"
|
||||||
|
```
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
# TimeMaster – Dev Log
|
||||||
|
|
||||||
|
## 2026-03-28 21:59 – 22:02 (3m)
|
||||||
|
**Beschreibung:** Projekte-Feature entfernen, Timetrack einbauen
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-03-28 21:59 – 22:02 (3m)
|
||||||
|
**Beschreibung:** Projekte-Feature entfernen, Timetrack einbauen
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-03-28 21:59 – 22:02 (3m)
|
||||||
|
**Beschreibung:** Projekte-Feature entfernen, Timetrack einbauen
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-03-28 22:22 – 22:22 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-03-28 22:24 – 22:25 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 14:31 – 19:45 (5h 14m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** archivmail
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 19:46 – 19:47 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 19:49 – 19:49 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 19:52 – 19:52 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 19:54 – 19:56 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 19:58 – 20:00 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:06 – 20:06 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:06 – 20:08 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:11 – 20:11 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:12 – 20:12 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:12 – 20:12 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:14 – 20:14 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:15 – 20:15 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:17 – 20:17 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:19 – 20:22 (3m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:38 – 20:38 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:39 – 20:39 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 20:46 – 20:50 (4m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 21:05 – 21:08 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 21:09 – 21:13 (3m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 21:17 – 21:18 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-04-06 21:27 – 21:31 (3m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 17:06 – 17:07 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 17:10 – 17:11 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 17:20 – 17:20 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 17:27 – 17:27 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 17:31 – 17:32 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 17:39 – 17:40 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 17:41 – 17:44 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 17:47 – 17:49 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 17:57 – 17:58 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 18:18 – 18:19 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 18:27 – 18:32 (5m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 19:50 – 19:52 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 19:52 – 19:53 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-05 22:43 – 10:21 (11h 37m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** frontend
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-06 17:20 – 17:34 (14m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** frontend
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-06 17:34 – 17:35 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-06 22:47 – 22:51 (4m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-06 22:54 – 22:55 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-06 23:00 – 23:00 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-06 23:00 – 23:10 (9m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-23 19:20 – 19:21 (1m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
Keine Änderungen ermittelbar.
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Test-Benutzer
|
||||||
|
|
||||||
|
Alle Test-User sind in der Firma **VSB Magdeburg** angelegt.
|
||||||
|
Passwort für alle: `Test1234!`
|
||||||
|
|
||||||
|
| E-Mail | Rolle | Name |
|
||||||
|
|---------------------------|----------------|---------------|
|
||||||
|
| super.admin@tm-test.de | SUPER_ADMIN | Super Admin |
|
||||||
|
| firma.admin@tm-test.de | COMPANY_ADMIN | Firma Admin |
|
||||||
|
| hannah.hr@tm-test.de | HR | Hannah HR |
|
||||||
|
| max.manager@tm-test.de | MANAGER | Max Manager |
|
||||||
|
| emil.employee@tm-test.de | EMPLOYEE | Emil Employee |
|
||||||
|
|
||||||
|
## Produktiv-User
|
||||||
|
|
||||||
|
| E-Mail | Rolle | Name |
|
||||||
|
|-----------------------|----------------|------------------|
|
||||||
|
| patrick@perlbach24.de | COMPANY_ADMIN | Patrick Perlbach |
|
||||||
|
| bundyxl@gmx.de | EMPLOYEE | Patrick Test |
|
||||||
|
|
||||||
|
## Neu anlegen (SQL)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Passwort-Hash für 'Test1234!' erzeugen:
|
||||||
|
-- /opt/timemaster/backend/venv/bin/python3 -c "import bcrypt; print(bcrypt.hashpw(b'Test1234!', bcrypt.gensalt(rounds=12)).decode())"
|
||||||
|
|
||||||
|
INSERT INTO users (id, company_id, email, first_name, last_name, role, password_hash, is_active, auth_provider)
|
||||||
|
VALUES (gen_random_uuid(), '<company_id>', '<email>', '<first>', '<last>', '<role>', '<hash>', true, 'local');
|
||||||
|
```
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# App
|
||||||
|
APP_NAME=TimeMaster
|
||||||
|
APP_ENV=development
|
||||||
|
SECRET_KEY=change-me-in-production-min-32-chars-long
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASE_URL=postgresql+asyncpg://timemaster:timemaster_secret@db:5432/timemaster_db
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||||
|
REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||||
|
|
||||||
|
# Email (Resend)
|
||||||
|
RESEND_API_KEY=re_your_api_key_here
|
||||||
|
EMAIL_FROM=noreply@yourdomain.com
|
||||||
|
EMAIL_FROM_NAME=TimeMaster
|
||||||
|
|
||||||
|
# Superadmin (erstellt beim ersten Start)
|
||||||
|
FIRST_SUPERADMIN_EMAIL=admin@yourdomain.com
|
||||||
|
FIRST_SUPERADMIN_PASSWORD=change-me-immediately
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# TimeMaster – agent-01-auth
|
||||||
|
|
||||||
|
Backend: Python 3.12 · FastAPI · SQLAlchemy (async) · PostgreSQL · Redis
|
||||||
|
Laufend nativ auf dem Server (kein Docker in Phase 1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen (Ubuntu 22.04 / 24.04)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt install -y \
|
||||||
|
python3.12 python3.12-venv python3.12-dev \
|
||||||
|
postgresql postgresql-contrib \
|
||||||
|
redis-server nginx git build-essential libpq-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1 · PostgreSQL einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now postgresql
|
||||||
|
|
||||||
|
sudo -u postgres psql <<SQL
|
||||||
|
CREATE ROLE timemaster LOGIN PASSWORD 'ein_sicheres_passwort';
|
||||||
|
CREATE DATABASE timemaster_db OWNER timemaster;
|
||||||
|
GRANT ALL PRIVILEGES ON DATABASE timemaster_db TO timemaster;
|
||||||
|
SQL
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2 · Redis starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now redis-server
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3 · Projekt klonen & venv anlegen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /opt/timemaster
|
||||||
|
sudo chown $USER:$USER /opt/timemaster
|
||||||
|
git clone https://github.com/dein-repo/timemaster.git /opt/timemaster
|
||||||
|
|
||||||
|
cd /opt/timemaster/backend
|
||||||
|
python3.12 -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4 · Umgebungsvariablen setzen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Werte anpassen: DATABASE_URL, SECRET_KEY, RESEND_API_KEY usw.
|
||||||
|
```
|
||||||
|
|
||||||
|
Wichtige Felder:
|
||||||
|
```
|
||||||
|
SECRET_KEY=<min. 32 zufällige Zeichen>
|
||||||
|
DATABASE_URL=postgresql+asyncpg://timemaster:passwort@localhost:5432/timemaster_db
|
||||||
|
FRONTEND_URL=https://deine-domain.de
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5 · Datenbank-Migration ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/timemaster/backend
|
||||||
|
source venv/bin/activate
|
||||||
|
alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6 · Server starten (Entwicklung)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
API-Docs (nur dev): http://localhost:8000/docs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7 · Systemd-Service einrichten (Produktion)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /opt/timemaster/timemaster.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now timemaster
|
||||||
|
sudo systemctl status timemaster
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8 · Nginx einrichten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp /opt/timemaster/nginx.conf /etc/nginx/sites-available/timemaster
|
||||||
|
sudo ln -s /etc/nginx/sites-available/timemaster /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
|
||||||
|
# SSL via Let's Encrypt
|
||||||
|
sudo certbot --nginx -d deine-domain.de
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9 · Tests ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/timemaster/backend
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install aiosqlite # nur für Tests (SQLite in-memory)
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API-Übersicht (agent-01)
|
||||||
|
|
||||||
|
| Method | Endpoint | Beschreibung |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | /api/v1/auth/register | Firma + Admin anlegen |
|
||||||
|
| POST | /api/v1/auth/login | Login → JWT + Refresh Token |
|
||||||
|
| POST | /api/v1/auth/refresh | Access Token erneuern |
|
||||||
|
| POST | /api/v1/auth/logout | Session beenden |
|
||||||
|
| GET | /api/v1/auth/me | Aktueller Nutzer |
|
||||||
|
| POST | /api/v1/auth/password-reset | Reset-Link anfordern |
|
||||||
|
| POST | /api/v1/auth/password-reset/confirm | Passwort neu setzen |
|
||||||
|
| POST | /api/v1/auth/invite/accept | Einladung annehmen |
|
||||||
|
| GET | /api/v1/users/ | Alle Nutzer (Admin/HR) |
|
||||||
|
| POST | /api/v1/users/invite | Nutzer einladen |
|
||||||
|
| GET | /api/v1/users/me | Eigenes Profil |
|
||||||
|
| GET | /api/v1/users/{id} | Nutzer abrufen |
|
||||||
|
| PATCH| /api/v1/users/{id} | Nutzer bearbeiten |
|
||||||
|
| POST | /api/v1/users/{id}/deactivate | Deaktivieren |
|
||||||
|
| POST | /api/v1/users/{id}/reactivate | Reaktivieren |
|
||||||
|
| POST | /api/v1/users/{id}/kiosk-pin | Kiosk-PIN setzen |
|
||||||
|
| GET | /api/v1/companies/me | Firmenprofil |
|
||||||
|
| PATCH| /api/v1/companies/me | Firmenprofil bearbeiten |
|
||||||
|
| GET | /api/v1/companies/me/departments | Abteilungen |
|
||||||
|
| POST | /api/v1/companies/me/departments | Abteilung anlegen |
|
||||||
|
| PATCH| /api/v1/companies/me/departments/{id} | Abteilung bearbeiten |
|
||||||
|
| DELETE| /api/v1/companies/me/departments/{id} | Abteilung löschen |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dateistruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py ← FastAPI App
|
||||||
|
│ ├── core/
|
||||||
|
│ │ ├── config.py ← Settings (.env)
|
||||||
|
│ │ ├── database.py ← AsyncEngine + get_db
|
||||||
|
│ │ ├── security.py ← JWT, Hashing, Tokens
|
||||||
|
│ │ └── dependencies.py ← get_current_user, require_role
|
||||||
|
│ ├── models/ ← SQLAlchemy ORM
|
||||||
|
│ ├── schemas/ ← Pydantic v2
|
||||||
|
│ ├── routers/ ← API-Endpunkte
|
||||||
|
│ └── services/ ← Business-Logik
|
||||||
|
├── migrations/ ← Alembic
|
||||||
|
├── tests/ ← pytest
|
||||||
|
├── alembic.ini
|
||||||
|
├── pytest.ini
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nächste Schritte (Sprint 2)
|
||||||
|
|
||||||
|
- **agent-02-zeiterfassung**: Stempeluhr, Zeit-Einträge, ArbZG-Prüfung
|
||||||
|
- **agent-03-abwesenheit**: Urlaubsanträge, Genehmigungsflow, Kalender
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from pydantic import model_validator
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
# App
|
||||||
|
app_name: str = "TimeMaster"
|
||||||
|
app_env: str = "development"
|
||||||
|
secret_key: str = "change-me-in-production"
|
||||||
|
frontend_url: str = "http://localhost:5173"
|
||||||
|
allowed_hosts: list[str] = []
|
||||||
|
|
||||||
|
# Database
|
||||||
|
database_url: str = "postgresql+asyncpg://timemaster:secret@localhost:5432/timemaster_db"
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
access_token_expire_minutes: int = 30
|
||||||
|
refresh_token_expire_days: int = 30
|
||||||
|
algorithm: str = "HS256"
|
||||||
|
|
||||||
|
# Email
|
||||||
|
resend_api_key: str = ""
|
||||||
|
email_from: str = "noreply@timemaster.app"
|
||||||
|
email_from_name: str = "TimeMaster"
|
||||||
|
|
||||||
|
# First superadmin
|
||||||
|
first_superadmin_email: str = ""
|
||||||
|
first_superadmin_password: str = ""
|
||||||
|
|
||||||
|
@model_validator(mode='after')
|
||||||
|
def validate_secret_key(self):
|
||||||
|
if self.app_env == 'production' and self.secret_key == 'change-me-in-production':
|
||||||
|
raise ValueError('SECRET_KEY must be changed in production! Set SECRET_KEY env variable.')
|
||||||
|
if len(self.secret_key) < 32:
|
||||||
|
raise ValueError('SECRET_KEY must be at least 32 characters long.')
|
||||||
|
return self
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.app_env == "production"
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=settings.app_env == "development",
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=10,
|
||||||
|
max_overflow=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
AsyncSessionLocal = async_sessionmaker(
|
||||||
|
engine,
|
||||||
|
class_=AsyncSession,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncSession:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from jose import JWTError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import decode_access_token
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
|
bearer_scheme = HTTPBearer()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: Annotated[HTTPAuthorizationCredentials, Depends(bearer_scheme)],
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
) -> User:
|
||||||
|
credentials_exception = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
payload = decode_access_token(credentials.credentials)
|
||||||
|
user_id: str = payload.get("sub")
|
||||||
|
if user_id is None:
|
||||||
|
raise credentials_exception
|
||||||
|
except JWTError:
|
||||||
|
raise credentials_exception
|
||||||
|
|
||||||
|
user = await db.get(User, UUID(user_id))
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise credentials_exception
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
|
|
||||||
|
|
||||||
|
def require_role(*roles: UserRole):
|
||||||
|
"""Dependency factory: require_role(UserRole.MANAGER, UserRole.COMPANY_ADMIN)"""
|
||||||
|
async def checker(current_user: CurrentUser) -> User:
|
||||||
|
if current_user.role not in roles:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Insufficient permissions",
|
||||||
|
)
|
||||||
|
return current_user
|
||||||
|
return Depends(checker)
|
||||||
|
|
||||||
|
|
||||||
|
def require_same_company(target_company_id: UUID, current_user: User) -> None:
|
||||||
|
"""Raise 403 if user tries to access another company's data."""
|
||||||
|
if (
|
||||||
|
current_user.role != UserRole.SUPER_ADMIN
|
||||||
|
and current_user.company_id != target_company_id
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Access to this resource is not allowed",
|
||||||
|
)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
# ── Password ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def hash_password(password: str) -> str:
|
||||||
|
return bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||||
|
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
"""SHA-256 hash for storing tokens (refresh, invite, reset) in DB."""
|
||||||
|
return hashlib.sha256(token.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ── JWT ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.access_token_expire_minutes
|
||||||
|
)
|
||||||
|
payload = {"sub": subject, "exp": expire, "type": "access"}
|
||||||
|
if extra:
|
||||||
|
payload.update(extra)
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token() -> tuple[str, str]:
|
||||||
|
"""Returns (raw_token, hashed_token). Store hash in DB, send raw to client."""
|
||||||
|
raw = secrets.token_urlsafe(64)
|
||||||
|
return raw, hash_token(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_access_token(token: str) -> dict[str, Any]:
|
||||||
|
"""Raises JWTError on invalid/expired token."""
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||||
|
if payload.get("type") != "access":
|
||||||
|
raise JWTError("Invalid token type")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
# ── Partial token (TOTP pending) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def create_partial_token(user_id: str) -> str:
|
||||||
|
"""Short-lived token issued after password-OK but before TOTP verification. Valid 5 min."""
|
||||||
|
expire = datetime.now(timezone.utc) + timedelta(minutes=5)
|
||||||
|
payload = {"sub": user_id, "exp": expire, "type": "partial"}
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def decode_partial_token(token: str) -> str:
|
||||||
|
"""Returns user_id (sub). Raises JWTError on invalid/expired/wrong-type token."""
|
||||||
|
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||||
|
if payload.get("type") != "partial":
|
||||||
|
raise JWTError("Invalid token type")
|
||||||
|
return payload["sub"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── One-time tokens ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def generate_invite_token() -> tuple[str, str]:
|
||||||
|
"""Returns (raw, hashed). Invite valid for 7 days."""
|
||||||
|
raw = secrets.token_urlsafe(32)
|
||||||
|
return raw, hash_token(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_reset_token() -> tuple[str, str]:
|
||||||
|
"""Returns (raw, hashed). Reset valid for 1 hour."""
|
||||||
|
raw = secrets.token_urlsafe(32)
|
||||||
|
return raw, hash_token(raw)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.middleware.trustedhost import TrustedHostMiddleware
|
||||||
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import engine, Base
|
||||||
|
from app.core.limiter import limiter
|
||||||
|
from app.routers import auth, users, companies
|
||||||
|
from app.routers import time_entries, absences, reports, ldap, smtp, caldav
|
||||||
|
from app.routers import import_kimai
|
||||||
|
from app.routers import kiosk
|
||||||
|
from app.routers import busylight
|
||||||
|
from app.routers import audit
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
# Startup: Tabellen anlegen falls noch nicht vorhanden (Alembic übernimmt das in Prod)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
yield
|
||||||
|
# Shutdown
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version="0.1.0",
|
||||||
|
docs_url="/docs" if not settings.is_production else None,
|
||||||
|
redoc_url="/redoc" if not settings.is_production else None,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
|
||||||
|
# ── Middleware ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=[settings.frontend_url],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# TODO (M-07): TrustedHostMiddleware – set ALLOWED_HOSTS env variable (comma-separated) in production.
|
||||||
|
# Example: ALLOWED_HOSTS=timemaster.example.com,www.timemaster.example.com
|
||||||
|
# The placeholder "yourdomain.com" has been replaced with a config-driven approach.
|
||||||
|
if settings.is_production and settings.allowed_hosts:
|
||||||
|
app.add_middleware(TrustedHostMiddleware, allowed_hosts=settings.allowed_hosts)
|
||||||
|
|
||||||
|
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
API_PREFIX = "/api/v1"
|
||||||
|
|
||||||
|
app.include_router(auth.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(users.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(companies.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(time_entries.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(absences.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(reports.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(ldap.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(smtp.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(caldav.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(import_kimai.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(kiosk.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(busylight.router, prefix=API_PREFIX)
|
||||||
|
app.include_router(audit.router, prefix=API_PREFIX)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Health ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/health", tags=["System"])
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok", "app": settings.app_name, "env": settings.app_env}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from app.models.company import Company
|
||||||
|
from app.models.department import Department
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.session import Session
|
||||||
|
from app.models.password_reset import PasswordReset
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.work_schedule import WorkSchedule
|
||||||
|
from app.models.time_entry import TimeEntry, EntryStatus, EntrySource
|
||||||
|
from app.models.absence_type import AbsenceType
|
||||||
|
from app.models.absence import Absence, AbsenceStatus
|
||||||
|
from app.models.vacation_balance import VacationBalance
|
||||||
|
from app.models.overtime_balance import OvertimeBalance
|
||||||
|
from app.models.public_holiday import PublicHoliday
|
||||||
|
from app.models.smtp_config import SmtpConfig
|
||||||
|
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
|
||||||
|
from app.models.kiosk_device import KioskDevice, KioskAuthMethod
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Company",
|
||||||
|
"Department",
|
||||||
|
"User",
|
||||||
|
"UserRole",
|
||||||
|
"Session",
|
||||||
|
"PasswordReset",
|
||||||
|
"AuditLog",
|
||||||
|
"WorkSchedule",
|
||||||
|
"TimeEntry",
|
||||||
|
"EntryStatus",
|
||||||
|
"EntrySource",
|
||||||
|
"AbsenceType",
|
||||||
|
"Absence",
|
||||||
|
"AbsenceStatus",
|
||||||
|
"VacationBalance",
|
||||||
|
"OvertimeBalance",
|
||||||
|
"PublicHoliday",
|
||||||
|
"KioskDevice",
|
||||||
|
"KioskAuthMethod",
|
||||||
|
]
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import date, datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, DateTime, Enum, ForeignKey, Numeric, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.absence_type import AbsenceType
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class Absence(Base):
|
||||||
|
__tablename__ = "absences"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
type_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("absence_types.id", ondelete="RESTRICT"), nullable=False
|
||||||
|
)
|
||||||
|
start_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
end_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
half_day_start: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
half_day_end: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
working_days: Mapped[float] = mapped_column(Numeric(5, 1), default=0)
|
||||||
|
status: Mapped[AbsenceStatus] = mapped_column(
|
||||||
|
Enum(AbsenceStatus, name="absencestatus", values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False, default=AbsenceStatus.PENDING,
|
||||||
|
)
|
||||||
|
approved_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
substitute_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
|
rejection_reason: Mapped[str | None] = mapped_column(Text)
|
||||||
|
correction_note: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
# Zusatzinformationen (Weiterbildung, Dienstreise, etc.)
|
||||||
|
# Struktur je Kategorie:
|
||||||
|
# training: {"course_name": str, "provider": str, "location": str}
|
||||||
|
# business_trip: {"destination": str, "purpose": str}
|
||||||
|
meta: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
|
||||||
|
# Krankheit: Arbeitsunfähigkeitsbescheinigung
|
||||||
|
certificate_required_by: Mapped[date | None] = mapped_column(Date)
|
||||||
|
certificate_received_at: Mapped[date | None] = mapped_column(Date)
|
||||||
|
|
||||||
|
# CalDAV-Sync
|
||||||
|
caldav_uid: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
caldav_user_etag: Mapped[str | None] = mapped_column(Text)
|
||||||
|
caldav_company_etag: Mapped[str | None] = mapped_column(Text)
|
||||||
|
caldav_last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
caldav_synced_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User", primaryjoin="Absence.user_id == User.id",
|
||||||
|
foreign_keys="[Absence.user_id]", lazy="noload",
|
||||||
|
)
|
||||||
|
absence_type: Mapped["AbsenceType"] = relationship("AbsenceType", lazy="noload")
|
||||||
|
approver: Mapped["User | None"] = relationship(
|
||||||
|
"User", primaryjoin="Absence.approved_by == User.id",
|
||||||
|
foreign_keys="[Absence.approved_by]", lazy="noload",
|
||||||
|
)
|
||||||
|
substitute: Mapped["User | None"] = relationship(
|
||||||
|
"User", primaryjoin="Absence.substitute_id == User.id",
|
||||||
|
foreign_keys="[Absence.substitute_id]", lazy="noload",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Absence {self.user_id} {self.start_date}–{self.end_date} [{self.status}]>"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Enum, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceCategory(str, enum.Enum):
|
||||||
|
VACATION = "vacation"
|
||||||
|
SICK = "sick"
|
||||||
|
OVERTIME_COMP = "overtime_comp"
|
||||||
|
TRAINING = "training"
|
||||||
|
BUSINESS_TRIP = "business_trip"
|
||||||
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceType(Base):
|
||||||
|
__tablename__ = "absence_types"
|
||||||
|
|
||||||
|
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"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
color: Mapped[str] = mapped_column(String(7), default="#3B82F6")
|
||||||
|
category: Mapped[AbsenceCategory] = mapped_column(
|
||||||
|
Enum(AbsenceCategory, name="absencecategory", values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False, default=AbsenceCategory.OTHER,
|
||||||
|
)
|
||||||
|
requires_approval: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
deducts_vacation: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
affects_overtime_balance: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
requires_certificate: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
certificate_after_days: Mapped[int] = mapped_column(Integer, default=3)
|
||||||
|
is_paid: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
max_days_per_year: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
|
||||||
|
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<AbsenceType {self.name} [{self.category}]>"
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
__tablename__ = "audit_logs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
company_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="SET NULL"), index=True)
|
||||||
|
user_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
entity_type: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
old_value: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
new_value: Mapped[dict | None] = mapped_column(JSONB)
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(45))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func # noqa: F401
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class CaldavCompanyConfig(Base):
|
||||||
|
"""Zentraler Firmenkalender – alle genehmigten Abwesenheiten landen hier."""
|
||||||
|
__tablename__ = "caldav_company_configs"
|
||||||
|
|
||||||
|
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"),
|
||||||
|
nullable=False, unique=True, index=True,
|
||||||
|
)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
principal_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
calendar_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
calendar_display_name: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
verify_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
name_template: Mapped[str] = mapped_column(Text, default="$vorname $nachname – $typ")
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
||||||
|
|
||||||
|
|
||||||
|
class CaldavUserConfig(Base):
|
||||||
|
"""Persönlicher Kalender des Mitarbeiters."""
|
||||||
|
__tablename__ = "caldav_user_configs"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, unique=True, index=True,
|
||||||
|
)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
principal_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
calendar_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
username: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
calendar_display_name: Mapped[str] = mapped_column(String(255), default="")
|
||||||
|
verify_ssl: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship("User", lazy="noload")
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.department import Department
|
||||||
|
|
||||||
|
|
||||||
|
class PersonnelNumberMode(str, enum.Enum):
|
||||||
|
MANUAL = "manual"
|
||||||
|
AUTO = "auto"
|
||||||
|
|
||||||
|
|
||||||
|
class Company(Base):
|
||||||
|
__tablename__ = "companies"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||||
|
plan: Mapped[str] = mapped_column(String(50), default="trial")
|
||||||
|
logo_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
country: Mapped[str] = mapped_column(String(10), default="DE")
|
||||||
|
state: Mapped[str | None] = mapped_column(String(10))
|
||||||
|
settings: Mapped[dict] = mapped_column(JSONB, default=dict)
|
||||||
|
|
||||||
|
# Personalnummern-Konfiguration
|
||||||
|
personnel_number_required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
personnel_number_mode: Mapped[str] = mapped_column(String(10), nullable=False, default=PersonnelNumberMode.MANUAL.value)
|
||||||
|
personnel_number_next: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
|
||||||
|
# Krankmeldungs-Konfiguration: Default-Schwelle für AU-Pflicht (in Tagen).
|
||||||
|
# Pro AbsenceType via certificate_after_days überschreibbar.
|
||||||
|
sick_note_required_after_days: Mapped[int] = mapped_column(Integer, nullable=False, default=3)
|
||||||
|
|
||||||
|
# Busylight-Pull: SHA-256-Hash des per-Firma-Tokens (Klartext nie in DB).
|
||||||
|
busylight_pull_token_hash: Mapped[str | None] = mapped_column(String(64), unique=True)
|
||||||
|
busylight_token_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
|
||||||
|
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Company {self.name}>"
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class Department(Base):
|
||||||
|
__tablename__ = "departments"
|
||||||
|
|
||||||
|
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"), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
manager_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL", use_alter=True, name="fk_dep_manager"))
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
company: Mapped["Company"] = relationship("Company", back_populates="departments")
|
||||||
|
members: Mapped[list["User"]] = relationship(
|
||||||
|
"User",
|
||||||
|
primaryjoin="User.department_id == Department.id",
|
||||||
|
foreign_keys="[User.department_id]",
|
||||||
|
back_populates="department",
|
||||||
|
lazy="noload",
|
||||||
|
)
|
||||||
|
manager: Mapped["User | None"] = relationship(
|
||||||
|
"User",
|
||||||
|
primaryjoin="Department.manager_id == User.id",
|
||||||
|
foreign_keys="[Department.manager_id]",
|
||||||
|
lazy="noload",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Department {self.name}>"
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, func
|
||||||
|
from sqlalchemy.dialects.postgresql import ARRAY, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
class KioskAuthMethod(str, enum.Enum):
|
||||||
|
PIN = "pin"
|
||||||
|
NFC = "nfc"
|
||||||
|
QR = "qr"
|
||||||
|
LIST = "list" # Mitarbeiter-Liste
|
||||||
|
|
||||||
|
|
||||||
|
class KioskDevice(Base):
|
||||||
|
__tablename__ = "kiosk_devices"
|
||||||
|
|
||||||
|
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"),
|
||||||
|
nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
location: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<KioskDevice {self.name} ({self.company_id})>"
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
class LdapConfig(Base):
|
||||||
|
__tablename__ = "ldap_configs"
|
||||||
|
|
||||||
|
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"),
|
||||||
|
nullable=False, unique=True, index=True
|
||||||
|
)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Server
|
||||||
|
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
port: Mapped[int] = mapped_column(Integer, default=389)
|
||||||
|
use_ssl: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
use_tls: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
tls_verify: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Bind credentials
|
||||||
|
bind_dn: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
bind_password_encrypted: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|
||||||
|
# Search
|
||||||
|
base_dn: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
user_search_filter: Mapped[str] = mapped_column(
|
||||||
|
String(512), nullable=False, default="(objectClass=person)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attribute mapping
|
||||||
|
attr_email: Mapped[str] = mapped_column(String(100), default="mail")
|
||||||
|
attr_firstname: Mapped[str] = mapped_column(String(100), default="givenName")
|
||||||
|
attr_lastname: Mapped[str] = mapped_column(String(100), default="sn")
|
||||||
|
attr_username: Mapped[str] = mapped_column(String(100), default="sAMAccountName")
|
||||||
|
attr_department: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
attr_personnel_number: Mapped[str | None] = mapped_column(String(100))
|
||||||
|
|
||||||
|
# Sync state
|
||||||
|
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<LdapConfig {self.host} company={self.company_id}>"
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Numeric, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
class OvertimeBalance(Base):
|
||||||
|
"""Kumuliertes Überstundenguthaben pro Mitarbeiter.
|
||||||
|
|
||||||
|
total_hours = Summe aller genehmigten Überstunden aus time_entries
|
||||||
|
taken_hours = bereits als Freizeitausgleich genommene Stunden
|
||||||
|
available = total_hours - taken_hours
|
||||||
|
"""
|
||||||
|
__tablename__ = "overtime_balances"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, unique=True, index=True,
|
||||||
|
)
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
total_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0"))
|
||||||
|
taken_hours: Mapped[Decimal] = mapped_column(Numeric(8, 2), default=Decimal("0"))
|
||||||
|
last_calculated: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship("User", lazy="noload")
|
||||||
|
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_hours(self) -> Decimal:
|
||||||
|
return self.total_hours - self.taken_hours
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<OvertimeBalance user={self.user_id} total={self.total_hours}h taken={self.taken_hours}h>"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordReset(Base):
|
||||||
|
__tablename__ = "password_resets"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship("User")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Numeric, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base):
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
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"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
color: Mapped[str] = mapped_column(String(7), default="#3B82F6") # Tailwind blue-500
|
||||||
|
budget_hours: Mapped[float | None] = mapped_column(Numeric(8, 2))
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Project {self.name} ({self.company_id})>"
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Date, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class PublicHoliday(Base):
|
||||||
|
__tablename__ = "public_holidays"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("country", "state", "date", name="uq_public_holiday"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
country: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||||
|
state: Mapped[str | None] = mapped_column(String(10)) # z.B. "BY", "NW" für Bundesländer
|
||||||
|
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
year: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||||
|
is_high_rate: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<PublicHoliday {self.name} {self.date}>"
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import INET, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class Session(Base):
|
||||||
|
__tablename__ = "sessions"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||||
|
refresh_token_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||||
|
device: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
ip: Mapped[str | None] = mapped_column(String(45)) # supports IPv6
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user: Mapped["User"] = relationship("User", back_populates="sessions")
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<Session user={self.user_id} expires={self.expires_at}>"
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""
|
||||||
|
SMTP-Konfiguration pro Firma.
|
||||||
|
Passwort wird Fernet-verschlüsselt gespeichert (gleiche Methode wie ldap_service).
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpConfig(Base):
|
||||||
|
__tablename__ = "smtp_configs"
|
||||||
|
__table_args__ = (UniqueConstraint("company_id"),)
|
||||||
|
|
||||||
|
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), nullable=False, index=True)
|
||||||
|
|
||||||
|
host: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
port: Mapped[int] = mapped_column(Integer, default=587, nullable=False)
|
||||||
|
use_tls: Mapped[bool] = mapped_column(Boolean, default=False) # SMTPS port 465
|
||||||
|
use_starttls: Mapped[bool] = mapped_column(Boolean, default=True) # STARTTLS port 587
|
||||||
|
|
||||||
|
username: Mapped[str | None] = mapped_column(String(255))
|
||||||
|
password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
from_email: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
from_name: Mapped[str] = mapped_column(String(255), default="TimeMaster", nullable=False)
|
||||||
|
|
||||||
|
is_enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import date, datetime, time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Date, DateTime, Enum, ForeignKey, Integer, String, Text, Time, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class EntryStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
APPROVED = "approved"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class EntrySource(str, enum.Enum):
|
||||||
|
WEB = "web"
|
||||||
|
KIOSK = "kiosk"
|
||||||
|
API = "api"
|
||||||
|
MANUAL = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntry(Base):
|
||||||
|
__tablename__ = "time_entries"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
date: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||||
|
start_time: Mapped[time] = mapped_column(Time(timezone=False), nullable=False)
|
||||||
|
end_time: Mapped[time | None] = mapped_column(Time(timezone=False))
|
||||||
|
break_minutes: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
break_start: Mapped[time | None] = mapped_column(Time(timezone=False)) # Aktive Pause tracken
|
||||||
|
project_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
|
note: Mapped[str | None] = mapped_column(Text)
|
||||||
|
status: Mapped[EntryStatus] = mapped_column(
|
||||||
|
Enum(EntryStatus, name="entrystatus", values_callable=lambda x: [e.value for e in x]), nullable=False, default=EntryStatus.PENDING
|
||||||
|
)
|
||||||
|
source: Mapped[EntrySource] = mapped_column(
|
||||||
|
Enum(EntrySource, name="entrysource", values_callable=lambda x: [e.value for e in x]), nullable=False, default=EntrySource.WEB
|
||||||
|
)
|
||||||
|
approved_by: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
correction_note: Mapped[str | None] = mapped_column(Text)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship(
|
||||||
|
"User", primaryjoin="TimeEntry.user_id == User.id",
|
||||||
|
foreign_keys="[TimeEntry.user_id]", lazy="noload"
|
||||||
|
)
|
||||||
|
approver: Mapped["User | None"] = relationship(
|
||||||
|
"User", primaryjoin="TimeEntry.approved_by == User.id",
|
||||||
|
foreign_keys="[TimeEntry.approved_by]", lazy="noload"
|
||||||
|
)
|
||||||
|
@property
|
||||||
|
def worked_minutes(self) -> int | None:
|
||||||
|
"""Gearbeitete Minuten (ohne Pausen), None wenn noch offen."""
|
||||||
|
if self.end_time is None:
|
||||||
|
return None
|
||||||
|
start_total = self.start_time.hour * 60 + self.start_time.minute
|
||||||
|
end_total = self.end_time.hour * 60 + self.end_time.minute
|
||||||
|
if end_total <= start_total:
|
||||||
|
end_total += 24 * 60 # overnight shift
|
||||||
|
return max(0, end_total - start_total - self.break_minutes)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def worked_hours(self) -> float | None:
|
||||||
|
mins = self.worked_minutes
|
||||||
|
return round(mins / 60, 2) if mins is not None else None
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<TimeEntry {self.user_id} {self.date} {self.start_time}-{self.end_time}>"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import uuid
|
||||||
|
import enum
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Text, func
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.department import Department
|
||||||
|
from app.models.session import Session
|
||||||
|
|
||||||
|
|
||||||
|
class UserRole(str, enum.Enum):
|
||||||
|
SUPER_ADMIN = "SUPER_ADMIN"
|
||||||
|
COMPANY_ADMIN = "COMPANY_ADMIN"
|
||||||
|
HR = "HR"
|
||||||
|
MANAGER = "MANAGER"
|
||||||
|
EMPLOYEE = "EMPLOYEE"
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProvider(str, enum.Enum):
|
||||||
|
LOCAL = "local"
|
||||||
|
LDAP = "ldap"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
company_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("companies.id", ondelete="CASCADE"))
|
||||||
|
department_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("departments.id", ondelete="SET NULL"))
|
||||||
|
|
||||||
|
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
first_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
last_name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
role: Mapped[UserRole] = mapped_column(Enum(UserRole), nullable=False, default=UserRole.EMPLOYEE)
|
||||||
|
auth_provider: Mapped[AuthProvider] = mapped_column(
|
||||||
|
Enum(AuthProvider, name="authprovider", values_callable=lambda x: [e.value for e in x]),
|
||||||
|
nullable=False, default=AuthProvider.LOCAL,
|
||||||
|
)
|
||||||
|
ldap_dn: Mapped[str | None] = mapped_column(Text)
|
||||||
|
work_schedule_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("work_schedules.id", ondelete="SET NULL")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Kiosk auth
|
||||||
|
kiosk_pin_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
|
kiosk_qr_token: Mapped[str | None] = mapped_column(Text, unique=True)
|
||||||
|
|
||||||
|
# Kalender-Kürzel (vom Manager setzbar, für CalDAV-Template $kuerzel)
|
||||||
|
kuerzel: Mapped[str | None] = mapped_column(String(20))
|
||||||
|
|
||||||
|
# Personalnummer (numerisch, eindeutig pro Firma; bleibt nach Deaktivierung reserviert)
|
||||||
|
personnel_number: Mapped[str | None] = mapped_column(String(50))
|
||||||
|
|
||||||
|
# TOTP / 2FA
|
||||||
|
totp_secret: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
totp_enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Permissions
|
||||||
|
can_manual_time_entry: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
# Account state
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
invite_token_hash: Mapped[str | None] = mapped_column(Text)
|
||||||
|
invite_expires: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
company: Mapped["Company"] = relationship("Company", back_populates="users")
|
||||||
|
department: Mapped["Department | None"] = relationship(
|
||||||
|
"Department",
|
||||||
|
primaryjoin="User.department_id == Department.id",
|
||||||
|
foreign_keys="[User.department_id]",
|
||||||
|
back_populates="members",
|
||||||
|
)
|
||||||
|
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user", cascade="all, delete-orphan", lazy="noload")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_name(self) -> str:
|
||||||
|
return f"{self.first_name} {self.last_name}"
|
||||||
|
|
||||||
|
def is_admin_or_above(self) -> bool:
|
||||||
|
return self.role in (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<User {self.email} [{self.role}]>"
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Integer, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class VacationBalance(Base):
|
||||||
|
__tablename__ = "vacation_balances"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "year", name="uq_vacation_balance_user_year"),)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
year: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
entitled_days: Mapped[int] = mapped_column(Integer, default=30) # Grundurlaub
|
||||||
|
special_days: Mapped[int] = mapped_column(Integer, default=0) # Sondertage
|
||||||
|
carried_over: Mapped[int] = mapped_column(Integer, default=0) # Resturlaub aus Vorjahr
|
||||||
|
used_days: Mapped[int] = mapped_column(Integer, default=0) # Verbraucht
|
||||||
|
|
||||||
|
user: Mapped["User"] = relationship("User", lazy="noload")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_days(self) -> int:
|
||||||
|
return self.entitled_days + self.special_days + self.carried_over
|
||||||
|
|
||||||
|
@property
|
||||||
|
def remaining_days(self) -> int:
|
||||||
|
return self.total_days - self.used_days
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<VacationBalance {self.user_id} {self.year}: {self.remaining_days} left>"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Date, ForeignKey, Numeric, String
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
class WorkSchedule(Base):
|
||||||
|
__tablename__ = "work_schedules"
|
||||||
|
|
||||||
|
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"), nullable=False
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
mon_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
|
||||||
|
tue_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
|
||||||
|
wed_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
|
||||||
|
thu_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
|
||||||
|
fri_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("8.00"))
|
||||||
|
sat_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("0.00"))
|
||||||
|
sun_h: Mapped[Decimal] = mapped_column(Numeric(4, 2), default=Decimal("0.00"))
|
||||||
|
valid_from: Mapped[date] = mapped_column(Date, nullable=False)
|
||||||
|
|
||||||
|
company: Mapped["Company"] = relationship("Company", lazy="noload")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def weekly_hours(self) -> Decimal:
|
||||||
|
return self.mon_h + self.tue_h + self.wed_h + self.thu_h + self.fri_h + self.sat_h + self.sun_h
|
||||||
|
|
||||||
|
def hours_for_weekday(self, weekday: int) -> Decimal:
|
||||||
|
"""weekday: 0=Mon, 1=Tue, ..., 6=Sun"""
|
||||||
|
mapping = [self.mon_h, self.tue_h, self.wed_h, self.thu_h, self.fri_h, self.sat_h, self.sun_h]
|
||||||
|
return mapping[weekday]
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"<WorkSchedule {self.name}>"
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import CurrentUser, require_role
|
||||||
|
from app.models.absence import AbsenceStatus
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.overtime_balance import OvertimeBalance
|
||||||
|
from app.schemas.absence import (
|
||||||
|
AbsenceCreate,
|
||||||
|
AbsenceListResponse,
|
||||||
|
AbsenceOut,
|
||||||
|
AbsenceReject,
|
||||||
|
AbsenceUpdate,
|
||||||
|
AbsenceTypeCreate,
|
||||||
|
AbsenceTypeOut,
|
||||||
|
AbsenceTypeUpdate,
|
||||||
|
CalendarEntry,
|
||||||
|
CertificateMarkIn,
|
||||||
|
OvertimeBalanceOut,
|
||||||
|
PublicHolidayCreate,
|
||||||
|
PublicHolidayOut,
|
||||||
|
QuickSickIn,
|
||||||
|
SickStatsOut,
|
||||||
|
VacationBalanceOut,
|
||||||
|
VacationBalanceUpdate,
|
||||||
|
)
|
||||||
|
from app.services.absence_service import absence_service
|
||||||
|
from app.models.company import Company
|
||||||
|
from sqlalchemy import select
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Abwesenheiten"])
|
||||||
|
|
||||||
|
|
||||||
|
def _carryover_expiry(company: Company, year: int) -> tuple[date | None, bool]:
|
||||||
|
"""Verfallsdatum für Resturlaub berechnen.
|
||||||
|
Gibt (expires_at, is_expired) zurück. None wenn kein Verfall konfiguriert."""
|
||||||
|
s = company.settings or {}
|
||||||
|
month = s.get("carryover_expires_month")
|
||||||
|
day = s.get("carryover_expires_day")
|
||||||
|
if not month or not day:
|
||||||
|
return None, False
|
||||||
|
try:
|
||||||
|
expires_at = date(year, int(month), int(day))
|
||||||
|
return expires_at, date.today() > expires_at
|
||||||
|
except ValueError:
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Absence Types ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/absence-types/", response_model=list[AbsenceTypeOut])
|
||||||
|
async def list_absence_types(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
types = await absence_service.list_types(current_user.company_id, db)
|
||||||
|
return [AbsenceTypeOut.model_validate(t) for t in types]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/absence-types/", response_model=AbsenceTypeOut, status_code=201)
|
||||||
|
async def create_absence_type(
|
||||||
|
data: AbsenceTypeCreate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
at = await absence_service.create_type(current_user.company_id, data, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(at)
|
||||||
|
return AbsenceTypeOut.model_validate(at)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/absence-types/{type_id}", response_model=AbsenceTypeOut)
|
||||||
|
async def update_absence_type(
|
||||||
|
type_id: UUID,
|
||||||
|
data: AbsenceTypeUpdate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
at = await absence_service.update_type(type_id, current_user.company_id, data, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(at)
|
||||||
|
return AbsenceTypeOut.model_validate(at)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public Holidays ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/public-holidays/", response_model=list[PublicHolidayOut])
|
||||||
|
async def list_public_holidays(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
year: int = Query(...),
|
||||||
|
country: str = Query("DE"),
|
||||||
|
state: str | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
holidays = await absence_service.list_holidays(year, country, state, db)
|
||||||
|
return [PublicHolidayOut.model_validate(h) for h in holidays]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/public-holidays/", response_model=PublicHolidayOut, status_code=201)
|
||||||
|
async def create_public_holiday(
|
||||||
|
data: PublicHolidayCreate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
holiday = await absence_service.create_holiday(data, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(holiday)
|
||||||
|
return PublicHolidayOut.model_validate(holiday)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Absences ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/absences/calendar", response_model=list[CalendarEntry])
|
||||||
|
async def get_calendar(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
year: int = Query(...),
|
||||||
|
month: int | None = Query(None, ge=1, le=12),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Team-Kalender: alle Abwesenheiten im Zeitraum."""
|
||||||
|
entries = await absence_service.get_calendar(current_user.company_id, year, month, db)
|
||||||
|
return [CalendarEntry(**e) for e in entries]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/absences/balance", response_model=VacationBalanceOut)
|
||||||
|
async def get_own_balance(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
year: int = Query(...),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Eigenes Urlaubskonto."""
|
||||||
|
balance = await absence_service.get_balance(current_user.id, year, db)
|
||||||
|
pending = await absence_service.get_pending_days(current_user.id, year, db)
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
|
||||||
|
return VacationBalanceOut.model_validate(balance).model_copy(update={
|
||||||
|
"pending_days": pending,
|
||||||
|
"carried_over_expires_at": expires_at,
|
||||||
|
"carried_over_expired": expired,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/absences/balance/{user_id}", response_model=VacationBalanceOut)
|
||||||
|
async def get_balance_for_user(
|
||||||
|
user_id: UUID,
|
||||||
|
year: int = Query(...),
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Urlaubskonto eines Mitarbeiters (MANAGER/HR/ADMIN)."""
|
||||||
|
target_user = await db.get(User, user_id)
|
||||||
|
if target_user is None or target_user.company_id != current_user.company_id:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
|
||||||
|
balance = await absence_service.get_balance(user_id, year, db)
|
||||||
|
pending = await absence_service.get_pending_days(user_id, year, db)
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
|
||||||
|
return VacationBalanceOut.model_validate(balance).model_copy(update={
|
||||||
|
"pending_days": pending,
|
||||||
|
"carried_over_expires_at": expires_at,
|
||||||
|
"carried_over_expired": expired,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/absences/quick-sick", response_model=AbsenceOut, status_code=201)
|
||||||
|
async def quick_sick(
|
||||||
|
data: QuickSickIn,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Sofort-Krankmeldung (auto-approved). Nutzt den ersten aktiven SICK-Typ der Firma."""
|
||||||
|
absence, _ = await absence_service.quick_sick(
|
||||||
|
data.start_date, data.end_date, current_user, db
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(absence)
|
||||||
|
return AbsenceOut.model_validate(absence)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/absences/sick-stats", response_model=list[SickStatsOut])
|
||||||
|
async def get_sick_stats(
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
ref_date: date | None = Query(None, description="Stichtag, default heute"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Krankheitsstatistik (rolling 12 Monate ab ref_date) inkl. Bradford-Faktor."""
|
||||||
|
stats = await absence_service.get_sick_stats(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
current_user=current_user,
|
||||||
|
ref_date=ref_date or date.today(),
|
||||||
|
db=db,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
return [SickStatsOut(**s) for s in stats]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/absences/overtime-balance", response_model=OvertimeBalanceOut)
|
||||||
|
async def get_overtime_balance(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Eigenes Überstunden-Konto."""
|
||||||
|
bal = await db.scalar(
|
||||||
|
select(OvertimeBalance).where(OvertimeBalance.user_id == current_user.id)
|
||||||
|
)
|
||||||
|
if bal is None:
|
||||||
|
return OvertimeBalanceOut(total_hours=0, taken_hours=0, available_hours=0)
|
||||||
|
return OvertimeBalanceOut(
|
||||||
|
total_hours=float(bal.total_hours),
|
||||||
|
taken_hours=float(bal.taken_hours),
|
||||||
|
available_hours=float(bal.available_hours),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/absences/", response_model=AbsenceListResponse)
|
||||||
|
async def list_absences(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
type_id: UUID | None = Query(None),
|
||||||
|
status: AbsenceStatus | None = Query(None),
|
||||||
|
year: int | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
total, absences = await absence_service.list_absences(
|
||||||
|
current_user.company_id, current_user, db,
|
||||||
|
user_id=user_id, type_id=type_id, status=status, year=year,
|
||||||
|
)
|
||||||
|
return AbsenceListResponse(total=total, items=[AbsenceOut.model_validate(a) for a in absences])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/absences/", response_model=AbsenceOut, status_code=201)
|
||||||
|
async def create_absence(
|
||||||
|
data: AbsenceCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Abwesenheitsantrag stellen. HR/Admin kann for_user_id setzen um für andere anzulegen."""
|
||||||
|
acting_user = current_user
|
||||||
|
if data.for_user_id and data.for_user_id != current_user.id:
|
||||||
|
if current_user.role not in _manager_roles:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung, Abwesenheiten für andere anzulegen.")
|
||||||
|
target = await db.get(User, data.for_user_id)
|
||||||
|
if not target or target.company_id != current_user.company_id:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
|
||||||
|
acting_user = target
|
||||||
|
absence, warnings = await absence_service.create_absence(data, acting_user, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(absence)
|
||||||
|
return AbsenceOut.model_validate(absence)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/absences/{absence_id}", response_model=AbsenceOut)
|
||||||
|
async def update_absence(
|
||||||
|
absence_id: UUID,
|
||||||
|
data: AbsenceUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Ausstehenden Antrag bearbeiten (Mitarbeiter: eigene; Manager: alle der Company)."""
|
||||||
|
absence = await absence_service.update_absence(absence_id, data, current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(absence)
|
||||||
|
return AbsenceOut.model_validate(absence)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/absences/{absence_id}", response_model=AbsenceOut)
|
||||||
|
async def get_absence(
|
||||||
|
absence_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
absence = await absence_service.get_by_id(absence_id, current_user, db)
|
||||||
|
return AbsenceOut.model_validate(absence)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/absences/{absence_id}", status_code=204)
|
||||||
|
async def cancel_absence(
|
||||||
|
absence_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Eigenen ausstehenden Antrag stornieren."""
|
||||||
|
await absence_service.cancel_absence(absence_id, current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/absences/{absence_id}/approve", response_model=AbsenceOut)
|
||||||
|
async def approve_absence(
|
||||||
|
absence_id: UUID,
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
absence = await absence_service.approve_absence(absence_id, current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(absence)
|
||||||
|
return AbsenceOut.model_validate(absence)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/absences/{absence_id}/reject", response_model=AbsenceOut)
|
||||||
|
async def reject_absence(
|
||||||
|
absence_id: UUID,
|
||||||
|
data: AbsenceReject,
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
absence = await absence_service.reject_absence(absence_id, data, current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(absence)
|
||||||
|
return AbsenceOut.model_validate(absence)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/absences/{absence_id}/certificate", response_model=AbsenceOut)
|
||||||
|
async def mark_certificate_received(
|
||||||
|
absence_id: UUID,
|
||||||
|
data: CertificateMarkIn,
|
||||||
|
current_user: User = require_role(UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""HR/Admin markiert die AU-Bescheinigung als eingegangen."""
|
||||||
|
absence = await absence_service.mark_certificate_received(
|
||||||
|
absence_id, data.received_at, current_user, db
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(absence)
|
||||||
|
return AbsenceOut.model_validate(absence)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Urlaubskonto bearbeiten (HR/Admin) ────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.patch("/absences/balance/{user_id}", response_model=VacationBalanceOut)
|
||||||
|
async def update_balance(
|
||||||
|
user_id: UUID,
|
||||||
|
data: VacationBalanceUpdate,
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
year: int = Query(...),
|
||||||
|
):
|
||||||
|
"""Grundurlaub, Sondertage und Resturlaub eines Mitarbeiters anpassen."""
|
||||||
|
from app.models.vacation_balance import VacationBalance
|
||||||
|
target = await db.get(User, user_id)
|
||||||
|
if target is None or target.company_id != current_user.company_id:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(404, "Mitarbeiter nicht gefunden")
|
||||||
|
|
||||||
|
balance = await absence_service.get_balance(user_id, year, db)
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(balance, field, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(balance)
|
||||||
|
pending = await absence_service.get_pending_days(user_id, year, db)
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
expires_at, expired = _carryover_expiry(company, year) if company else (None, False)
|
||||||
|
return VacationBalanceOut.model_validate(balance).model_copy(update={
|
||||||
|
"pending_days": pending,
|
||||||
|
"carried_over_expires_at": expires_at,
|
||||||
|
"carried_over_expired": expired,
|
||||||
|
})
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""AuditLog-Endpoint – nur für COMPANY_ADMIN und SUPER_ADMIN, company-isoliert."""
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import joinedload
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import require_role
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.audit_log import AuditLogEntry, AuditLogListResponse
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Audit Log"])
|
||||||
|
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-logs", response_model=AuditLogListResponse)
|
||||||
|
async def list_audit_logs(
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
action: str | None = Query(None),
|
||||||
|
entity_type: str | None = Query(None),
|
||||||
|
date_from: datetime | None = Query(None),
|
||||||
|
date_to: datetime | None = Query(None),
|
||||||
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
base_filter = [AuditLog.company_id == current_user.company_id]
|
||||||
|
|
||||||
|
if current_user.role == UserRole.SUPER_ADMIN:
|
||||||
|
base_filter = [] # SUPER_ADMIN sieht alle Firmen
|
||||||
|
|
||||||
|
if user_id:
|
||||||
|
base_filter.append(AuditLog.user_id == user_id)
|
||||||
|
if action:
|
||||||
|
base_filter.append(AuditLog.action.ilike(f"%{action}%"))
|
||||||
|
if entity_type:
|
||||||
|
base_filter.append(AuditLog.entity_type == entity_type)
|
||||||
|
if date_from:
|
||||||
|
base_filter.append(AuditLog.created_at >= date_from)
|
||||||
|
if date_to:
|
||||||
|
base_filter.append(AuditLog.created_at <= date_to)
|
||||||
|
|
||||||
|
count_q = select(func.count()).select_from(AuditLog).where(*base_filter)
|
||||||
|
total = await db.scalar(count_q) or 0
|
||||||
|
|
||||||
|
rows_q = (
|
||||||
|
select(AuditLog, User.first_name, User.last_name)
|
||||||
|
.outerjoin(User, AuditLog.user_id == User.id)
|
||||||
|
.where(*base_filter)
|
||||||
|
.order_by(AuditLog.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
rows = (await db.execute(rows_q)).all()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
AuditLogEntry(
|
||||||
|
id=log.id,
|
||||||
|
user_id=log.user_id,
|
||||||
|
user_name=f"{first} {last}".strip() if first or last else None,
|
||||||
|
action=log.action,
|
||||||
|
entity_type=log.entity_type,
|
||||||
|
entity_id=log.entity_id,
|
||||||
|
old_value=log.old_value,
|
||||||
|
new_value=log.new_value,
|
||||||
|
ip_address=log.ip,
|
||||||
|
created_at=log.created_at,
|
||||||
|
)
|
||||||
|
for log, first, last in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return AuditLogListResponse(total=total, items=items)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-logs/actions", response_model=list[str])
|
||||||
|
async def list_audit_actions(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Alle vorhandenen Action-Werte für Filter-Dropdown."""
|
||||||
|
filter_cond = (
|
||||||
|
[] if current_user.role == UserRole.SUPER_ADMIN
|
||||||
|
else [AuditLog.company_id == current_user.company_id]
|
||||||
|
)
|
||||||
|
q = (
|
||||||
|
select(AuditLog.action)
|
||||||
|
.where(*filter_cond)
|
||||||
|
.distinct()
|
||||||
|
.order_by(AuditLog.action)
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return [r for (r,) in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit-logs/entity-types", response_model=list[str])
|
||||||
|
async def list_entity_types(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Alle vorhandenen Entity-Typen für Filter-Dropdown."""
|
||||||
|
filter_cond = (
|
||||||
|
[AuditLog.entity_type.isnot(None)]
|
||||||
|
if current_user.role == UserRole.SUPER_ADMIN
|
||||||
|
else [AuditLog.company_id == current_user.company_id, AuditLog.entity_type.isnot(None)]
|
||||||
|
)
|
||||||
|
q = (
|
||||||
|
select(AuditLog.entity_type)
|
||||||
|
.where(*filter_cond)
|
||||||
|
.distinct()
|
||||||
|
.order_by(AuditLog.entity_type)
|
||||||
|
)
|
||||||
|
result = await db.execute(q)
|
||||||
|
return [r for (r,) in result.all()]
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import CurrentUser
|
||||||
|
from app.core.limiter import limiter
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
from app.schemas.auth import (
|
||||||
|
LoginRequest,
|
||||||
|
MessageResponse,
|
||||||
|
PasswordResetConfirm,
|
||||||
|
PasswordResetRequest,
|
||||||
|
RefreshRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
TokenResponse,
|
||||||
|
TotpConfirmRequest,
|
||||||
|
TotpDisableRequest,
|
||||||
|
TotpLoginRequest,
|
||||||
|
TotpSetupResponse,
|
||||||
|
)
|
||||||
|
from app.schemas.user import InviteAccept, UserOut
|
||||||
|
from app.services.auth_service import auth_service
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
current_password: str
|
||||||
|
new_password: str = Field(min_length=8)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["Auth"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=TokenResponse, status_code=201)
|
||||||
|
@limiter.limit("3/hour")
|
||||||
|
async def register(request: Request, data: RegisterRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""Create a new company + admin account."""
|
||||||
|
return await auth_service.register(data, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def login(request: Request, data: LoginRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
return await auth_service.login(data, db, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
async def refresh(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
return await auth_service.refresh(data.refresh_token, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", response_model=MessageResponse)
|
||||||
|
async def logout(data: RefreshRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
await auth_service.logout(data.refresh_token, db)
|
||||||
|
return MessageResponse(message="Logged out successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset", response_model=MessageResponse)
|
||||||
|
@limiter.limit("3/hour")
|
||||||
|
async def request_password_reset(request: Request, data: PasswordResetRequest, db: AsyncSession = Depends(get_db)):
|
||||||
|
result = await auth_service.request_password_reset(data.email, db)
|
||||||
|
if result == "ldap":
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Dein Konto wird über LDAP verwaltet. Bitte setze dein Passwort direkt beim LDAP-Administrator zurück.",
|
||||||
|
)
|
||||||
|
return MessageResponse(message="Falls diese E-Mail-Adresse registriert ist, wurde ein Reset-Link verschickt.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/password-reset/confirm", response_model=MessageResponse)
|
||||||
|
@limiter.limit("5/hour")
|
||||||
|
async def confirm_password_reset(request: Request, data: PasswordResetConfirm, db: AsyncSession = Depends(get_db)):
|
||||||
|
await auth_service.confirm_password_reset(data.token, data.new_password, db)
|
||||||
|
return MessageResponse(message="Password updated successfully")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invite/accept", response_model=UserOut)
|
||||||
|
@limiter.limit("10/hour")
|
||||||
|
async def accept_invite(request: Request, data: InviteAccept, db: AsyncSession = Depends(get_db)):
|
||||||
|
from app.services.user_service import user_service
|
||||||
|
user = await user_service.accept_invite(data, db)
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def me(current_user: CurrentUser):
|
||||||
|
return UserOut.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-password", response_model=MessageResponse)
|
||||||
|
async def change_password(
|
||||||
|
data: ChangePasswordRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Passwort ändern (eingeloggter User, benötigt aktuelles Passwort)."""
|
||||||
|
if not verify_password(data.current_password, current_user.password_hash):
|
||||||
|
raise HTTPException(status_code=400, detail="Aktuelles Passwort ist falsch")
|
||||||
|
import re
|
||||||
|
if not re.search(r'[A-Z]', data.new_password) or not re.search(r'[0-9]', data.new_password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Neues Passwort muss mindestens 1 Großbuchstaben und 1 Zahl enthalten"
|
||||||
|
)
|
||||||
|
current_user.password_hash = hash_password(data.new_password)
|
||||||
|
await db.commit()
|
||||||
|
return MessageResponse(message="Passwort erfolgreich geändert")
|
||||||
|
|
||||||
|
|
||||||
|
# ── TOTP / 2FA ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/totp/setup", response_model=TotpSetupResponse)
|
||||||
|
async def totp_setup(current_user: CurrentUser):
|
||||||
|
"""Generiert ein neues TOTP-Secret und gibt die otpauth-URI zurück. Noch nicht aktiviert."""
|
||||||
|
import pyotp
|
||||||
|
secret = pyotp.random_base32()
|
||||||
|
issuer = "TimeMaster"
|
||||||
|
label = current_user.email
|
||||||
|
uri = pyotp.totp.TOTP(secret).provisioning_uri(name=label, issuer_name=issuer)
|
||||||
|
# Secret temporär im User speichern (noch nicht totp_enabled)
|
||||||
|
current_user.totp_secret = secret
|
||||||
|
# Hinweis: DB-Commit passiert NICHT hier – erst nach verify in /totp/confirm
|
||||||
|
# Damit das Secret nicht verloren geht, sofort speichern
|
||||||
|
return TotpSetupResponse(secret=secret, otpauth_uri=uri)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/setup/save", response_model=MessageResponse)
|
||||||
|
async def totp_setup_save(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Speichert das generierte Secret temporär (ohne Aktivierung)."""
|
||||||
|
import pyotp
|
||||||
|
if not current_user.totp_secret:
|
||||||
|
secret = pyotp.random_base32()
|
||||||
|
current_user.totp_secret = secret
|
||||||
|
await db.commit()
|
||||||
|
return MessageResponse(message="Secret gespeichert")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/confirm", response_model=MessageResponse)
|
||||||
|
async def totp_confirm(
|
||||||
|
data: TotpConfirmRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Bestätigt den ersten TOTP-Code und aktiviert 2FA."""
|
||||||
|
import pyotp
|
||||||
|
if not current_user.totp_secret:
|
||||||
|
raise HTTPException(400, "Kein TOTP-Secret vorhanden. Zuerst /totp/setup aufrufen.")
|
||||||
|
totp = pyotp.TOTP(current_user.totp_secret)
|
||||||
|
if not totp.verify(data.code, valid_window=1):
|
||||||
|
raise HTTPException(400, "Ungültiger Code")
|
||||||
|
current_user.totp_enabled = True
|
||||||
|
await db.commit()
|
||||||
|
return MessageResponse(message="Zwei-Faktor-Authentifizierung aktiviert")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/disable", response_model=MessageResponse)
|
||||||
|
async def totp_disable(
|
||||||
|
data: TotpDisableRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Deaktiviert TOTP. Benötigt aktuelles Passwort + gültigen TOTP-Code."""
|
||||||
|
import pyotp
|
||||||
|
if not verify_password(data.password, current_user.password_hash or ""):
|
||||||
|
raise HTTPException(400, "Passwort falsch")
|
||||||
|
if not current_user.totp_enabled or not current_user.totp_secret:
|
||||||
|
raise HTTPException(400, "2FA ist nicht aktiv")
|
||||||
|
totp = pyotp.TOTP(current_user.totp_secret)
|
||||||
|
if not totp.verify(data.code, valid_window=1):
|
||||||
|
raise HTTPException(400, "Ungültiger TOTP-Code")
|
||||||
|
current_user.totp_enabled = False
|
||||||
|
current_user.totp_secret = None
|
||||||
|
await db.commit()
|
||||||
|
return MessageResponse(message="Zwei-Faktor-Authentifizierung deaktiviert")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/totp/login", response_model=TokenResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def totp_login(
|
||||||
|
request: Request,
|
||||||
|
data: TotpLoginRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Zweiter Login-Schritt: partial_token + TOTP-Code → volle Tokens."""
|
||||||
|
import pyotp
|
||||||
|
from uuid import UUID
|
||||||
|
from app.core.security import decode_partial_token
|
||||||
|
from app.models.user import User
|
||||||
|
from jose import JWTError
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = decode_partial_token(data.partial_token)
|
||||||
|
except JWTError:
|
||||||
|
raise HTTPException(401, "Ungültiger oder abgelaufener Token")
|
||||||
|
|
||||||
|
user = await db.get(User, UUID(user_id))
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(401, "Benutzer nicht gefunden")
|
||||||
|
if not user.totp_enabled or not user.totp_secret:
|
||||||
|
raise HTTPException(400, "2FA nicht aktiv")
|
||||||
|
|
||||||
|
totp = pyotp.TOTP(user.totp_secret)
|
||||||
|
if not totp.verify(data.code, valid_window=1):
|
||||||
|
raise HTTPException(400, "Ungültiger Code")
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
return await auth_service._create_session(user, db, request=request)
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""Busylight-Integration (Pull-Endpoint + Token-Verwaltung).
|
||||||
|
|
||||||
|
- Pull: GET /busylight/users – Auth via per-Firma Bearer-Token (SHA-256 in DB)
|
||||||
|
- Verwaltung: POST/DELETE /companies/me/busylight-token – COMPANY_ADMIN/SUPER_ADMIN
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
from datetime import date, datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import require_role
|
||||||
|
from app.core.limiter import limiter
|
||||||
|
from app.models.absence import Absence, AbsenceStatus
|
||||||
|
from app.models.absence_type import AbsenceType
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.busylight import (
|
||||||
|
BusylightAbsenceItem,
|
||||||
|
BusylightTokenRotated,
|
||||||
|
BusylightTokenStatus,
|
||||||
|
BusylightUserItem,
|
||||||
|
BusylightUsersResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Busylight"])
|
||||||
|
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
_pull_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(token: str) -> str:
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Token-Verwaltung (eingeloggter Admin) ────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/companies/me/busylight-token", response_model=BusylightTokenStatus)
|
||||||
|
async def get_busylight_token_status(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
return BusylightTokenStatus(
|
||||||
|
configured=company.busylight_pull_token_hash is not None,
|
||||||
|
created_at=company.busylight_token_created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/companies/me/busylight-token/rotate", response_model=BusylightTokenRotated)
|
||||||
|
async def rotate_busylight_token(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
company.busylight_pull_token_hash = _hash_token(token)
|
||||||
|
company.busylight_token_created_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=company.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="busylight_token_rotated",
|
||||||
|
entity_type="company",
|
||||||
|
entity_id=company.id,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
return BusylightTokenRotated(token=token, created_at=company.busylight_token_created_at)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/companies/me/busylight-token", status_code=204)
|
||||||
|
async def delete_busylight_token(
|
||||||
|
request: Request,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
if company.busylight_pull_token_hash is None:
|
||||||
|
return
|
||||||
|
company.busylight_pull_token_hash = None
|
||||||
|
company.busylight_token_created_at = None
|
||||||
|
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=company.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="busylight_token_revoked",
|
||||||
|
entity_type="company",
|
||||||
|
entity_id=company.id,
|
||||||
|
ip=request.client.host if request.client else None,
|
||||||
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pull-Endpoint (busylight liest hier) ─────────────────────────────────────
|
||||||
|
|
||||||
|
async def _company_from_token(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Company:
|
||||||
|
if credentials is None or not credentials.credentials:
|
||||||
|
raise HTTPException(status_code=401, detail="Missing token")
|
||||||
|
token_hash = _hash_token(credentials.credentials)
|
||||||
|
company = await db.scalar(
|
||||||
|
select(Company).where(Company.busylight_pull_token_hash == token_hash)
|
||||||
|
)
|
||||||
|
if company is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid token")
|
||||||
|
return company
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/busylight/users", response_model=BusylightUsersResponse)
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def list_users_for_busylight(
|
||||||
|
request: Request,
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_pull_bearer),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
company = await _company_from_token(credentials, db)
|
||||||
|
today = date.today()
|
||||||
|
|
||||||
|
users = (await db.scalars(
|
||||||
|
select(User)
|
||||||
|
.where(
|
||||||
|
User.company_id == company.id,
|
||||||
|
User.is_active == True,
|
||||||
|
User.personnel_number.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(User.last_name, User.first_name)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
user_ids = [u.id for u in users]
|
||||||
|
abs_rows = []
|
||||||
|
if user_ids:
|
||||||
|
abs_rows = (await db.execute(
|
||||||
|
select(Absence, AbsenceType)
|
||||||
|
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||||||
|
.where(
|
||||||
|
Absence.user_id.in_(user_ids),
|
||||||
|
Absence.status == AbsenceStatus.APPROVED,
|
||||||
|
Absence.start_date <= today,
|
||||||
|
Absence.end_date >= today,
|
||||||
|
)
|
||||||
|
)).all()
|
||||||
|
|
||||||
|
by_user: dict = {uid: [] for uid in user_ids}
|
||||||
|
for absence, atype in abs_rows:
|
||||||
|
by_user[absence.user_id].append(
|
||||||
|
BusylightAbsenceItem(type=atype.name, category=atype.category.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
BusylightUserItem(
|
||||||
|
personnel_number=u.personnel_number,
|
||||||
|
full_name=u.full_name,
|
||||||
|
absences_today=by_user[u.id],
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
return BusylightUsersResponse(date=today, users=items)
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
"""
|
||||||
|
CalDAV-Konfiguration und manueller Sync-Trigger.
|
||||||
|
|
||||||
|
Firmenkalender: nur COMPANY_ADMIN / SUPER_ADMIN
|
||||||
|
Persönlicher Kalender: jeder eingeloggte Nutzer für sich selbst
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import CurrentUser, require_role
|
||||||
|
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.caldav import (
|
||||||
|
CaldavCompanyConfigOut,
|
||||||
|
CaldavCompanyConfigSave,
|
||||||
|
CaldavUserConfigOut,
|
||||||
|
CaldavUserConfigSave,
|
||||||
|
ResyncResult,
|
||||||
|
)
|
||||||
|
from app.services.caldav_service import caldav_service, encrypt_password
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/caldav", tags=["CalDAV"])
|
||||||
|
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Firmenkalender ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/company/config", response_model=CaldavCompanyConfigOut | None)
|
||||||
|
async def get_company_config(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await caldav_service.get_company_config(current_user.company_id, db)
|
||||||
|
return CaldavCompanyConfigOut.model_validate(cfg) if cfg else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/company/config", response_model=CaldavCompanyConfigOut)
|
||||||
|
async def save_company_config(
|
||||||
|
data: CaldavCompanyConfigSave,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await caldav_service.get_company_config(current_user.company_id, db)
|
||||||
|
if cfg is None:
|
||||||
|
cfg = CaldavCompanyConfig(company_id=current_user.company_id, id=uuid.uuid4())
|
||||||
|
db.add(cfg)
|
||||||
|
|
||||||
|
cfg.enabled = data.enabled
|
||||||
|
cfg.principal_url = data.principal_url
|
||||||
|
cfg.calendar_url = data.calendar_url
|
||||||
|
cfg.username = data.username
|
||||||
|
cfg.calendar_display_name = data.calendar_display_name
|
||||||
|
cfg.verify_ssl = data.verify_ssl
|
||||||
|
if data.password:
|
||||||
|
cfg.password_encrypted = encrypt_password(data.password)
|
||||||
|
elif not cfg.password_encrypted:
|
||||||
|
raise HTTPException(status_code=400, detail="Passwort wird beim ersten Speichern benötigt.")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return CaldavCompanyConfigOut.model_validate(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/company/test")
|
||||||
|
async def test_company_config(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await caldav_service.get_company_config(current_user.company_id, db)
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="Keine Firmen-CalDAV-Konfiguration vorhanden.")
|
||||||
|
result = await caldav_service.test_config(cfg)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise HTTPException(status_code=502, detail=result["error"])
|
||||||
|
return {"message": "Verbindung erfolgreich.", "http_status": result.get("status")}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/company/resync", response_model=ResyncResult)
|
||||||
|
async def resync_all(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Alle genehmigten Abwesenheiten neu in den Firmenkalender synchronisieren."""
|
||||||
|
result = await caldav_service.resync_all_approved(current_user.company_id, db)
|
||||||
|
await db.commit()
|
||||||
|
return ResyncResult(**result)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Persönlicher Kalender ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/user/config", response_model=CaldavUserConfigOut | None)
|
||||||
|
async def get_user_config(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await caldav_service.get_user_config(current_user.id, db)
|
||||||
|
return CaldavUserConfigOut.model_validate(cfg) if cfg else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user/config", response_model=CaldavUserConfigOut)
|
||||||
|
async def save_user_config(
|
||||||
|
data: CaldavUserConfigSave,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await caldav_service.get_user_config(current_user.id, db)
|
||||||
|
if cfg is None:
|
||||||
|
cfg = CaldavUserConfig(user_id=current_user.id, id=uuid.uuid4())
|
||||||
|
db.add(cfg)
|
||||||
|
|
||||||
|
cfg.enabled = data.enabled
|
||||||
|
cfg.principal_url = data.principal_url
|
||||||
|
cfg.calendar_url = data.calendar_url
|
||||||
|
cfg.username = data.username
|
||||||
|
cfg.calendar_display_name = data.calendar_display_name
|
||||||
|
cfg.verify_ssl = data.verify_ssl
|
||||||
|
if data.password:
|
||||||
|
cfg.password_encrypted = encrypt_password(data.password)
|
||||||
|
elif not cfg.password_encrypted:
|
||||||
|
raise HTTPException(status_code=400, detail="Passwort wird beim ersten Speichern benötigt.")
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return CaldavUserConfigOut.model_validate(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user/test")
|
||||||
|
async def test_user_config(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await caldav_service.get_user_config(current_user.id, db)
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="Keine persönliche CalDAV-Konfiguration vorhanden.")
|
||||||
|
result = await caldav_service.test_config(cfg)
|
||||||
|
if not result["ok"]:
|
||||||
|
raise HTTPException(status_code=502, detail=result["error"])
|
||||||
|
return {"message": "Verbindung erfolgreich.", "http_status": result.get("status")}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import CurrentUser, require_role
|
||||||
|
from app.models import Company
|
||||||
|
from app.models.department import Department
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.company import (
|
||||||
|
CompanyOut,
|
||||||
|
CompanyUpdate,
|
||||||
|
DepartmentCreate,
|
||||||
|
DepartmentOut,
|
||||||
|
DepartmentUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/companies", tags=["Companies"])
|
||||||
|
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=CompanyOut)
|
||||||
|
async def get_my_company(current_user: CurrentUser, db: AsyncSession = Depends(get_db)):
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
return CompanyOut.model_validate(company)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me", response_model=CompanyOut)
|
||||||
|
async def update_my_company(
|
||||||
|
data: CompanyUpdate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(company, field, value)
|
||||||
|
return CompanyOut.model_validate(company)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Departments ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/me/departments", response_model=list[DepartmentOut])
|
||||||
|
async def list_departments(current_user: CurrentUser, db: AsyncSession = Depends(get_db)):
|
||||||
|
depts = await db.scalars(
|
||||||
|
select(Department).where(Department.company_id == current_user.company_id)
|
||||||
|
)
|
||||||
|
return [DepartmentOut.model_validate(d) for d in depts.all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/departments", response_model=DepartmentOut, status_code=201)
|
||||||
|
async def create_department(
|
||||||
|
data: DepartmentCreate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
dept = Department(company_id=current_user.company_id, **data.model_dump())
|
||||||
|
db.add(dept)
|
||||||
|
await db.flush()
|
||||||
|
return DepartmentOut.model_validate(dept)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/me/departments/{dept_id}", response_model=DepartmentOut)
|
||||||
|
async def update_department(
|
||||||
|
dept_id: UUID,
|
||||||
|
data: DepartmentUpdate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
dept = await db.get(Department, dept_id)
|
||||||
|
if not dept or dept.company_id != current_user.company_id:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="Department not found")
|
||||||
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(dept, field, value)
|
||||||
|
return DepartmentOut.model_validate(dept)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/me/departments/{dept_id}", status_code=204)
|
||||||
|
async def delete_department(
|
||||||
|
dept_id: UUID,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
dept = await db.get(Department, dept_id)
|
||||||
|
if not dept or dept.company_id != current_user.company_id:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="Department not found")
|
||||||
|
await db.delete(dept)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""Router: Kimai CSV Import (nur HR / Admin)."""
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_db, require_role
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.services.kimai_import_service import (
|
||||||
|
ImportPreviewEntry,
|
||||||
|
ImportResult,
|
||||||
|
preview_kimai_import,
|
||||||
|
run_kimai_import,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/import", tags=["import"])
|
||||||
|
|
||||||
|
_allowed_roles = [UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportPreviewResponse(BaseModel):
|
||||||
|
preview: list[ImportPreviewEntry]
|
||||||
|
time_count: int
|
||||||
|
absence_count: int
|
||||||
|
skip_count: int
|
||||||
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportRunResponse(BaseModel):
|
||||||
|
time_imported: int
|
||||||
|
absence_imported: int
|
||||||
|
skipped: int
|
||||||
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/kimai/preview", response_model=ImportPreviewResponse)
|
||||||
|
async def kimai_preview(
|
||||||
|
user_id: Annotated[str, Form()],
|
||||||
|
file: Annotated[UploadFile, File()],
|
||||||
|
current_user: User = require_role(*_allowed_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Vorschau des Kimai-Imports (keine DB-Änderungen)."""
|
||||||
|
try:
|
||||||
|
target_id = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültige user_id")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
result: ImportResult = await preview_kimai_import(content, target_id, db)
|
||||||
|
|
||||||
|
time_count = sum(1 for p in result.preview if p.kind == "time" and not p.skipped)
|
||||||
|
abs_count = sum(1 for p in result.preview if p.kind == "absence" and not p.skipped)
|
||||||
|
|
||||||
|
return ImportPreviewResponse(
|
||||||
|
preview=result.preview,
|
||||||
|
time_count=time_count,
|
||||||
|
absence_count=abs_count,
|
||||||
|
skip_count=result.skipped,
|
||||||
|
errors=result.errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/kimai/run", response_model=ImportRunResponse)
|
||||||
|
async def kimai_run(
|
||||||
|
user_id: Annotated[str, Form()],
|
||||||
|
file: Annotated[UploadFile, File()],
|
||||||
|
current_user: User = require_role(*_allowed_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Führt den Kimai-Import durch (schreibt in DB)."""
|
||||||
|
try:
|
||||||
|
target_id = uuid.UUID(user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Ungültige user_id")
|
||||||
|
|
||||||
|
content = await file.read()
|
||||||
|
result: ImportResult = await run_kimai_import(content, target_id, current_user.id, db)
|
||||||
|
|
||||||
|
return ImportRunResponse(
|
||||||
|
time_imported=result.time_imported,
|
||||||
|
absence_imported=result.absence_imported,
|
||||||
|
skipped=result.skipped,
|
||||||
|
errors=result.errors,
|
||||||
|
)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import require_role
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceCreated, KioskDeviceOut, KioskDeviceUpdate
|
||||||
|
from app.services.kiosk_service import kiosk_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/kiosk", tags=["Kiosk"])
|
||||||
|
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Geräteverwaltung (COMPANY_ADMIN) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/devices", response_model=list[KioskDeviceOut])
|
||||||
|
async def list_devices(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Alle registrierten Kiosk-Geräte der Firma auflisten."""
|
||||||
|
return await kiosk_service.list_devices(current_user.company_id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices", response_model=KioskDeviceCreated, status_code=201)
|
||||||
|
async def create_device(
|
||||||
|
data: KioskDeviceCreate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Neues Kiosk-Gerät registrieren. Token wird nur einmalig zurückgegeben."""
|
||||||
|
device, raw_token = await kiosk_service.create_device(current_user.company_id, data, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(device)
|
||||||
|
return KioskDeviceCreated(
|
||||||
|
**KioskDeviceOut.model_validate(device).model_dump(),
|
||||||
|
token=raw_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices/{device_id}", response_model=KioskDeviceOut)
|
||||||
|
async def get_device(
|
||||||
|
device_id: UUID,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await kiosk_service.get_device(device_id, current_user.company_id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/devices/{device_id}", response_model=KioskDeviceOut)
|
||||||
|
async def update_device(
|
||||||
|
device_id: UUID,
|
||||||
|
data: KioskDeviceUpdate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
device = await kiosk_service.update_device(device_id, current_user.company_id, data, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(device)
|
||||||
|
return KioskDeviceOut.model_validate(device)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{device_id}/rotate-token", response_model=KioskDeviceCreated)
|
||||||
|
async def rotate_token(
|
||||||
|
device_id: UUID,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Token rotieren – das alte Token wird sofort ungültig."""
|
||||||
|
device, raw_token = await kiosk_service.rotate_token(device_id, current_user.company_id, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(device)
|
||||||
|
return KioskDeviceCreated(
|
||||||
|
**KioskDeviceOut.model_validate(device).model_dump(),
|
||||||
|
token=raw_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/devices/{device_id}", status_code=204)
|
||||||
|
async def delete_device(
|
||||||
|
device_id: UUID,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
await kiosk_service.delete_device(device_id, current_user.company_id, db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kiosk-Auth (Gerät authentifiziert sich per Token) ─────────────────────────
|
||||||
|
|
||||||
|
@router.get("/me", response_model=KioskDeviceOut)
|
||||||
|
async def kiosk_me(
|
||||||
|
x_kiosk_token: str = Header(..., alias="X-Kiosk-Token", min_length=32, max_length=128),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Kiosk-Gerät prüft seine eigene Identität / aktualisiert last_seen_at."""
|
||||||
|
device = await kiosk_service.authenticate_device(x_kiosk_token, db)
|
||||||
|
await db.commit()
|
||||||
|
return KioskDeviceOut.model_validate(device)
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""LDAP configuration and sync endpoints.
|
||||||
|
|
||||||
|
All endpoints require COMPANY_ADMIN or SUPER_ADMIN role.
|
||||||
|
"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import require_role
|
||||||
|
from app.models.ldap_config import LdapConfig
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
from app.schemas.ldap import (
|
||||||
|
LdapConfigCreate,
|
||||||
|
LdapConfigOut,
|
||||||
|
LdapConfigUpdate,
|
||||||
|
LdapSyncRequest,
|
||||||
|
LdapSyncResult,
|
||||||
|
LdapTestResult,
|
||||||
|
LdapUserPreview,
|
||||||
|
)
|
||||||
|
from app.services.ldap_service import decrypt_password, encrypt_password, ldap_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ldap", tags=["LDAP"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=LdapConfigOut | None)
|
||||||
|
async def get_ldap_config(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await ldap_service.get_config(current_user.company_id, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config", response_model=LdapConfigOut)
|
||||||
|
async def create_ldap_config(
|
||||||
|
data: LdapConfigCreate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
existing = await ldap_service.get_config(current_user.company_id, db)
|
||||||
|
if existing:
|
||||||
|
# Update instead of duplicate
|
||||||
|
return await _apply_update(existing, data.model_dump(), db)
|
||||||
|
|
||||||
|
cfg = LdapConfig(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
enabled=data.enabled,
|
||||||
|
host=data.host,
|
||||||
|
port=data.port,
|
||||||
|
use_ssl=data.use_ssl,
|
||||||
|
use_tls=data.use_tls,
|
||||||
|
bind_dn=data.bind_dn,
|
||||||
|
bind_password_encrypted=encrypt_password(data.bind_password),
|
||||||
|
base_dn=data.base_dn,
|
||||||
|
user_search_filter=data.user_search_filter,
|
||||||
|
attr_email=data.attr_email,
|
||||||
|
attr_firstname=data.attr_firstname,
|
||||||
|
attr_lastname=data.attr_lastname,
|
||||||
|
attr_username=data.attr_username,
|
||||||
|
attr_department=data.attr_department,
|
||||||
|
)
|
||||||
|
db.add(cfg)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/config", response_model=LdapConfigOut)
|
||||||
|
async def update_ldap_config(
|
||||||
|
data: LdapConfigUpdate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
|
||||||
|
return await _apply_update(cfg, data.model_dump(exclude_none=True), db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test", response_model=LdapTestResult)
|
||||||
|
async def test_ldap_connection(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
|
||||||
|
result = ldap_service.test_connection(cfg)
|
||||||
|
return LdapTestResult(success=result.success, message=result.message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preview", response_model=list[LdapUserPreview])
|
||||||
|
async def preview_ldap_users(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Returns first 50 users found in LDAP (for preview before sync)."""
|
||||||
|
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
|
||||||
|
raw_users = ldap_service.search_users(cfg)
|
||||||
|
previews = []
|
||||||
|
for u in raw_users[:50]:
|
||||||
|
previews.append(LdapUserPreview(
|
||||||
|
dn=u.get("dn", ""),
|
||||||
|
email=str(u.get(cfg.attr_email, "") or "").lower(),
|
||||||
|
first_name=str(u.get(cfg.attr_firstname, "") or ""),
|
||||||
|
last_name=str(u.get(cfg.attr_lastname, "") or ""),
|
||||||
|
department=str(u.get(cfg.attr_department, "") or "") if cfg.attr_department else None,
|
||||||
|
))
|
||||||
|
return previews
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sync", response_model=LdapSyncResult)
|
||||||
|
async def sync_ldap_users(
|
||||||
|
data: LdapSyncRequest,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await ldap_service.get_config_or_404(current_user.company_id, db)
|
||||||
|
result = await ldap_service.sync_users(cfg, db, default_role=data.default_role)
|
||||||
|
return LdapSyncResult(
|
||||||
|
created=result.created,
|
||||||
|
updated=result.updated,
|
||||||
|
deactivated=result.deactivated,
|
||||||
|
errors=result.errors,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _apply_update(cfg: LdapConfig, updates: dict, db: AsyncSession) -> LdapConfig:
|
||||||
|
for field, value in updates.items():
|
||||||
|
if field == "bind_password" and value:
|
||||||
|
cfg.bind_password_encrypted = encrypt_password(value)
|
||||||
|
elif hasattr(cfg, field):
|
||||||
|
setattr(cfg, field, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return cfg
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
from datetime import date
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import CurrentUser, require_role
|
||||||
|
from app.models.project import Project
|
||||||
|
from app.models.time_entry import TimeEntry, EntryStatus
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.project import (
|
||||||
|
ProjectCreate,
|
||||||
|
ProjectListResponse,
|
||||||
|
ProjectOut,
|
||||||
|
ProjectTimeReport,
|
||||||
|
ProjectUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/projects", tags=["Projekte"])
|
||||||
|
|
||||||
|
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_company(project: Project, company_id: UUID) -> None:
|
||||||
|
if project.company_id != company_id:
|
||||||
|
raise HTTPException(404, "Projekt nicht gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ProjectListResponse)
|
||||||
|
async def list_projects(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
include_inactive: bool = Query(False),
|
||||||
|
):
|
||||||
|
stmt = select(Project).where(Project.company_id == current_user.company_id)
|
||||||
|
if not include_inactive:
|
||||||
|
stmt = stmt.where(Project.is_active == True)
|
||||||
|
stmt = stmt.order_by(Project.name)
|
||||||
|
result = await db.scalars(stmt)
|
||||||
|
items = list(result.all())
|
||||||
|
return ProjectListResponse(total=len(items), items=items)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ProjectOut, status_code=201)
|
||||||
|
async def create_project(
|
||||||
|
data: ProjectCreate,
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
project = Project(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
color=data.color,
|
||||||
|
budget_hours=data.budget_hours,
|
||||||
|
)
|
||||||
|
db.add(project)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/report/summary", response_model=list[ProjectTimeReport])
|
||||||
|
async def projects_summary(
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
date_from: date | None = Query(None),
|
||||||
|
date_to: date | None = Query(None),
|
||||||
|
):
|
||||||
|
projects = list((await db.scalars(
|
||||||
|
select(Project).where(Project.company_id == current_user.company_id, Project.is_active == True)
|
||||||
|
)).all())
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for project in projects:
|
||||||
|
stmt = (
|
||||||
|
select(TimeEntry)
|
||||||
|
.join(TimeEntry.user)
|
||||||
|
.where(
|
||||||
|
TimeEntry.project_id == project.id,
|
||||||
|
User.company_id == current_user.company_id,
|
||||||
|
TimeEntry.status == EntryStatus.APPROVED,
|
||||||
|
TimeEntry.end_time.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if date_from:
|
||||||
|
stmt = stmt.where(TimeEntry.date >= date_from)
|
||||||
|
if date_to:
|
||||||
|
stmt = stmt.where(TimeEntry.date <= date_to)
|
||||||
|
|
||||||
|
entries = list((await db.scalars(stmt)).all())
|
||||||
|
total_minutes = sum(e.worked_minutes or 0 for e in entries)
|
||||||
|
total_hours = round(total_minutes / 60, 2)
|
||||||
|
|
||||||
|
budget_used_pct = None
|
||||||
|
if project.budget_hours and float(project.budget_hours) > 0:
|
||||||
|
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
|
||||||
|
|
||||||
|
result.append(ProjectTimeReport(
|
||||||
|
project_id=project.id,
|
||||||
|
project_name=project.name,
|
||||||
|
project_color=project.color,
|
||||||
|
total_hours=total_hours,
|
||||||
|
entry_count=len(entries),
|
||||||
|
budget_hours=float(project.budget_hours) if project.budget_hours else None,
|
||||||
|
budget_used_pct=budget_used_pct,
|
||||||
|
))
|
||||||
|
|
||||||
|
result.sort(key=lambda x: x.total_hours, reverse=True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}", response_model=ProjectOut)
|
||||||
|
async def get_project(
|
||||||
|
project_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
project = await db.get(Project, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Projekt nicht gefunden")
|
||||||
|
_assert_company(project, current_user.company_id)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{project_id}", response_model=ProjectOut)
|
||||||
|
async def update_project(
|
||||||
|
project_id: UUID,
|
||||||
|
data: ProjectUpdate,
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
project = await db.get(Project, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Projekt nicht gefunden")
|
||||||
|
_assert_company(project, current_user.company_id)
|
||||||
|
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(project, field, value)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(project)
|
||||||
|
return project
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{project_id}", status_code=204)
|
||||||
|
async def delete_project(
|
||||||
|
project_id: UUID,
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
project = await db.get(Project, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Projekt nicht gefunden")
|
||||||
|
_assert_company(project, current_user.company_id)
|
||||||
|
project.is_active = False
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/report", response_model=ProjectTimeReport)
|
||||||
|
async def project_time_report(
|
||||||
|
project_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
date_from: date | None = Query(None),
|
||||||
|
date_to: date | None = Query(None),
|
||||||
|
):
|
||||||
|
project = await db.get(Project, project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(404, "Projekt nicht gefunden")
|
||||||
|
_assert_company(project, current_user.company_id)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(TimeEntry)
|
||||||
|
.join(TimeEntry.user)
|
||||||
|
.where(
|
||||||
|
TimeEntry.project_id == project_id,
|
||||||
|
User.company_id == current_user.company_id,
|
||||||
|
TimeEntry.status == EntryStatus.APPROVED,
|
||||||
|
TimeEntry.end_time.is_not(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if date_from:
|
||||||
|
stmt = stmt.where(TimeEntry.date >= date_from)
|
||||||
|
if date_to:
|
||||||
|
stmt = stmt.where(TimeEntry.date <= date_to)
|
||||||
|
|
||||||
|
entries = list((await db.scalars(stmt)).all())
|
||||||
|
total_minutes = sum(e.worked_minutes or 0 for e in entries)
|
||||||
|
total_hours = round(total_minutes / 60, 2)
|
||||||
|
|
||||||
|
budget_used_pct = None
|
||||||
|
if project.budget_hours and float(project.budget_hours) > 0:
|
||||||
|
budget_used_pct = round(total_hours / float(project.budget_hours) * 100, 1)
|
||||||
|
|
||||||
|
return ProjectTimeReport(
|
||||||
|
project_id=project.id,
|
||||||
|
project_name=project.name,
|
||||||
|
project_color=project.color,
|
||||||
|
total_hours=total_hours,
|
||||||
|
entry_count=len(entries),
|
||||||
|
budget_hours=float(project.budget_hours) if project.budget_hours else None,
|
||||||
|
budget_used_pct=budget_used_pct,
|
||||||
|
)
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
from datetime import date, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
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 User, UserRole
|
||||||
|
from app.schemas.report import (
|
||||||
|
AbsenceReport,
|
||||||
|
CompanyDashboard,
|
||||||
|
EmployeeDashboard,
|
||||||
|
OvertimeReport,
|
||||||
|
OvertimeReportDetailed,
|
||||||
|
TeamDashboard,
|
||||||
|
TimeReport,
|
||||||
|
)
|
||||||
|
from app.services.report_service import report_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["Dashboard & Reports"])
|
||||||
|
|
||||||
|
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dashboard ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/dashboard/me", response_model=EmployeeDashboard)
|
||||||
|
async def my_dashboard(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Mitarbeiter-Dashboard: eigene Stunden, Urlaub, Status heute."""
|
||||||
|
return await report_service.employee_dashboard(current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/team", response_model=TeamDashboard)
|
||||||
|
async def team_dashboard(
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Team-Dashboard: Anwesenheit, ausstehende Genehmigungen."""
|
||||||
|
return await report_service.team_dashboard(current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/company", response_model=CompanyDashboard)
|
||||||
|
async def company_dashboard(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Unternehmens-Dashboard: Gesamtübersicht, Überstunden, kommende Abwesenheiten."""
|
||||||
|
return await report_service.company_dashboard(current_user, db)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Reports ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _default_date_from() -> date:
|
||||||
|
today = date.today()
|
||||||
|
return today.replace(day=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_date_to() -> date:
|
||||||
|
return date.today()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/time", response_model=TimeReport)
|
||||||
|
async def time_report(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
date_from: date = Query(default_factory=_default_date_from),
|
||||||
|
date_to: date = Query(default_factory=_default_date_to),
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Zeiterfassungsbericht (JSON). EMPLOYEE sieht nur eigene Einträge."""
|
||||||
|
return await report_service.time_report(
|
||||||
|
current_user.company_id, current_user, db, date_from, date_to, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/absences", response_model=AbsenceReport)
|
||||||
|
async def absence_report(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
date_from: date = Query(default_factory=_default_date_from),
|
||||||
|
date_to: date = Query(default_factory=_default_date_to),
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Abwesenheitsbericht (JSON). EMPLOYEE sieht nur eigene Abwesenheiten."""
|
||||||
|
return await report_service.absence_report(
|
||||||
|
current_user.company_id, current_user, db, date_from, date_to, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/overtime", response_model=OvertimeReport)
|
||||||
|
async def overtime_report(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
date_from: date = Query(default_factory=_default_date_from),
|
||||||
|
date_to: date = Query(default_factory=_default_date_to),
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Überstundenbericht (JSON). EMPLOYEE sieht nur eigene Daten."""
|
||||||
|
return await report_service.overtime_report(
|
||||||
|
current_user.company_id, current_user, db, date_from, date_to, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/overtime/detail", response_model=OvertimeReportDetailed)
|
||||||
|
async def overtime_report_detail(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
date_from: date = Query(default_factory=_default_date_from),
|
||||||
|
date_to: date = Query(default_factory=_default_date_to),
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Erweiterter Überstundenbericht mit Wochen- und Tagesaufschlüsselung."""
|
||||||
|
return await report_service.overtime_report_detail(
|
||||||
|
current_user.company_id, current_user, db, date_from, date_to, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Export ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/reports/time/export")
|
||||||
|
async def export_time_report(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
date_from: date = Query(default_factory=_default_date_from),
|
||||||
|
date_to: date = Query(default_factory=_default_date_to),
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Zeiterfassungsbericht als CSV, XLSX oder PDF herunterladen."""
|
||||||
|
report = await report_service.time_report(
|
||||||
|
current_user.company_id, current_user, db, date_from, date_to, user_id
|
||||||
|
)
|
||||||
|
filename = f"zeiterfassung_{date_from}_{date_to}"
|
||||||
|
|
||||||
|
if format == "pdf":
|
||||||
|
content = report_service.time_report_to_pdf(report)
|
||||||
|
return Response(content=content, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
|
||||||
|
if format == "xlsx":
|
||||||
|
content = report_service.to_xlsx(report_service._time_rows_to_dicts(report.rows), sheet_name="Zeiterfassung")
|
||||||
|
return Response(content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
|
||||||
|
content = report_service.to_csv(report_service._time_rows_to_dicts(report.rows))
|
||||||
|
return Response(content=content, media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/absences/export")
|
||||||
|
async def export_absence_report(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
date_from: date = Query(default_factory=_default_date_from),
|
||||||
|
date_to: date = Query(default_factory=_default_date_to),
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Abwesenheitsbericht als CSV, XLSX oder PDF herunterladen."""
|
||||||
|
report = await report_service.absence_report(
|
||||||
|
current_user.company_id, current_user, db, date_from, date_to, user_id
|
||||||
|
)
|
||||||
|
filename = f"abwesenheiten_{date_from}_{date_to}"
|
||||||
|
|
||||||
|
if format == "pdf":
|
||||||
|
content = report_service.absence_report_to_pdf(report)
|
||||||
|
return Response(content=content, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
|
||||||
|
if format == "xlsx":
|
||||||
|
content = report_service.to_xlsx(report_service._absence_rows_to_dicts(report.rows), sheet_name="Abwesenheiten")
|
||||||
|
return Response(content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
|
||||||
|
content = report_service.to_csv(report_service._absence_rows_to_dicts(report.rows))
|
||||||
|
return Response(content=content, media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/overtime/export")
|
||||||
|
async def export_overtime_report(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
date_from: date = Query(default_factory=_default_date_from),
|
||||||
|
date_to: date = Query(default_factory=_default_date_to),
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
format: str = Query("csv", pattern="^(csv|xlsx|pdf)$"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Überstundenbericht als CSV, XLSX oder PDF herunterladen (Detailansicht)."""
|
||||||
|
detail = await report_service.overtime_report_detail(
|
||||||
|
current_user.company_id, current_user, db, date_from, date_to, user_id
|
||||||
|
)
|
||||||
|
filename = f"ueberstunden_{date_from}_{date_to}"
|
||||||
|
|
||||||
|
if format == "pdf":
|
||||||
|
content = report_service.overtime_detail_to_pdf(detail)
|
||||||
|
return Response(content=content, media_type="application/pdf",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}.pdf"})
|
||||||
|
if format == "xlsx":
|
||||||
|
content = report_service.to_xlsx(report_service._overtime_detail_to_dicts(detail), sheet_name="Überstunden")
|
||||||
|
return Response(content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}.xlsx"})
|
||||||
|
content = report_service.to_csv(report_service._overtime_detail_to_dicts(detail))
|
||||||
|
return Response(content=content, media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}.csv"})
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
SMTP-Konfiguration pro Firma.
|
||||||
|
Nur COMPANY_ADMIN / SUPER_ADMIN darf lesen und schreiben.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import require_role
|
||||||
|
from app.models.smtp_config import SmtpConfig
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.smtp import SmtpConfigOut, SmtpConfigSave, SmtpTestRequest
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/smtp", tags=["SMTP-Konfiguration"])
|
||||||
|
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
def _encrypt(plain: str) -> str:
|
||||||
|
from app.core.config import settings
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
key = hashlib.sha256(settings.secret_key.encode()).digest()
|
||||||
|
f = Fernet(base64.urlsafe_b64encode(key))
|
||||||
|
return f.encrypt(plain.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_config(company_id: uuid.UUID, db: AsyncSession) -> SmtpConfig | None:
|
||||||
|
return await db.scalar(select(SmtpConfig).where(SmtpConfig.company_id == company_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_model=SmtpConfigOut | None)
|
||||||
|
async def get_smtp_config(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cfg = await _get_config(current_user.company_id, db)
|
||||||
|
if not cfg:
|
||||||
|
return None
|
||||||
|
return SmtpConfigOut.model_validate(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config", response_model=SmtpConfigOut)
|
||||||
|
async def save_smtp_config(
|
||||||
|
data: SmtpConfigSave,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Erstellt oder überschreibt die SMTP-Konfiguration der Firma."""
|
||||||
|
cfg = await _get_config(current_user.company_id, db)
|
||||||
|
|
||||||
|
if cfg is None:
|
||||||
|
cfg = SmtpConfig(company_id=current_user.company_id, id=uuid.uuid4())
|
||||||
|
db.add(cfg)
|
||||||
|
|
||||||
|
cfg.host = data.host
|
||||||
|
cfg.port = data.port
|
||||||
|
cfg.use_tls = data.use_tls
|
||||||
|
cfg.use_starttls = data.use_starttls
|
||||||
|
cfg.username = data.username
|
||||||
|
cfg.from_email = data.from_email
|
||||||
|
cfg.from_name = data.from_name
|
||||||
|
cfg.is_enabled = data.is_enabled
|
||||||
|
|
||||||
|
if data.password is not None:
|
||||||
|
cfg.password_encrypted = _encrypt(data.password)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(cfg)
|
||||||
|
return SmtpConfigOut.model_validate(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/test")
|
||||||
|
async def test_smtp(
|
||||||
|
data: SmtpTestRequest,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Sendet eine Test-E-Mail mit der aktuellen Konfiguration."""
|
||||||
|
cfg = await _get_config(current_user.company_id, db)
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="Keine SMTP-Konfiguration vorhanden.")
|
||||||
|
try:
|
||||||
|
await email_service.send_test(cfg, data.to)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"SMTP-Fehler: {exc}")
|
||||||
|
return {"message": f"Test-E-Mail an {data.to} verschickt."}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
from datetime import date
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.dependencies import CurrentUser, require_role
|
||||||
|
from app.models.time_entry import EntryStatus
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.schemas.time_entry import (
|
||||||
|
BalanceResponse,
|
||||||
|
ManualEntryCreate,
|
||||||
|
RejectRequest,
|
||||||
|
StampInRequest,
|
||||||
|
StampOutRequest,
|
||||||
|
TimeEntryListResponse,
|
||||||
|
TimeEntryOut,
|
||||||
|
TimeEntryUpdate,
|
||||||
|
TimeEntryWithWarnings,
|
||||||
|
WorkScheduleCreate,
|
||||||
|
WorkScheduleOut,
|
||||||
|
)
|
||||||
|
from app.services.time_service import time_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/time", tags=["Zeiterfassung"])
|
||||||
|
|
||||||
|
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Stempeluhr ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/stamp-in", response_model=TimeEntryWithWarnings, status_code=201)
|
||||||
|
async def stamp_in(
|
||||||
|
data: StampInRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Einstempeln – startet einen neuen Zeiterfassungseintrag."""
|
||||||
|
entry, warnings = await time_service.stamp_in(current_user, data, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entry)
|
||||||
|
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/stamp-out", response_model=TimeEntryWithWarnings)
|
||||||
|
async def stamp_out(
|
||||||
|
data: StampOutRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Ausstempeln – schließt den offenen Zeiterfassungseintrag."""
|
||||||
|
entry, warnings = await time_service.stamp_out(current_user, data.note, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entry)
|
||||||
|
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/break-start", response_model=TimeEntryOut)
|
||||||
|
async def break_start(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Pause beginnen."""
|
||||||
|
entry = await time_service.break_start(current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entry)
|
||||||
|
return TimeEntryOut.model_validate(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/break-end", response_model=TimeEntryOut)
|
||||||
|
async def break_end(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Pause beenden."""
|
||||||
|
entry = await time_service.break_end(current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entry)
|
||||||
|
return TimeEntryOut.model_validate(entry)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Heute ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/today", response_model=list[TimeEntryOut])
|
||||||
|
async def get_today(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Alle Einträge des heutigen Tages für den aktuellen Benutzer."""
|
||||||
|
entries = await time_service.get_today(current_user, db)
|
||||||
|
return [TimeEntryOut.model_validate(e) for e in entries]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Einträge ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/entries", response_model=TimeEntryListResponse)
|
||||||
|
async def list_entries(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
user_id: UUID | None = Query(None),
|
||||||
|
date_from: date | None = Query(None),
|
||||||
|
date_to: date | None = Query(None),
|
||||||
|
status: EntryStatus | None = Query(None),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
total, entries = await time_service.list_entries(
|
||||||
|
current_user.company_id, current_user, db,
|
||||||
|
user_id=user_id, date_from=date_from, date_to=date_to,
|
||||||
|
status=status, skip=skip, limit=limit,
|
||||||
|
)
|
||||||
|
return TimeEntryListResponse(total=total, items=[TimeEntryOut.model_validate(e) for e in entries])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entries", response_model=TimeEntryWithWarnings, status_code=201)
|
||||||
|
async def create_manual_entry(
|
||||||
|
data: ManualEntryCreate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Manuellen Zeiterfassungseintrag anlegen."""
|
||||||
|
entry, warnings = await time_service.create_manual(data, current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entry)
|
||||||
|
return TimeEntryWithWarnings(entry=TimeEntryOut.model_validate(entry), warnings=warnings)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/entries/{entry_id}", response_model=TimeEntryOut)
|
||||||
|
async def update_entry(
|
||||||
|
entry_id: UUID,
|
||||||
|
data: TimeEntryUpdate,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Zeiterfassungseintrag korrigieren."""
|
||||||
|
entry = await time_service.update_entry(entry_id, data, current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entry)
|
||||||
|
return TimeEntryOut.model_validate(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entries/{entry_id}/approve", response_model=TimeEntryOut)
|
||||||
|
async def approve_entry(
|
||||||
|
entry_id: UUID,
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Zeiterfassungseintrag genehmigen."""
|
||||||
|
entry = await time_service.approve_entry(entry_id, current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entry)
|
||||||
|
return TimeEntryOut.model_validate(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/entries/{entry_id}/reject", response_model=TimeEntryOut)
|
||||||
|
async def reject_entry(
|
||||||
|
entry_id: UUID,
|
||||||
|
data: RejectRequest,
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Zeiterfassungseintrag ablehnen."""
|
||||||
|
entry = await time_service.reject_entry(entry_id, current_user, data.rejection_note, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(entry)
|
||||||
|
return TimeEntryOut.model_validate(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/entries/{entry_id}", status_code=204)
|
||||||
|
async def delete_entry(
|
||||||
|
entry_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Zeiteintrag löschen. Mitarbeiter: nur eigene offene/ausstehende Einträge. Manager: alle außer genehmigten (außer HR/Admin)."""
|
||||||
|
await time_service.delete_entry(entry_id, current_user, db)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Überstundenkonto ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/balance/me", response_model=BalanceResponse)
|
||||||
|
async def get_own_balance(
|
||||||
|
current_user: CurrentUser,
|
||||||
|
period_start: date | None = Query(None),
|
||||||
|
period_end: date | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Eigenes Überstundenkonto."""
|
||||||
|
return await time_service.get_balance(current_user.id, current_user, db, period_start, period_end)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balance/{user_id}", response_model=BalanceResponse)
|
||||||
|
async def get_balance(
|
||||||
|
user_id: UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
period_start: date | None = Query(None),
|
||||||
|
period_end: date | None = Query(None),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Überstundenkonto für einen Benutzer."""
|
||||||
|
if user_id != current_user.id:
|
||||||
|
if current_user.role == UserRole.EMPLOYEE:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
target_user = await db.get(User, user_id)
|
||||||
|
if target_user is None or target_user.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
|
||||||
|
return await time_service.get_balance(user_id, current_user, db, period_start, period_end)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Arbeitspläne ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/schedules", response_model=list[WorkScheduleOut])
|
||||||
|
async def list_schedules(
|
||||||
|
current_user: User = require_role(*_manager_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
schedules = await time_service.list_work_schedules(current_user.company_id, db)
|
||||||
|
return [WorkScheduleOut.model_validate(s) for s in schedules]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/schedules", response_model=WorkScheduleOut, status_code=201)
|
||||||
|
async def create_schedule(
|
||||||
|
data: WorkScheduleCreate,
|
||||||
|
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
schedule = await time_service.create_work_schedule(current_user.company_id, data, db)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(schedule)
|
||||||
|
return WorkScheduleOut.model_validate(schedule)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/schedules/{schedule_id}", response_model=WorkScheduleOut)
|
||||||
|
async def update_schedule(
|
||||||
|
schedule_id: UUID,
|
||||||
|
data: WorkScheduleCreate,
|
||||||
|
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
from app.models.work_schedule import WorkSchedule
|
||||||
|
schedule = await db.scalar(
|
||||||
|
sa_select(WorkSchedule).where(
|
||||||
|
WorkSchedule.id == schedule_id,
|
||||||
|
WorkSchedule.company_id == current_user.company_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not schedule:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
|
||||||
|
for field, value in data.model_dump().items():
|
||||||
|
setattr(schedule, field, value)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(schedule)
|
||||||
|
return WorkScheduleOut.model_validate(schedule)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/schedules/{schedule_id}", status_code=204)
|
||||||
|
async def delete_schedule(
|
||||||
|
schedule_id: UUID,
|
||||||
|
current_user: User = require_role(UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
from app.models.work_schedule import WorkSchedule
|
||||||
|
schedule = await db.scalar(
|
||||||
|
sa_select(WorkSchedule).where(
|
||||||
|
WorkSchedule.id == schedule_id,
|
||||||
|
WorkSchedule.company_id == current_user.company_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not schedule:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=404, detail="Arbeitsplan nicht gefunden")
|
||||||
|
await db.delete(schedule)
|
||||||
|
await db.commit()
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Query, UploadFile
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
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 User, UserRole
|
||||||
|
from app.schemas.auth import MessageResponse
|
||||||
|
from app.schemas.user import (
|
||||||
|
InviteRequest,
|
||||||
|
NextPersonnelNumberResponse,
|
||||||
|
SetKioskPinRequest,
|
||||||
|
UserImportResult,
|
||||||
|
UserImportRowResult,
|
||||||
|
UserListResponse,
|
||||||
|
UserOut,
|
||||||
|
UserUpdate,
|
||||||
|
)
|
||||||
|
from app.services import user_import_service
|
||||||
|
from app.services.user_service import user_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["Users"])
|
||||||
|
|
||||||
|
_admin_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
_hr_roles = (UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN, UserRole.HR, UserRole.MANAGER)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=UserListResponse)
|
||||||
|
async def list_users(
|
||||||
|
current_user: User = require_role(*_hr_roles),
|
||||||
|
skip: int = Query(0, ge=0),
|
||||||
|
limit: int = Query(50, ge=1, le=500),
|
||||||
|
active_only: bool = Query(True),
|
||||||
|
search: str | None = Query(None, max_length=100),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
total, users = await user_service.list_users(
|
||||||
|
current_user.company_id, db, skip, limit, active_only, search,
|
||||||
|
)
|
||||||
|
return UserListResponse(total=total, items=[UserOut.model_validate(u) for u in users])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/invite", response_model=UserOut, status_code=201)
|
||||||
|
async def invite_user(
|
||||||
|
data: InviteRequest,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = await user_service.invite(data, current_user.company_id, current_user, db)
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserOut)
|
||||||
|
async def get_me(current_user: CurrentUser):
|
||||||
|
return UserOut.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/next-personnel-number", response_model=NextPersonnelNumberResponse)
|
||||||
|
async def next_personnel_number(
|
||||||
|
current_user: User = require_role(*_hr_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Schlägt die nächste freie Personalnummer vor (ohne den Counter zu erhöhen)."""
|
||||||
|
suggestion = await user_service.next_personnel_suggestion(current_user.company_id, db)
|
||||||
|
return NextPersonnelNumberResponse(next=suggestion)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/by-personnel/{number}", response_model=UserOut)
|
||||||
|
async def get_user_by_personnel(
|
||||||
|
number: str,
|
||||||
|
current_user: User = require_role(*_hr_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = await user_service.get_by_personnel_number(number, current_user.company_id, db)
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/import-template.csv", response_class=PlainTextResponse)
|
||||||
|
async def import_template(
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
):
|
||||||
|
csv_text = user_import_service.build_template_csv()
|
||||||
|
return PlainTextResponse(
|
||||||
|
content=csv_text,
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="user-import-template.csv"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/preview", response_model=UserImportResult)
|
||||||
|
async def user_import_preview(
|
||||||
|
file: Annotated[UploadFile, File()],
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
content = await file.read()
|
||||||
|
result = await user_import_service.preview_csv(content, current_user.company_id, current_user, db)
|
||||||
|
return _to_import_result_schema(result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/apply", response_model=UserImportResult)
|
||||||
|
async def user_import_apply(
|
||||||
|
file: Annotated[UploadFile, File()],
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
content = await file.read()
|
||||||
|
result = await user_import_service.apply_csv(content, current_user.company_id, current_user, db)
|
||||||
|
return _to_import_result_schema(result)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_import_result_schema(result) -> UserImportResult:
|
||||||
|
return UserImportResult(
|
||||||
|
total_rows=result.total_rows,
|
||||||
|
created=result.created,
|
||||||
|
reactivated=result.reactivated,
|
||||||
|
errors=result.errors,
|
||||||
|
items=[
|
||||||
|
UserImportRowResult(
|
||||||
|
row=i.row, email=i.email, personnel_number=i.personnel_number,
|
||||||
|
action=i.action, message=i.message,
|
||||||
|
)
|
||||||
|
for i in result.items
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{user_id}", response_model=UserOut)
|
||||||
|
async def get_user(
|
||||||
|
user_id: UUID,
|
||||||
|
current_user: User = require_role(*_hr_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = await user_service.get_by_id(user_id, current_user.company_id, db)
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}", response_model=UserOut)
|
||||||
|
async def update_user(
|
||||||
|
user_id: UUID,
|
||||||
|
data: UserUpdate,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = await user_service.update(user_id, data, current_user, db)
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/deactivate", response_model=UserOut)
|
||||||
|
async def deactivate_user(
|
||||||
|
user_id: UUID,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = await user_service.deactivate(user_id, current_user, db)
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/reactivate", response_model=UserOut)
|
||||||
|
async def reactivate_user(
|
||||||
|
user_id: UUID,
|
||||||
|
current_user: User = require_role(*_admin_roles),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
user = await user_service.reactivate(user_id, current_user, db)
|
||||||
|
return UserOut.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{user_id}/kiosk-pin", response_model=MessageResponse)
|
||||||
|
async def set_kiosk_pin(
|
||||||
|
user_id: UUID,
|
||||||
|
data: SetKioskPinRequest,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
# Users can set their own PIN; admins can set for any user in company
|
||||||
|
if user_id != current_user.id and not current_user.is_admin_or_above():
|
||||||
|
from fastapi import HTTPException
|
||||||
|
raise HTTPException(status_code=403, detail="Not allowed")
|
||||||
|
user = await user_service.get_by_id(user_id, current_user.company_id, db)
|
||||||
|
await user_service.set_kiosk_pin(user, data.pin, db)
|
||||||
|
return MessageResponse(message="Kiosk PIN updated")
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.absence import AbsenceStatus
|
||||||
|
from app.models.absence_type import AbsenceCategory
|
||||||
|
|
||||||
|
|
||||||
|
# ── AbsenceType ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AbsenceTypeOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
color: str
|
||||||
|
category: AbsenceCategory
|
||||||
|
requires_approval: bool
|
||||||
|
deducts_vacation: bool
|
||||||
|
affects_overtime_balance: bool
|
||||||
|
requires_certificate: bool
|
||||||
|
certificate_after_days: int
|
||||||
|
is_paid: bool
|
||||||
|
max_days_per_year: int | None
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceTypeCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
category: AbsenceCategory = AbsenceCategory.OTHER
|
||||||
|
requires_approval: bool = True
|
||||||
|
deducts_vacation: bool = False
|
||||||
|
affects_overtime_balance: bool = False
|
||||||
|
requires_certificate: bool = False
|
||||||
|
certificate_after_days: int = Field(3, ge=0, le=365)
|
||||||
|
is_paid: bool = True
|
||||||
|
max_days_per_year: int | None = Field(None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceTypeUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
category: AbsenceCategory | None = None
|
||||||
|
requires_approval: bool | None = None
|
||||||
|
deducts_vacation: bool | None = None
|
||||||
|
affects_overtime_balance: bool | None = None
|
||||||
|
requires_certificate: bool | None = None
|
||||||
|
certificate_after_days: int | None = Field(None, ge=0, le=365)
|
||||||
|
is_paid: bool | None = None
|
||||||
|
max_days_per_year: int | None = Field(None, ge=1)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Absence ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AbsenceOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
type_id: uuid.UUID
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
half_day_start: bool
|
||||||
|
half_day_end: bool
|
||||||
|
working_days: float
|
||||||
|
status: AbsenceStatus
|
||||||
|
approved_by: uuid.UUID | None
|
||||||
|
substitute_id: uuid.UUID | None
|
||||||
|
note: str | None
|
||||||
|
correction_note: str | None
|
||||||
|
rejection_reason: str | None
|
||||||
|
certificate_required_by: date | None = None
|
||||||
|
certificate_received_at: date | None = None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceCreate(BaseModel):
|
||||||
|
type_id: uuid.UUID
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
half_day_start: bool = False
|
||||||
|
half_day_end: bool = False
|
||||||
|
substitute_id: uuid.UUID | None = None
|
||||||
|
note: str | None = None
|
||||||
|
for_user_id: uuid.UUID | None = None # HR/Admin: Abwesenheit für anderen Mitarbeiter anlegen
|
||||||
|
|
||||||
|
def model_post_init(self, __context) -> None:
|
||||||
|
if self.end_date < self.start_date:
|
||||||
|
raise ValueError("end_date must be >= start_date")
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceUpdate(BaseModel):
|
||||||
|
type_id: uuid.UUID | None = None
|
||||||
|
start_date: date | None = None
|
||||||
|
end_date: date | None = None
|
||||||
|
half_day_start: bool | None = None
|
||||||
|
half_day_end: bool | None = None
|
||||||
|
substitute_id: uuid.UUID | None = None
|
||||||
|
note: str | None = None
|
||||||
|
correction_note: str | None = None # Pflicht bei Änderung genehmigter Anträge (Mitarbeiter)
|
||||||
|
|
||||||
|
def model_post_init(self, __context) -> None:
|
||||||
|
if self.start_date and self.end_date and self.end_date < self.start_date:
|
||||||
|
raise ValueError("end_date must be >= start_date")
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceReject(BaseModel):
|
||||||
|
rejection_reason: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[AbsenceOut]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Krankmeldung ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class QuickSickIn(BaseModel):
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
|
||||||
|
def model_post_init(self, __context) -> None:
|
||||||
|
if self.end_date < self.start_date:
|
||||||
|
raise ValueError("end_date must be >= start_date")
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateMarkIn(BaseModel):
|
||||||
|
received_at: date | None = None # default = heute
|
||||||
|
|
||||||
|
|
||||||
|
class SickStatsOut(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
user_name: str
|
||||||
|
personnel_number: str | None = None
|
||||||
|
episodes: int
|
||||||
|
total_days: float
|
||||||
|
bradford_factor: float
|
||||||
|
certificates_overdue: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── VacationBalance ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class VacationBalanceOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
year: int
|
||||||
|
entitled_days: int
|
||||||
|
special_days: int = 0
|
||||||
|
carried_over: int
|
||||||
|
used_days: int
|
||||||
|
total_days: int
|
||||||
|
remaining_days: int
|
||||||
|
pending_days: float = 0
|
||||||
|
# Resturlaub-Verfall (wird zur Laufzeit befüllt, nicht in DB)
|
||||||
|
carried_over_expires_at: date | None = None
|
||||||
|
carried_over_expired: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class VacationBalanceUpdate(BaseModel):
|
||||||
|
entitled_days: int | None = Field(None, ge=0, le=365)
|
||||||
|
special_days: int | None = Field(None, ge=0, le=365)
|
||||||
|
carried_over: int | None = Field(None, ge=0, le=365)
|
||||||
|
|
||||||
|
|
||||||
|
# ── PublicHoliday ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PublicHolidayOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
country: str
|
||||||
|
state: str | None
|
||||||
|
date: date
|
||||||
|
name: str
|
||||||
|
year: int
|
||||||
|
|
||||||
|
|
||||||
|
class PublicHolidayCreate(BaseModel):
|
||||||
|
country: str = Field("DE", min_length=2, max_length=10)
|
||||||
|
state: str | None = Field(None, max_length=10)
|
||||||
|
date: date
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
|
# ── OvertimeBalance ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class OvertimeBalanceOut(BaseModel):
|
||||||
|
total_hours: float
|
||||||
|
taken_hours: float
|
||||||
|
available_hours: float
|
||||||
|
|
||||||
|
|
||||||
|
# ── Calendar ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CalendarEntry(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
user_name: str
|
||||||
|
absence_id: uuid.UUID
|
||||||
|
type_name: str
|
||||||
|
type_color: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
status: AbsenceStatus
|
||||||
|
working_days: float
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogEntry(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
user_id: UUID | None
|
||||||
|
user_name: str | None
|
||||||
|
action: str
|
||||||
|
entity_type: str | None
|
||||||
|
entity_id: UUID | None
|
||||||
|
old_value: dict | None
|
||||||
|
new_value: dict | None
|
||||||
|
ip_address: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLogListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[AuditLogEntry]
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
from pydantic import BaseModel, EmailStr, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterRequest(BaseModel):
|
||||||
|
company_name: str = Field(min_length=2, max_length=255)
|
||||||
|
first_name: str = Field(min_length=1, max_length=100)
|
||||||
|
last_name: str = Field(min_length=1, max_length=100)
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def password_strength(self):
|
||||||
|
pw = self.password
|
||||||
|
if not any(c.isupper() for c in pw):
|
||||||
|
raise ValueError("Password must contain at least one uppercase letter")
|
||||||
|
if not any(c.isdigit() for c in pw):
|
||||||
|
raise ValueError("Password must contain at least one digit")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordResetConfirm(BaseModel):
|
||||||
|
token: str
|
||||||
|
new_password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def password_strength(self):
|
||||||
|
pw = self.new_password
|
||||||
|
if not any(c.isupper() for c in pw):
|
||||||
|
raise ValueError("Password must contain at least one uppercase letter")
|
||||||
|
if not any(c.isdigit() for c in pw):
|
||||||
|
raise ValueError("Password must contain at least one digit")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
totp_required: bool = False
|
||||||
|
partial_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TotpSetupResponse(BaseModel):
|
||||||
|
secret: str # base32 secret for manual entry
|
||||||
|
otpauth_uri: str # otpauth://totp/... für QR-Code
|
||||||
|
|
||||||
|
|
||||||
|
class TotpConfirmRequest(BaseModel):
|
||||||
|
code: str = Field(min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class TotpLoginRequest(BaseModel):
|
||||||
|
partial_token: str
|
||||||
|
code: str = Field(min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class TotpDisableRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
code: str = Field(min_length=6, max_length=6)
|
||||||
|
|
||||||
|
|
||||||
|
class AccessTokenResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageResponse(BaseModel):
|
||||||
|
message: str
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class BusylightTokenStatus(BaseModel):
|
||||||
|
configured: bool
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BusylightTokenRotated(BaseModel):
|
||||||
|
token: str = Field(..., description="Klartext-Token, wird nur einmal angezeigt.")
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BusylightAbsenceItem(BaseModel):
|
||||||
|
type: str
|
||||||
|
category: str
|
||||||
|
|
||||||
|
|
||||||
|
class BusylightUserItem(BaseModel):
|
||||||
|
personnel_number: str
|
||||||
|
full_name: str
|
||||||
|
absences_today: list[BusylightAbsenceItem]
|
||||||
|
|
||||||
|
|
||||||
|
class BusylightUsersResponse(BaseModel):
|
||||||
|
date: date
|
||||||
|
users: list[BusylightUserItem]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class CaldavCompanyConfigOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID
|
||||||
|
enabled: bool
|
||||||
|
principal_url: str
|
||||||
|
calendar_url: str | None
|
||||||
|
username: str
|
||||||
|
calendar_display_name: str
|
||||||
|
verify_ssl: bool
|
||||||
|
name_template: str
|
||||||
|
last_error: str | None
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CaldavCompanyConfigSave(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
principal_url: str = Field(min_length=1)
|
||||||
|
calendar_url: str | None = None
|
||||||
|
username: str = Field(min_length=1, max_length=255)
|
||||||
|
password: str | None = None # leer = unverändert
|
||||||
|
calendar_display_name: str = ""
|
||||||
|
verify_ssl: bool = True
|
||||||
|
name_template: str = "$vorname $nachname – $typ"
|
||||||
|
|
||||||
|
|
||||||
|
class CaldavUserConfigOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
enabled: bool
|
||||||
|
principal_url: str
|
||||||
|
calendar_url: str | None
|
||||||
|
username: str
|
||||||
|
calendar_display_name: str
|
||||||
|
verify_ssl: bool
|
||||||
|
last_error: str | None
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CaldavUserConfigSave(BaseModel):
|
||||||
|
enabled: bool = False
|
||||||
|
principal_url: str = Field(min_length=1)
|
||||||
|
calendar_url: str | None = None
|
||||||
|
username: str = Field(min_length=1, max_length=255)
|
||||||
|
password: str | None = None
|
||||||
|
calendar_display_name: str = ""
|
||||||
|
verify_ssl: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class ResyncResult(BaseModel):
|
||||||
|
synced: int
|
||||||
|
failed: int
|
||||||
|
total: int
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
PersonnelNumberModeT = Literal["manual", "auto"]
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
slug: str
|
||||||
|
plan: str
|
||||||
|
logo_url: str | None
|
||||||
|
country: str
|
||||||
|
state: str | None
|
||||||
|
settings: dict
|
||||||
|
personnel_number_required: bool = False
|
||||||
|
personnel_number_mode: PersonnelNumberModeT = "manual"
|
||||||
|
personnel_number_next: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=2, max_length=255)
|
||||||
|
state: str | None = Field(None, max_length=10)
|
||||||
|
settings: dict | None = None
|
||||||
|
personnel_number_required: bool | None = None
|
||||||
|
personnel_number_mode: PersonnelNumberModeT | None = None
|
||||||
|
personnel_number_next: int | None = Field(None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
manager_id: uuid.UUID | None
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
manager_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
manager_id: uuid.UUID | None = None
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
class KioskDeviceCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
location: str | None = Field(None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def name_not_blank(cls, v: str) -> str:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
|
||||||
|
return v.strip()
|
||||||
|
|
||||||
|
|
||||||
|
class KioskDeviceUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=255)
|
||||||
|
location: str | None = Field(None, max_length=255)
|
||||||
|
|
||||||
|
@field_validator("name")
|
||||||
|
@classmethod
|
||||||
|
def name_not_blank(cls, v: str | None) -> str | None:
|
||||||
|
if v is not None:
|
||||||
|
if not v.strip():
|
||||||
|
raise ValueError("Name darf nicht nur aus Leerzeichen bestehen.")
|
||||||
|
return v.strip()
|
||||||
|
return v
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class KioskDeviceOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
location: str | None
|
||||||
|
is_active: bool
|
||||||
|
last_seen_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class KioskDeviceCreated(KioskDeviceOut):
|
||||||
|
"""Wird nur einmalig bei Erstellung zurückgegeben – enthält den Klartext-Token."""
|
||||||
|
token: str
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.user import UserRole
|
||||||
|
|
||||||
|
|
||||||
|
class LdapConfigCreate(BaseModel):
|
||||||
|
host: str = Field(min_length=1, max_length=255)
|
||||||
|
port: int = Field(default=389, ge=1, le=65535)
|
||||||
|
use_ssl: bool = False
|
||||||
|
use_tls: bool = False
|
||||||
|
tls_verify: bool = False
|
||||||
|
bind_dn: str = Field(min_length=1)
|
||||||
|
bind_password: str = Field(min_length=1)
|
||||||
|
base_dn: str = Field(min_length=1)
|
||||||
|
user_search_filter: str = Field(default="(objectClass=person)", min_length=1, max_length=512)
|
||||||
|
attr_email: str = Field(default="mail", min_length=1, max_length=100)
|
||||||
|
attr_firstname: str = Field(default="givenName", min_length=1, max_length=100)
|
||||||
|
attr_lastname: str = Field(default="sn", min_length=1, max_length=100)
|
||||||
|
attr_username: str = Field(default="sAMAccountName", min_length=1, max_length=100)
|
||||||
|
attr_department: str | None = Field(default=None, max_length=100)
|
||||||
|
attr_personnel_number: str | None = Field(default=None, max_length=100)
|
||||||
|
enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class LdapConfigUpdate(BaseModel):
|
||||||
|
host: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
port: int | None = Field(default=None, ge=1, le=65535)
|
||||||
|
use_ssl: bool | None = None
|
||||||
|
use_tls: bool | None = None
|
||||||
|
tls_verify: bool | None = None
|
||||||
|
bind_dn: str | None = None
|
||||||
|
bind_password: str | None = None
|
||||||
|
base_dn: str | None = None
|
||||||
|
user_search_filter: str | None = Field(default=None, max_length=512)
|
||||||
|
attr_email: str | None = Field(default=None, max_length=100)
|
||||||
|
attr_firstname: str | None = Field(default=None, max_length=100)
|
||||||
|
attr_lastname: str | None = Field(default=None, max_length=100)
|
||||||
|
attr_username: str | None = Field(default=None, max_length=100)
|
||||||
|
attr_department: str | None = Field(default=None, max_length=100)
|
||||||
|
attr_personnel_number: str | None = Field(default=None, max_length=100)
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LdapConfigOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID
|
||||||
|
enabled: bool
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
use_ssl: bool
|
||||||
|
use_tls: bool
|
||||||
|
tls_verify: bool
|
||||||
|
bind_dn: str
|
||||||
|
base_dn: str
|
||||||
|
user_search_filter: str
|
||||||
|
attr_email: str
|
||||||
|
attr_firstname: str
|
||||||
|
attr_lastname: str
|
||||||
|
attr_username: str
|
||||||
|
attr_department: str | None
|
||||||
|
attr_personnel_number: str | None = None
|
||||||
|
last_sync_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class LdapTestResult(BaseModel):
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class LdapSyncRequest(BaseModel):
|
||||||
|
default_role: UserRole = UserRole.EMPLOYEE
|
||||||
|
|
||||||
|
|
||||||
|
class LdapSyncResult(BaseModel):
|
||||||
|
created: int
|
||||||
|
updated: int
|
||||||
|
deactivated: int
|
||||||
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class LdapUserPreview(BaseModel):
|
||||||
|
dn: str
|
||||||
|
email: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
department: str | None = None
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
color: str
|
||||||
|
budget_hours: float | None
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
description: str | None = None
|
||||||
|
color: str = Field("#3B82F6", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
budget_hours: float | None = Field(None, ge=0.1, le=99999)
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectUpdate(BaseModel):
|
||||||
|
name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
description: str | None = None
|
||||||
|
color: str | None = Field(None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||||
|
budget_hours: float | None = Field(None, ge=0.1, le=99999)
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[ProjectOut]
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectTimeReport(BaseModel):
|
||||||
|
project_id: uuid.UUID
|
||||||
|
project_name: str
|
||||||
|
project_color: str
|
||||||
|
total_hours: float
|
||||||
|
entry_count: int
|
||||||
|
budget_hours: float | None
|
||||||
|
budget_used_pct: float | None # None wenn kein Budget
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, time
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
# ── Employee Dashboard ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class EmployeeDashboard(BaseModel):
|
||||||
|
today_open: bool
|
||||||
|
today_start: time | None
|
||||||
|
today_hours_so_far: float | None
|
||||||
|
week_hours_worked: float
|
||||||
|
week_hours_expected: float
|
||||||
|
week_overtime: float
|
||||||
|
vacation_remaining_days: int | None
|
||||||
|
vacation_used_days: int
|
||||||
|
vacation_entitled_days: int
|
||||||
|
pending_absences: int
|
||||||
|
overtime_balance_hours: float | None # verfügbares Überstundenguthaben
|
||||||
|
schedule_name: str | None # zugewiesener Arbeitsplan
|
||||||
|
|
||||||
|
|
||||||
|
# ── Team Dashboard ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TeamMemberStatus(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
user_name: str
|
||||||
|
department: str | None
|
||||||
|
status: str # "present" | "on_leave" | "absent"
|
||||||
|
absence_type: str | None
|
||||||
|
time_in: time | None
|
||||||
|
hours_today: float | None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamDashboard(BaseModel):
|
||||||
|
present_count: int
|
||||||
|
on_leave_count: int
|
||||||
|
absent_count: int
|
||||||
|
pending_time_approvals: int
|
||||||
|
pending_absence_approvals: int
|
||||||
|
members: list[TeamMemberStatus]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Company Dashboard ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UpcomingAbsence(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
user_name: str
|
||||||
|
absence_type: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
working_days: float
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyDashboard(BaseModel):
|
||||||
|
total_employees: int
|
||||||
|
active_today: int
|
||||||
|
attendance_rate: float
|
||||||
|
month_hours_worked: float
|
||||||
|
month_hours_expected: float
|
||||||
|
month_overtime: float
|
||||||
|
pending_time_approvals: int
|
||||||
|
pending_absence_approvals: int
|
||||||
|
upcoming_absences: list[UpcomingAbsence]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Time Report ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class HoursBreakdown(BaseModel):
|
||||||
|
"""Stunden-Aufteilung nach §3b EStG für den Steuerberater."""
|
||||||
|
normal_hours: float # Mo–Fr, 06–20 Uhr, kein Feiertag
|
||||||
|
night_25_hours: float # 20–24 + 04–06 Uhr (25% Zuschlag)
|
||||||
|
night_40_hours: float # 00–04 Uhr (40% Zuschlag)
|
||||||
|
sunday_hours: float # Sonntag 00–24 Uhr (50% Zuschlag)
|
||||||
|
holiday_125_hours: float # gesetzl. Feiertag (125% Zuschlag)
|
||||||
|
holiday_150_hours: float # besondere Feiertage 25.12, 26.12, 01.05 etc. (150%)
|
||||||
|
holiday_name: str | None # Name des Feiertags falls zutreffend
|
||||||
|
|
||||||
|
|
||||||
|
class TimeReportRow(BaseModel):
|
||||||
|
date: date
|
||||||
|
user_id: uuid.UUID
|
||||||
|
user_name: str
|
||||||
|
personnel_number: str | None = None
|
||||||
|
department: str | None
|
||||||
|
start_time: time
|
||||||
|
end_time: time | None
|
||||||
|
break_minutes: int
|
||||||
|
worked_hours: float | None
|
||||||
|
status: str
|
||||||
|
source: str
|
||||||
|
note: str | None
|
||||||
|
breakdown: HoursBreakdown | None = None # None wenn kein Bundesland konfiguriert
|
||||||
|
|
||||||
|
|
||||||
|
class TimeReport(BaseModel):
|
||||||
|
date_from: date
|
||||||
|
date_to: date
|
||||||
|
total_rows: int
|
||||||
|
total_hours: float
|
||||||
|
rows: list[TimeReportRow]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Absence Report ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class AbsenceReportRow(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
user_name: str
|
||||||
|
personnel_number: str | None = None
|
||||||
|
department: str | None
|
||||||
|
absence_type: str
|
||||||
|
start_date: date
|
||||||
|
end_date: date
|
||||||
|
working_days: float
|
||||||
|
status: str
|
||||||
|
note: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceReport(BaseModel):
|
||||||
|
date_from: date
|
||||||
|
date_to: date
|
||||||
|
total_rows: int
|
||||||
|
total_days: float
|
||||||
|
rows: list[AbsenceReportRow]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Overtime Report ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class OvertimeReportRow(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
user_name: str
|
||||||
|
personnel_number: str | None = None
|
||||||
|
department: str | None
|
||||||
|
hours_worked: float
|
||||||
|
hours_expected: float
|
||||||
|
overtime_hours: float
|
||||||
|
|
||||||
|
|
||||||
|
class OvertimeReport(BaseModel):
|
||||||
|
date_from: date
|
||||||
|
date_to: date
|
||||||
|
total_employees: int
|
||||||
|
total_overtime: float
|
||||||
|
rows: list[OvertimeReportRow]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Overtime Detail Report (Option A: Inline-Expand) ──────────────────────────
|
||||||
|
|
||||||
|
class DayEntry(BaseModel):
|
||||||
|
"""Einzelner Zeiteintrag innerhalb eines Tages (mehrere möglich)."""
|
||||||
|
start_time: time
|
||||||
|
end_time: time
|
||||||
|
break_minutes: int
|
||||||
|
hours_worked: float
|
||||||
|
status: str
|
||||||
|
arbzg_warnings: list[str] = []
|
||||||
|
breakdown: HoursBreakdown | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OvertimeDay(BaseModel):
|
||||||
|
date: date
|
||||||
|
weekday: str # "Mo", "Di", …
|
||||||
|
hours_worked: float # Summe aller Einträge des Tages
|
||||||
|
hours_expected: float
|
||||||
|
overtime: float
|
||||||
|
entries: list[DayEntry] = [] # leer = kein Eintrag an dem Tag
|
||||||
|
|
||||||
|
|
||||||
|
class OvertimeWeek(BaseModel):
|
||||||
|
week_nr: int
|
||||||
|
week_start: date
|
||||||
|
week_end: date
|
||||||
|
hours_worked: float
|
||||||
|
hours_expected: float
|
||||||
|
overtime: float
|
||||||
|
days: list[OvertimeDay]
|
||||||
|
|
||||||
|
|
||||||
|
class OvertimeReportRowDetailed(OvertimeReportRow):
|
||||||
|
weeks: list[OvertimeWeek] = []
|
||||||
|
arbzg_violation_days: int = 0 # Tage > 10h
|
||||||
|
special_hours_total: HoursBreakdown | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OvertimeReportDetailed(BaseModel):
|
||||||
|
date_from: date
|
||||||
|
date_to: date
|
||||||
|
total_employees: int
|
||||||
|
total_overtime: float
|
||||||
|
rows: list[OvertimeReportRowDetailed]
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import uuid
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpConfigOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
use_tls: bool
|
||||||
|
use_starttls: bool
|
||||||
|
username: str | None
|
||||||
|
from_email: str
|
||||||
|
from_name: str
|
||||||
|
is_enabled: bool
|
||||||
|
# password_encrypted wird nie zurückgegeben
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpConfigSave(BaseModel):
|
||||||
|
host: str = Field(min_length=1, max_length=255)
|
||||||
|
port: int = Field(default=587, ge=1, le=65535)
|
||||||
|
use_tls: bool = False
|
||||||
|
use_starttls: bool = True
|
||||||
|
username: str | None = Field(None, max_length=255)
|
||||||
|
password: str | None = None # Klartext – wird serverseitig verschlüsselt
|
||||||
|
from_email: str = Field(min_length=5, max_length=255)
|
||||||
|
from_name: str = Field(default="TimeMaster", min_length=1, max_length=255)
|
||||||
|
is_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpTestRequest(BaseModel):
|
||||||
|
to: str = Field(min_length=5, max_length=255)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import date, datetime, time
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.models.time_entry import EntrySource, EntryStatus
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
user_id: uuid.UUID
|
||||||
|
date: date
|
||||||
|
start_time: time
|
||||||
|
end_time: time | None
|
||||||
|
break_minutes: int
|
||||||
|
break_start: time | None
|
||||||
|
project_id: uuid.UUID | None
|
||||||
|
note: str | None
|
||||||
|
status: EntryStatus
|
||||||
|
source: EntrySource
|
||||||
|
approved_by: uuid.UUID | None
|
||||||
|
correction_note: str | None
|
||||||
|
worked_hours: float | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryWithWarnings(BaseModel):
|
||||||
|
entry: TimeEntryOut
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class StampInRequest(BaseModel):
|
||||||
|
source: EntrySource = EntrySource.WEB
|
||||||
|
project_id: uuid.UUID | None = None
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StampOutRequest(BaseModel):
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ManualEntryCreate(BaseModel):
|
||||||
|
user_id: uuid.UUID | None = None # MANAGER/HR können für andere setzen
|
||||||
|
date: date
|
||||||
|
start_time: time
|
||||||
|
end_time: time
|
||||||
|
break_minutes: int = Field(0, ge=0, le=600)
|
||||||
|
project_id: uuid.UUID | None = None
|
||||||
|
note: str | None = None
|
||||||
|
source: EntrySource = EntrySource.MANUAL
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryUpdate(BaseModel):
|
||||||
|
start_time: time | None = None
|
||||||
|
end_time: time | None = None
|
||||||
|
break_minutes: int | None = Field(None, ge=0, le=600)
|
||||||
|
project_id: uuid.UUID | None = None
|
||||||
|
note: str | None = None
|
||||||
|
correction_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RejectRequest(BaseModel):
|
||||||
|
rejection_note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[TimeEntryOut]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkScheduleOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
mon_h: Decimal
|
||||||
|
tue_h: Decimal
|
||||||
|
wed_h: Decimal
|
||||||
|
thu_h: Decimal
|
||||||
|
fri_h: Decimal
|
||||||
|
sat_h: Decimal
|
||||||
|
sun_h: Decimal
|
||||||
|
valid_from: date
|
||||||
|
|
||||||
|
|
||||||
|
class WorkScheduleCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
mon_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
|
||||||
|
tue_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
|
||||||
|
wed_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
|
||||||
|
thu_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
|
||||||
|
fri_h: Decimal = Field(Decimal("8.00"), ge=0, le=24)
|
||||||
|
sat_h: Decimal = Field(Decimal("0.00"), ge=0, le=24)
|
||||||
|
sun_h: Decimal = Field(Decimal("0.00"), ge=0, le=24)
|
||||||
|
valid_from: date
|
||||||
|
|
||||||
|
|
||||||
|
class BalanceResponse(BaseModel):
|
||||||
|
user_id: uuid.UUID
|
||||||
|
period_start: date
|
||||||
|
period_end: date
|
||||||
|
total_hours_worked: float
|
||||||
|
expected_hours: float
|
||||||
|
overtime_hours: float
|
||||||
|
approved_entries: int
|
||||||
|
pending_entries: int
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, EmailStr, Field, model_validator
|
||||||
|
|
||||||
|
from app.models.user import AuthProvider, UserRole
|
||||||
|
|
||||||
|
|
||||||
|
PERSONNEL_NUMBER_PATTERN = r"^[0-9]+$"
|
||||||
|
|
||||||
|
|
||||||
|
class UserOut(BaseModel):
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
company_id: uuid.UUID | None
|
||||||
|
department_id: uuid.UUID | None
|
||||||
|
email: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
full_name: str
|
||||||
|
role: UserRole
|
||||||
|
auth_provider: AuthProvider
|
||||||
|
is_active: bool
|
||||||
|
last_login: datetime | None
|
||||||
|
created_at: datetime
|
||||||
|
kuerzel: str | None = None
|
||||||
|
personnel_number: str | None = None
|
||||||
|
can_manual_time_entry: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class UserUpdate(BaseModel):
|
||||||
|
first_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
last_name: str | None = Field(None, min_length=1, max_length=100)
|
||||||
|
department_id: uuid.UUID | None = None
|
||||||
|
role: UserRole | None = None
|
||||||
|
work_schedule_id: uuid.UUID | None = None
|
||||||
|
kuerzel: str | None = Field(None, max_length=20)
|
||||||
|
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
|
||||||
|
can_manual_time_entry: bool | None = None
|
||||||
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InviteRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
first_name: str = Field(min_length=1, max_length=100)
|
||||||
|
last_name: str = Field(min_length=1, max_length=100)
|
||||||
|
role: UserRole = UserRole.EMPLOYEE
|
||||||
|
department_id: uuid.UUID | None = None
|
||||||
|
personnel_number: str | None = Field(None, max_length=50, pattern=PERSONNEL_NUMBER_PATTERN)
|
||||||
|
# Wenn gesetzt → User wird sofort aktiv (kein Invite-E-Mail nötig)
|
||||||
|
initial_password: str | None = Field(None, min_length=8, max_length=128)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def password_strength(self):
|
||||||
|
pw = self.initial_password
|
||||||
|
if pw is None:
|
||||||
|
return self
|
||||||
|
if not any(c.isupper() for c in pw):
|
||||||
|
raise ValueError("initial_password must contain at least one uppercase letter")
|
||||||
|
if not any(c.isdigit() for c in pw):
|
||||||
|
raise ValueError("initial_password must contain at least one digit")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class InviteAccept(BaseModel):
|
||||||
|
token: str
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def password_strength(self):
|
||||||
|
pw = self.password
|
||||||
|
if not any(c.isupper() for c in pw):
|
||||||
|
raise ValueError("Password must contain at least one uppercase letter")
|
||||||
|
if not any(c.isdigit() for c in pw):
|
||||||
|
raise ValueError("Password must contain at least one digit")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
items: list[UserOut]
|
||||||
|
|
||||||
|
|
||||||
|
class SetKioskPinRequest(BaseModel):
|
||||||
|
pin: str = Field(min_length=4, max_length=6, pattern=r"^\d+$")
|
||||||
|
|
||||||
|
|
||||||
|
class NextPersonnelNumberResponse(BaseModel):
|
||||||
|
next: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserImportRowError(BaseModel):
|
||||||
|
row: int
|
||||||
|
email: str | None = None
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class UserImportRowResult(BaseModel):
|
||||||
|
row: int
|
||||||
|
email: str
|
||||||
|
personnel_number: str | None = None
|
||||||
|
action: str # "created" | "reactivated" | "skipped" | "error"
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserImportResult(BaseModel):
|
||||||
|
total_rows: int
|
||||||
|
created: int
|
||||||
|
reactivated: int
|
||||||
|
errors: int
|
||||||
|
items: list[UserImportRowResult]
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
import asyncio
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import and_, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.models.absence import Absence, AbsenceStatus
|
||||||
|
from app.models.absence_type import AbsenceCategory, AbsenceType
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.company import Company
|
||||||
|
from app.models.overtime_balance import OvertimeBalance
|
||||||
|
from app.models.public_holiday import PublicHoliday
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.vacation_balance import VacationBalance
|
||||||
|
from app.models.work_schedule import WorkSchedule
|
||||||
|
from app.schemas.absence import AbsenceCreate, AbsenceReject, AbsenceTypeCreate, AbsenceTypeUpdate
|
||||||
|
|
||||||
|
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
class AbsenceService:
|
||||||
|
|
||||||
|
# ── AbsenceTypes ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_types(self, company_id: UUID, db: AsyncSession) -> list[AbsenceType]:
|
||||||
|
result = await db.scalars(
|
||||||
|
select(AbsenceType)
|
||||||
|
.where(AbsenceType.company_id == company_id, AbsenceType.is_active == True)
|
||||||
|
.order_by(AbsenceType.name)
|
||||||
|
)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
async def create_type(
|
||||||
|
self, company_id: UUID, data: AbsenceTypeCreate, db: AsyncSession
|
||||||
|
) -> AbsenceType:
|
||||||
|
at = AbsenceType(company_id=company_id, **data.model_dump())
|
||||||
|
db.add(at)
|
||||||
|
await db.flush()
|
||||||
|
return at
|
||||||
|
|
||||||
|
async def update_type(
|
||||||
|
self, type_id: UUID, company_id: UUID, data: AbsenceTypeUpdate, db: AsyncSession
|
||||||
|
) -> AbsenceType:
|
||||||
|
at = await self._get_type_or_404(type_id, company_id, db)
|
||||||
|
for field, value in data.model_dump(exclude_none=True).items():
|
||||||
|
setattr(at, field, value)
|
||||||
|
return at
|
||||||
|
|
||||||
|
async def create_defaults_for_company(self, company_id: UUID, db: AsyncSession) -> None:
|
||||||
|
"""Standard-Abwesenheitstypen + Standard-Arbeitsplan für ein neues Unternehmen anlegen."""
|
||||||
|
defaults = [
|
||||||
|
{
|
||||||
|
"name": "Urlaub", "color": "#3B82F6", "category": AbsenceCategory.VACATION,
|
||||||
|
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Krankheit", "color": "#EF4444", "category": AbsenceCategory.SICK,
|
||||||
|
"requires_approval": False, "deducts_vacation": False, "is_paid": True,
|
||||||
|
"requires_certificate": True, "certificate_after_days": 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Freizeitausgleich", "color": "#F59E0B", "category": AbsenceCategory.OVERTIME_COMP,
|
||||||
|
"requires_approval": True, "deducts_vacation": False,
|
||||||
|
"affects_overtime_balance": True, "is_paid": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Weiterbildung", "color": "#8B5CF6", "category": AbsenceCategory.TRAINING,
|
||||||
|
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
|
||||||
|
"max_days_per_year": 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Dienstreise", "color": "#06B6D4", "category": AbsenceCategory.BUSINESS_TRIP,
|
||||||
|
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Homeoffice", "color": "#10B981", "category": AbsenceCategory.OTHER,
|
||||||
|
"requires_approval": True, "deducts_vacation": False, "is_paid": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Sonderurlaub", "color": "#84CC16", "category": AbsenceCategory.VACATION,
|
||||||
|
"requires_approval": True, "deducts_vacation": True, "is_paid": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for d in defaults:
|
||||||
|
db.add(AbsenceType(company_id=company_id, **d))
|
||||||
|
|
||||||
|
# Standard-Arbeitsplan: Mo–Fr 8h
|
||||||
|
schedule = WorkSchedule(
|
||||||
|
company_id=company_id,
|
||||||
|
name="Vollzeit (40h)",
|
||||||
|
valid_from=date.today(),
|
||||||
|
)
|
||||||
|
db.add(schedule)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# ── Absences ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_absences(
|
||||||
|
self,
|
||||||
|
company_id: UUID,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: UUID | None = None,
|
||||||
|
type_id: UUID | None = None,
|
||||||
|
status: AbsenceStatus | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
) -> tuple[int, list[Absence]]:
|
||||||
|
q = (
|
||||||
|
select(Absence)
|
||||||
|
.join(User, Absence.user_id == User.id)
|
||||||
|
.where(User.company_id == company_id)
|
||||||
|
)
|
||||||
|
if current_user.role == UserRole.EMPLOYEE:
|
||||||
|
q = q.where(Absence.user_id == current_user.id)
|
||||||
|
elif user_id:
|
||||||
|
q = q.where(Absence.user_id == user_id)
|
||||||
|
|
||||||
|
if type_id:
|
||||||
|
q = q.where(Absence.type_id == type_id)
|
||||||
|
if status:
|
||||||
|
q = q.where(Absence.status == status)
|
||||||
|
if year:
|
||||||
|
q = q.where(Absence.start_date >= date(year, 1, 1), Absence.end_date <= date(year, 12, 31))
|
||||||
|
|
||||||
|
total = await db.scalar(select(func.count()).select_from(q.subquery())) or 0
|
||||||
|
result = await db.scalars(q.order_by(Absence.start_date.desc()))
|
||||||
|
return total, list(result.all())
|
||||||
|
|
||||||
|
async def get_by_id(self, absence_id: UUID, current_user: User, db: AsyncSession) -> Absence:
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
if absence.user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def create_absence(
|
||||||
|
self,
|
||||||
|
data: AbsenceCreate,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[Absence, list[str]]:
|
||||||
|
# AbsenceType validieren
|
||||||
|
absence_type = await self._get_type_or_404(data.type_id, current_user.company_id, db)
|
||||||
|
|
||||||
|
# Arbeitstage berechnen
|
||||||
|
holidays = await self._get_holiday_dates(
|
||||||
|
current_user.company_id, data.start_date.year, db
|
||||||
|
)
|
||||||
|
working_days = self._calc_working_days(
|
||||||
|
data.start_date, data.end_date, holidays,
|
||||||
|
data.half_day_start, data.half_day_end
|
||||||
|
)
|
||||||
|
|
||||||
|
if working_days <= 0:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Arbeitstage im ausgewählten Zeitraum.")
|
||||||
|
|
||||||
|
# Urlaubskonto prüfen wenn Urlaub abgezogen werden soll
|
||||||
|
warnings: list[str] = []
|
||||||
|
if absence_type.deducts_vacation:
|
||||||
|
balance = await self._get_or_create_balance(current_user.id, data.start_date.year, db)
|
||||||
|
if balance.remaining_days < working_days:
|
||||||
|
warnings.append(
|
||||||
|
f"Urlaubskonto reicht möglicherweise nicht aus: "
|
||||||
|
f"{balance.remaining_days} Tage verfügbar, {working_days} Tage beantragt."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Überschneidung mit eigenen Abwesenheiten prüfen
|
||||||
|
overlap = await db.scalar(
|
||||||
|
select(Absence).where(
|
||||||
|
and_(
|
||||||
|
Absence.user_id == current_user.id,
|
||||||
|
Absence.status != AbsenceStatus.CANCELLED,
|
||||||
|
Absence.status != AbsenceStatus.REJECTED,
|
||||||
|
Absence.start_date <= data.end_date,
|
||||||
|
Absence.end_date >= data.start_date,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if overlap:
|
||||||
|
warnings.append("Überschneidung mit bestehender Abwesenheit im selben Zeitraum.")
|
||||||
|
|
||||||
|
status = AbsenceStatus.PENDING if absence_type.requires_approval else AbsenceStatus.APPROVED
|
||||||
|
approved_by = None if absence_type.requires_approval else current_user.id
|
||||||
|
|
||||||
|
# Krankmeldung: AU-Pflicht-Datum automatisch berechnen.
|
||||||
|
# Reihenfolge: AbsenceType.certificate_after_days (override) → Company default.
|
||||||
|
certificate_required_by: date | None = None
|
||||||
|
if absence_type.category == AbsenceCategory.SICK and absence_type.requires_certificate:
|
||||||
|
company = await db.get(Company, current_user.company_id)
|
||||||
|
company_default = company.sick_note_required_after_days if company else 3
|
||||||
|
threshold = absence_type.certificate_after_days or company_default
|
||||||
|
certificate_required_by = data.start_date + timedelta(days=threshold)
|
||||||
|
|
||||||
|
absence = Absence(
|
||||||
|
user_id=current_user.id,
|
||||||
|
type_id=data.type_id,
|
||||||
|
start_date=data.start_date,
|
||||||
|
end_date=data.end_date,
|
||||||
|
half_day_start=data.half_day_start,
|
||||||
|
half_day_end=data.half_day_end,
|
||||||
|
working_days=working_days,
|
||||||
|
status=status,
|
||||||
|
approved_by=approved_by,
|
||||||
|
substitute_id=data.substitute_id,
|
||||||
|
note=data.note,
|
||||||
|
certificate_required_by=certificate_required_by,
|
||||||
|
)
|
||||||
|
db.add(absence)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
# Bei automatischer Genehmigung Konto abziehen
|
||||||
|
if not absence_type.requires_approval and absence_type.deducts_vacation:
|
||||||
|
await self._deduct_vacation(current_user.id, data.start_date.year, int(working_days), db)
|
||||||
|
|
||||||
|
return absence, warnings
|
||||||
|
|
||||||
|
async def update_absence(
|
||||||
|
self, absence_id: UUID, data: "AbsenceUpdate", current_user: User, db: AsyncSession
|
||||||
|
) -> Absence:
|
||||||
|
from app.schemas.absence import AbsenceUpdate
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
# Mitarbeiter: nur eigene; Manager: gleiche Company
|
||||||
|
if current_user.role == UserRole.EMPLOYEE:
|
||||||
|
if absence.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
else:
|
||||||
|
owner = await db.get(User, absence.user_id)
|
||||||
|
if owner is None or owner.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||||
|
is_manager = current_user.role in _manager_roles
|
||||||
|
if absence.status not in (AbsenceStatus.PENDING, AbsenceStatus.APPROVED):
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende oder genehmigte Anträge können bearbeitet werden.")
|
||||||
|
if absence.status == AbsenceStatus.APPROVED and not is_manager:
|
||||||
|
# Mitarbeiter stellt Änderungswunsch → Begründung Pflicht, Status zurück auf pending
|
||||||
|
if not data.correction_note or not data.correction_note.strip():
|
||||||
|
raise HTTPException(status_code=422, detail="Änderungsgrund ist bei genehmigten Anträgen Pflicht.")
|
||||||
|
|
||||||
|
if data.type_id is not None:
|
||||||
|
await self._get_type_or_404(data.type_id, current_user.company_id, db)
|
||||||
|
absence.type_id = data.type_id
|
||||||
|
if data.start_date is not None:
|
||||||
|
absence.start_date = data.start_date
|
||||||
|
if data.end_date is not None:
|
||||||
|
absence.end_date = data.end_date
|
||||||
|
if data.half_day_start is not None:
|
||||||
|
absence.half_day_start = data.half_day_start
|
||||||
|
if data.half_day_end is not None:
|
||||||
|
absence.half_day_end = data.half_day_end
|
||||||
|
if data.substitute_id is not None:
|
||||||
|
absence.substitute_id = data.substitute_id
|
||||||
|
if data.note is not None:
|
||||||
|
absence.note = data.note
|
||||||
|
if data.correction_note is not None:
|
||||||
|
absence.correction_note = data.correction_note.strip() or None
|
||||||
|
|
||||||
|
# Genehmigter Antrag: Mitarbeiter-Änderung → zurück auf pending (erneute Genehmigung)
|
||||||
|
was_approved = absence.status == AbsenceStatus.APPROVED
|
||||||
|
if was_approved and not is_manager:
|
||||||
|
absence.status = AbsenceStatus.PENDING
|
||||||
|
absence.approved_by = None
|
||||||
|
|
||||||
|
# Arbeitstage neu berechnen
|
||||||
|
holiday_dates = await self._get_holiday_dates(current_user.company_id, absence.start_date.year, db)
|
||||||
|
absence.working_days = Decimal(str(
|
||||||
|
self._calc_working_days(absence.start_date, absence.end_date,
|
||||||
|
holiday_dates, absence.half_day_start, absence.half_day_end)
|
||||||
|
))
|
||||||
|
|
||||||
|
# Audit-Log
|
||||||
|
action = "absence_change_request" if (was_approved and not is_manager) else "absence_updated"
|
||||||
|
db.add(AuditLog(
|
||||||
|
user_id=current_user.id,
|
||||||
|
action=action,
|
||||||
|
entity_type="absence",
|
||||||
|
entity_id=absence.id,
|
||||||
|
old_value={"status": "approved" if was_approved else "pending"},
|
||||||
|
new_value={
|
||||||
|
"status": absence.status.value,
|
||||||
|
"start_date": str(absence.start_date),
|
||||||
|
"end_date": str(absence.end_date),
|
||||||
|
"working_days": float(absence.working_days),
|
||||||
|
"correction_note": absence.correction_note,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def cancel_absence(
|
||||||
|
self, absence_id: UUID, current_user: User, db: AsyncSession
|
||||||
|
) -> Absence:
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
if absence.user_id != current_user.id:
|
||||||
|
raise HTTPException(status_code=403, detail="Nur eigene Anträge können storniert werden.")
|
||||||
|
if absence.status != AbsenceStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können gelöscht werden.")
|
||||||
|
absence.status = AbsenceStatus.CANCELLED
|
||||||
|
|
||||||
|
# Audit-Log (DSGVO)
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="absence_cancelled",
|
||||||
|
entity_type="absence",
|
||||||
|
entity_id=absence.id,
|
||||||
|
old_value={"status": "pending"},
|
||||||
|
new_value={
|
||||||
|
"status": "cancelled",
|
||||||
|
"cancelled_by": str(current_user.id),
|
||||||
|
"absence_user_id": str(absence.user_id),
|
||||||
|
"start_date": str(absence.start_date),
|
||||||
|
"end_date": str(absence.end_date),
|
||||||
|
"working_days": float(absence.working_days),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
from app.services.caldav_service import caldav_service
|
||||||
|
asyncio.create_task(caldav_service.sync_removed(absence, db))
|
||||||
|
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def approve_absence(
|
||||||
|
self, absence_id: UUID, current_user: User, db: AsyncSession
|
||||||
|
) -> Absence:
|
||||||
|
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
requester = await db.get(User, absence.user_id)
|
||||||
|
if requester is None or requester.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||||
|
if absence.user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Eigene Abwesenheitsanträge können nicht selbst genehmigt werden."
|
||||||
|
)
|
||||||
|
if absence.status != AbsenceStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können genehmigt werden.")
|
||||||
|
|
||||||
|
absence.status = AbsenceStatus.APPROVED
|
||||||
|
absence.approved_by = current_user.id
|
||||||
|
|
||||||
|
absence_type = await db.get(AbsenceType, absence.type_id)
|
||||||
|
|
||||||
|
# Urlaubskonto abziehen wenn nötig
|
||||||
|
if absence_type and absence_type.deducts_vacation:
|
||||||
|
await self._deduct_vacation(absence.user_id, absence.start_date.year, int(absence.working_days), db)
|
||||||
|
|
||||||
|
# Überstundenkonto abziehen wenn Freizeitausgleich
|
||||||
|
if absence_type and absence_type.affects_overtime_balance:
|
||||||
|
await self._deduct_overtime(absence.user_id, absence.working_days, db)
|
||||||
|
|
||||||
|
# Audit-Log (DSGVO)
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="absence_approved",
|
||||||
|
entity_type="absence",
|
||||||
|
entity_id=absence.id,
|
||||||
|
old_value={"status": "pending"},
|
||||||
|
new_value={
|
||||||
|
"status": "approved",
|
||||||
|
"approved_by": str(current_user.id),
|
||||||
|
"approved_by_name": current_user.full_name,
|
||||||
|
"absence_user_id": str(absence.user_id),
|
||||||
|
"start_date": str(absence.start_date),
|
||||||
|
"end_date": str(absence.end_date),
|
||||||
|
"working_days": float(absence.working_days),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
# CalDAV-Sync (fire & forget – Fehler blockieren nicht die Genehmigung)
|
||||||
|
from app.services.caldav_service import caldav_service
|
||||||
|
asyncio.create_task(caldav_service.sync_approved(absence, db))
|
||||||
|
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def reject_absence(
|
||||||
|
self, absence_id: UUID, data: AbsenceReject, current_user: User, db: AsyncSession
|
||||||
|
) -> Absence:
|
||||||
|
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
requester = await db.get(User, absence.user_id)
|
||||||
|
if requester is None or requester.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||||
|
if absence.status != AbsenceStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende Anträge können abgelehnt werden.")
|
||||||
|
|
||||||
|
absence.status = AbsenceStatus.REJECTED
|
||||||
|
absence.approved_by = current_user.id
|
||||||
|
absence.rejection_reason = data.rejection_reason
|
||||||
|
|
||||||
|
# Audit-Log (DSGVO)
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="absence_rejected",
|
||||||
|
entity_type="absence",
|
||||||
|
entity_id=absence.id,
|
||||||
|
old_value={"status": "pending"},
|
||||||
|
new_value={
|
||||||
|
"status": "rejected",
|
||||||
|
"rejection_reason": absence.rejection_reason,
|
||||||
|
"rejected_by": str(current_user.id),
|
||||||
|
"rejected_by_name": current_user.full_name,
|
||||||
|
"absence_user_id": str(absence.user_id),
|
||||||
|
"start_date": str(absence.start_date),
|
||||||
|
"end_date": str(absence.end_date),
|
||||||
|
"working_days": float(absence.working_days),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
from app.services.caldav_service import caldav_service
|
||||||
|
asyncio.create_task(caldav_service.sync_removed(absence, db))
|
||||||
|
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def get_calendar(
|
||||||
|
self,
|
||||||
|
company_id: UUID,
|
||||||
|
year: int,
|
||||||
|
month: int | None,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> list[dict]:
|
||||||
|
q = (
|
||||||
|
select(Absence, User, AbsenceType)
|
||||||
|
.join(User, Absence.user_id == User.id)
|
||||||
|
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||||||
|
.where(
|
||||||
|
User.company_id == company_id,
|
||||||
|
Absence.status.in_([AbsenceStatus.PENDING, AbsenceStatus.APPROVED]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if month:
|
||||||
|
start = date(year, month, 1)
|
||||||
|
end = date(year, month, 28) + timedelta(days=4)
|
||||||
|
end = end.replace(day=1) - timedelta(days=1)
|
||||||
|
q = q.where(Absence.start_date <= end, Absence.end_date >= start)
|
||||||
|
else:
|
||||||
|
q = q.where(
|
||||||
|
Absence.start_date >= date(year, 1, 1),
|
||||||
|
Absence.end_date <= date(year, 12, 31),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(q.order_by(Absence.start_date))
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
calendar = []
|
||||||
|
for absence, user, atype in rows:
|
||||||
|
calendar.append({
|
||||||
|
"user_id": user.id,
|
||||||
|
"user_name": user.full_name,
|
||||||
|
"absence_id": absence.id,
|
||||||
|
"type_name": atype.name,
|
||||||
|
"type_color": atype.color,
|
||||||
|
"start_date": absence.start_date,
|
||||||
|
"end_date": absence.end_date,
|
||||||
|
"status": absence.status,
|
||||||
|
"working_days": absence.working_days,
|
||||||
|
})
|
||||||
|
return calendar
|
||||||
|
|
||||||
|
# ── Urlaubskonto ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_balance(self, user_id: UUID, year: int, db: AsyncSession) -> VacationBalance:
|
||||||
|
return await self._get_or_create_balance(user_id, year, db)
|
||||||
|
|
||||||
|
async def get_pending_days(self, user_id: UUID, year: int, db: AsyncSession) -> float:
|
||||||
|
"""Summe der Arbeitstage aus ausstehenden Anträgen die Urlaub abziehen."""
|
||||||
|
q = (
|
||||||
|
select(func.sum(Absence.working_days))
|
||||||
|
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||||||
|
.where(
|
||||||
|
Absence.user_id == user_id,
|
||||||
|
Absence.status == AbsenceStatus.PENDING,
|
||||||
|
AbsenceType.deducts_vacation.is_(True),
|
||||||
|
func.extract("year", Absence.start_date) == year,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await db.scalar(q)
|
||||||
|
return float(result or 0)
|
||||||
|
|
||||||
|
# ── Feiertage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_holidays(
|
||||||
|
self, year: int, country: str, state: str | None, db: AsyncSession
|
||||||
|
) -> list[PublicHoliday]:
|
||||||
|
q = select(PublicHoliday).where(
|
||||||
|
PublicHoliday.year == year, PublicHoliday.country == country
|
||||||
|
)
|
||||||
|
if state:
|
||||||
|
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
|
||||||
|
result = await db.scalars(q.order_by(PublicHoliday.date))
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
async def create_holiday(self, data, db: AsyncSession) -> PublicHoliday:
|
||||||
|
holiday = PublicHoliday(
|
||||||
|
country=data.country,
|
||||||
|
state=data.state,
|
||||||
|
date=data.date,
|
||||||
|
name=data.name,
|
||||||
|
year=data.date.year,
|
||||||
|
)
|
||||||
|
db.add(holiday)
|
||||||
|
await db.flush()
|
||||||
|
return holiday
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_type_or_404(
|
||||||
|
self, type_id: UUID, company_id: UUID, db: AsyncSession
|
||||||
|
) -> AbsenceType:
|
||||||
|
at = await db.get(AbsenceType, type_id)
|
||||||
|
if at is None or at.company_id != company_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheitstyp nicht gefunden.")
|
||||||
|
return at
|
||||||
|
|
||||||
|
async def _get_or_create_balance(
|
||||||
|
self, user_id: UUID, year: int, db: AsyncSession
|
||||||
|
) -> VacationBalance:
|
||||||
|
balance = await db.scalar(
|
||||||
|
select(VacationBalance).where(
|
||||||
|
VacationBalance.user_id == user_id, VacationBalance.year == year
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if balance is None:
|
||||||
|
# Automatischer Übertrag: Resturlaub aus dem Vorjahr übernehmen
|
||||||
|
prev = await db.scalar(
|
||||||
|
select(VacationBalance).where(
|
||||||
|
VacationBalance.user_id == user_id, VacationBalance.year == year - 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
carried = max(0, prev.remaining_days) if prev else 0
|
||||||
|
entitled = prev.entitled_days if prev else 30
|
||||||
|
balance = VacationBalance(
|
||||||
|
user_id=user_id,
|
||||||
|
year=year,
|
||||||
|
entitled_days=entitled,
|
||||||
|
carried_over=carried,
|
||||||
|
)
|
||||||
|
db.add(balance)
|
||||||
|
await db.flush()
|
||||||
|
return balance
|
||||||
|
|
||||||
|
async def _deduct_vacation(
|
||||||
|
self, user_id: UUID, year: int, days: int, db: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
balance = await self._get_or_create_balance(user_id, year, db)
|
||||||
|
balance.used_days += days
|
||||||
|
|
||||||
|
async def _deduct_overtime(
|
||||||
|
self, user_id: UUID, working_days: float, db: AsyncSession
|
||||||
|
) -> None:
|
||||||
|
"""Zieht working_days × tägliche Stunden vom Überstundenkonto ab."""
|
||||||
|
# Stunden/Tag aus Arbeitsplan ermitteln (Fallback: 8h)
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
daily_hours = Decimal("8.00")
|
||||||
|
if user and user.work_schedule_id:
|
||||||
|
schedule = await db.get(WorkSchedule, user.work_schedule_id)
|
||||||
|
if schedule:
|
||||||
|
working_days_in_week = sum(
|
||||||
|
1 for h in [schedule.mon_h, schedule.tue_h, schedule.wed_h,
|
||||||
|
schedule.thu_h, schedule.fri_h, schedule.sat_h, schedule.sun_h]
|
||||||
|
if h > 0
|
||||||
|
)
|
||||||
|
if working_days_in_week > 0:
|
||||||
|
daily_hours = schedule.weekly_hours / Decimal(working_days_in_week)
|
||||||
|
|
||||||
|
hours_to_deduct = Decimal(str(working_days)) * daily_hours
|
||||||
|
|
||||||
|
ob = await db.scalar(select(OvertimeBalance).where(OvertimeBalance.user_id == user_id))
|
||||||
|
if ob is None:
|
||||||
|
# Erstelle Eintrag mit 0 Überstunden — taken_hours kann negativ werden
|
||||||
|
company_id = user.company_id if user else None
|
||||||
|
if not company_id:
|
||||||
|
return
|
||||||
|
ob = OvertimeBalance(user_id=user_id, company_id=company_id)
|
||||||
|
db.add(ob)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
ob.taken_hours += hours_to_deduct
|
||||||
|
|
||||||
|
async def _get_holiday_dates(
|
||||||
|
self, company_id: UUID, year: int, db: AsyncSession
|
||||||
|
) -> set[date]:
|
||||||
|
"""Feiertage für die Company-Country holen."""
|
||||||
|
from app.models.company import Company
|
||||||
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
company = await db.get(Company, company_id)
|
||||||
|
country = company.country if company else "DE"
|
||||||
|
state = company.state if company else None
|
||||||
|
|
||||||
|
q = select(PublicHoliday.date).where(
|
||||||
|
PublicHoliday.year == year,
|
||||||
|
PublicHoliday.country == country,
|
||||||
|
)
|
||||||
|
if state:
|
||||||
|
q = q.where(or_(PublicHoliday.state == state, PublicHoliday.state.is_(None)))
|
||||||
|
|
||||||
|
result = await db.scalars(q)
|
||||||
|
return set(result.all())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_working_days(
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
holidays: set[date],
|
||||||
|
half_day_start: bool,
|
||||||
|
half_day_end: bool,
|
||||||
|
) -> float:
|
||||||
|
count = 0.0
|
||||||
|
current = start
|
||||||
|
while current <= end:
|
||||||
|
if current.weekday() < 5 and current not in holidays:
|
||||||
|
count += 1.0
|
||||||
|
current += timedelta(days=1)
|
||||||
|
# Halbtage abziehen
|
||||||
|
if half_day_start and start.weekday() < 5 and start not in holidays:
|
||||||
|
count -= 0.5
|
||||||
|
if half_day_end and end.weekday() < 5 and end not in holidays and end != start:
|
||||||
|
count -= 0.5
|
||||||
|
return max(0.0, count)
|
||||||
|
|
||||||
|
# ── Krankmeldung ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def quick_sick(
|
||||||
|
self,
|
||||||
|
start: date,
|
||||||
|
end: date,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[Absence, list[str]]:
|
||||||
|
"""Sofort-Krankmeldung: nutzt den ersten aktiven SICK-Typ der Firma."""
|
||||||
|
sick_type = await db.scalar(
|
||||||
|
select(AbsenceType)
|
||||||
|
.where(
|
||||||
|
AbsenceType.company_id == current_user.company_id,
|
||||||
|
AbsenceType.category == AbsenceCategory.SICK,
|
||||||
|
AbsenceType.is_active == True,
|
||||||
|
)
|
||||||
|
.order_by(AbsenceType.name)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
if sick_type is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Kein aktiver Krankheits-Typ konfiguriert.")
|
||||||
|
if end < start:
|
||||||
|
raise HTTPException(status_code=400, detail="Enddatum darf nicht vor dem Startdatum liegen.")
|
||||||
|
|
||||||
|
create_data = AbsenceCreate(
|
||||||
|
type_id=sick_type.id,
|
||||||
|
start_date=start,
|
||||||
|
end_date=end,
|
||||||
|
)
|
||||||
|
return await self.create_absence(create_data, current_user, db)
|
||||||
|
|
||||||
|
async def mark_certificate_received(
|
||||||
|
self,
|
||||||
|
absence_id: UUID,
|
||||||
|
received_at: date | None,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> Absence:
|
||||||
|
"""HR/Admin: AU-Bescheinigung als eingegangen markieren."""
|
||||||
|
if current_user.role not in (UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||||
|
raise HTTPException(status_code=403, detail="Nur HR/Admin darf den Attest-Eingang markieren.")
|
||||||
|
|
||||||
|
absence = await db.get(Absence, absence_id)
|
||||||
|
if absence is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Abwesenheit nicht gefunden.")
|
||||||
|
owner = await db.get(User, absence.user_id)
|
||||||
|
if owner is None or owner.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||||
|
|
||||||
|
absence_type = await db.get(AbsenceType, absence.type_id)
|
||||||
|
if absence_type is None or absence_type.category != AbsenceCategory.SICK:
|
||||||
|
raise HTTPException(status_code=409, detail="Nur für Krankmeldungen verfügbar.")
|
||||||
|
|
||||||
|
old_value = str(absence.certificate_received_at) if absence.certificate_received_at else None
|
||||||
|
absence.certificate_received_at = received_at or date.today()
|
||||||
|
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="absence_certificate_received",
|
||||||
|
entity_type="absence",
|
||||||
|
entity_id=absence.id,
|
||||||
|
old_value={"certificate_received_at": old_value},
|
||||||
|
new_value={
|
||||||
|
"certificate_received_at": str(absence.certificate_received_at),
|
||||||
|
"absence_user_id": str(absence.user_id),
|
||||||
|
"marked_by": str(current_user.id),
|
||||||
|
"marked_by_name": current_user.full_name,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
return absence
|
||||||
|
|
||||||
|
async def get_sick_stats(
|
||||||
|
self,
|
||||||
|
company_id: UUID,
|
||||||
|
current_user: User,
|
||||||
|
ref_date: date,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: UUID | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Krankheitsstatistik für rolling 12 Monate ab ref_date.
|
||||||
|
|
||||||
|
Bradford-Faktor: S² × D mit S = Anzahl Episoden, D = Summe Kranktage.
|
||||||
|
"""
|
||||||
|
window_start = ref_date - timedelta(days=365)
|
||||||
|
|
||||||
|
q = (
|
||||||
|
select(Absence, User)
|
||||||
|
.join(User, Absence.user_id == User.id)
|
||||||
|
.join(AbsenceType, Absence.type_id == AbsenceType.id)
|
||||||
|
.where(
|
||||||
|
User.company_id == company_id,
|
||||||
|
AbsenceType.category == AbsenceCategory.SICK,
|
||||||
|
Absence.status == AbsenceStatus.APPROVED,
|
||||||
|
Absence.start_date <= ref_date,
|
||||||
|
Absence.end_date >= window_start,
|
||||||
|
)
|
||||||
|
.order_by(User.last_name, User.first_name, Absence.start_date)
|
||||||
|
)
|
||||||
|
if user_id:
|
||||||
|
q = q.where(Absence.user_id == user_id)
|
||||||
|
# MANAGER sieht nur sein Department
|
||||||
|
if current_user.role == UserRole.MANAGER and current_user.department_id:
|
||||||
|
q = q.where(User.department_id == current_user.department_id)
|
||||||
|
|
||||||
|
result = await db.execute(q)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
by_user: dict[UUID, dict] = {}
|
||||||
|
for absence, user in rows:
|
||||||
|
entry = by_user.setdefault(user.id, {
|
||||||
|
"user_id": user.id,
|
||||||
|
"user_name": user.full_name,
|
||||||
|
"personnel_number": user.personnel_number,
|
||||||
|
"episodes": 0,
|
||||||
|
"total_days": 0.0,
|
||||||
|
"certificates_overdue": 0,
|
||||||
|
})
|
||||||
|
entry["episodes"] += 1
|
||||||
|
entry["total_days"] += float(absence.working_days or 0)
|
||||||
|
if (
|
||||||
|
absence.certificate_required_by
|
||||||
|
and absence.certificate_required_by < ref_date
|
||||||
|
and absence.certificate_received_at is None
|
||||||
|
):
|
||||||
|
entry["certificates_overdue"] += 1
|
||||||
|
|
||||||
|
for entry in by_user.values():
|
||||||
|
entry["bradford_factor"] = float(entry["episodes"]) ** 2 * entry["total_days"]
|
||||||
|
|
||||||
|
return list(by_user.values())
|
||||||
|
|
||||||
|
|
||||||
|
absence_service = AbsenceService()
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
generate_invite_token,
|
||||||
|
generate_reset_token,
|
||||||
|
hash_password,
|
||||||
|
hash_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from app.models import Company, PasswordReset, Session, User, UserRole
|
||||||
|
from app.schemas.auth import LoginRequest, RegisterRequest, TokenResponse
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(name: str) -> str:
|
||||||
|
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||||
|
return slug[:80]
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
|
||||||
|
async def register(self, data: RegisterRequest, db: AsyncSession) -> TokenResponse:
|
||||||
|
existing = await db.scalar(select(User).where(User.email == data.email))
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
|
||||||
|
base_slug = _slugify(data.company_name)
|
||||||
|
slug = base_slug
|
||||||
|
counter = 1
|
||||||
|
while await db.scalar(select(Company).where(Company.slug == slug)):
|
||||||
|
slug = f"{base_slug}-{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
company = Company(name=data.company_name, slug=slug)
|
||||||
|
db.add(company)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
user = User(
|
||||||
|
company_id=company.id,
|
||||||
|
email=data.email,
|
||||||
|
password_hash=hash_password(data.password),
|
||||||
|
first_name=data.first_name,
|
||||||
|
last_name=data.last_name,
|
||||||
|
role=UserRole.COMPANY_ADMIN,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
from app.services.absence_service import absence_service
|
||||||
|
await absence_service.create_defaults_for_company(company.id, db)
|
||||||
|
|
||||||
|
tokens = await self._create_session(user, db)
|
||||||
|
await email_service.send_welcome(user, db)
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
async def login(self, data: LoginRequest, db: AsyncSession, request: Request) -> TokenResponse:
|
||||||
|
from app.models.user import AuthProvider
|
||||||
|
from app.services.ldap_service import ldap_service
|
||||||
|
|
||||||
|
user = await db.scalar(select(User).where(User.email == data.email))
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid email or password",
|
||||||
|
)
|
||||||
|
if not user.is_active:
|
||||||
|
raise HTTPException(status_code=403, detail="Account is deactivated")
|
||||||
|
|
||||||
|
if user.auth_provider == AuthProvider.LDAP:
|
||||||
|
ldap_cfg = await ldap_service.get_config(user.company_id, db)
|
||||||
|
if not ldap_cfg or not ldap_cfg.enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="LDAP authentication not available",
|
||||||
|
)
|
||||||
|
if not ldap_service.authenticate_ldap(ldap_cfg, data.email, data.password):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid email or password",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not user.password_hash or not verify_password(data.password, user.password_hash):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid email or password",
|
||||||
|
)
|
||||||
|
|
||||||
|
# TOTP: wenn aktiviert → partial token zurückgeben statt vollem Login
|
||||||
|
if user.totp_enabled:
|
||||||
|
from app.core.security import create_partial_token
|
||||||
|
from app.schemas.auth import TokenResponse
|
||||||
|
partial = create_partial_token(str(user.id))
|
||||||
|
return TokenResponse(
|
||||||
|
access_token="",
|
||||||
|
refresh_token="",
|
||||||
|
totp_required=True,
|
||||||
|
partial_token=partial,
|
||||||
|
)
|
||||||
|
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
return await self._create_session(user, db, request=request)
|
||||||
|
|
||||||
|
async def refresh(self, raw_token: str, db: AsyncSession) -> TokenResponse:
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
session = await db.scalar(
|
||||||
|
select(Session).where(Session.refresh_token_hash == token_hash)
|
||||||
|
)
|
||||||
|
if not session or session.expires_at < datetime.now(timezone.utc):
|
||||||
|
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||||
|
|
||||||
|
user = await db.get(User, session.user_id)
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=401, detail="User not found or inactive")
|
||||||
|
|
||||||
|
await db.delete(session)
|
||||||
|
return await self._create_session(user, db)
|
||||||
|
|
||||||
|
async def logout(self, raw_token: str, db: AsyncSession) -> None:
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
session = await db.scalar(
|
||||||
|
select(Session).where(Session.refresh_token_hash == token_hash)
|
||||||
|
)
|
||||||
|
if session:
|
||||||
|
await db.delete(session)
|
||||||
|
|
||||||
|
async def request_password_reset(self, email: str, db: AsyncSession) -> str | None:
|
||||||
|
"""
|
||||||
|
Gibt None zurück (lokale User) oder 'ldap' wenn der User LDAP-Auth nutzt.
|
||||||
|
Die aufrufende Route entscheidet, was dem Client mitgeteilt wird.
|
||||||
|
"""
|
||||||
|
from app.models.user import AuthProvider
|
||||||
|
user = await db.scalar(select(User).where(User.email == email))
|
||||||
|
if not user:
|
||||||
|
return None # Security: kein Hinweis ob E-Mail existiert
|
||||||
|
|
||||||
|
if user.auth_provider == AuthProvider.LDAP:
|
||||||
|
return "ldap"
|
||||||
|
|
||||||
|
old_resets = await db.scalars(
|
||||||
|
select(PasswordReset).where(
|
||||||
|
PasswordReset.user_id == user.id,
|
||||||
|
PasswordReset.used_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for r in old_resets:
|
||||||
|
await db.delete(r)
|
||||||
|
|
||||||
|
raw, hashed = generate_reset_token()
|
||||||
|
reset = PasswordReset(
|
||||||
|
user_id=user.id,
|
||||||
|
token_hash=hashed,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=1),
|
||||||
|
)
|
||||||
|
db.add(reset)
|
||||||
|
await email_service.send_password_reset(user, raw, db)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def confirm_password_reset(self, token: str, new_password: str, db: AsyncSession) -> None:
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
reset = await db.scalar(
|
||||||
|
select(PasswordReset).where(
|
||||||
|
PasswordReset.token_hash == token_hash,
|
||||||
|
PasswordReset.used_at.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not reset or reset.expires_at < datetime.now(timezone.utc):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
|
||||||
|
|
||||||
|
user = await db.get(User, reset.user_id)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="User not found")
|
||||||
|
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
reset.used_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
sessions = await db.scalars(select(Session).where(Session.user_id == user.id))
|
||||||
|
for s in sessions:
|
||||||
|
await db.delete(s)
|
||||||
|
|
||||||
|
async def _create_session(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
request: Request | None = None,
|
||||||
|
) -> TokenResponse:
|
||||||
|
raw_refresh, hashed_refresh = create_refresh_token()
|
||||||
|
session = Session(
|
||||||
|
user_id=user.id,
|
||||||
|
refresh_token_hash=hashed_refresh,
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(days=settings.refresh_token_expire_days),
|
||||||
|
device=request.headers.get("User-Agent", "")[:255] if request else None,
|
||||||
|
ip=request.client.host if request and request.client else None,
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
access_token = create_access_token(
|
||||||
|
str(user.id),
|
||||||
|
extra={"role": user.role, "company_id": str(user.company_id)},
|
||||||
|
)
|
||||||
|
return TokenResponse(access_token=access_token, refresh_token=raw_refresh)
|
||||||
|
|
||||||
|
|
||||||
|
auth_service = AuthService()
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""
|
||||||
|
CalDAV-Sync für Abwesenheiten.
|
||||||
|
|
||||||
|
Logik:
|
||||||
|
approve → VEVENT in persönlichem Kalender (CaldavUserConfig) +
|
||||||
|
VEVENT in Firmenkalender (CaldavCompanyConfig)
|
||||||
|
reject / cancel → DELETE aus beiden Kalendern
|
||||||
|
|
||||||
|
Verwendet httpx für die HTTP-Kommunikation und icalendar für iCal-Erzeugung.
|
||||||
|
Passwörter werden Fernet-verschlüsselt gespeichert (gleiche Methode wie SMTP/LDAP).
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import date, timedelta, timezone, datetime
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from icalendar import Calendar, Event
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.absence import Absence
|
||||||
|
from app.models.caldav_config import CaldavCompanyConfig, CaldavUserConfig
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Crypto (shared with SMTP/LDAP) ────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _fernet():
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
key = hashlib.sha256(settings.secret_key.encode()).digest()
|
||||||
|
return Fernet(base64.urlsafe_b64encode(key))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_password(plain: str) -> str:
|
||||||
|
return _fernet().encrypt(plain.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_password(encrypted: str) -> str:
|
||||||
|
return _fernet().decrypt(encrypted.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
# ── iCal builder ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _build_ical(uid: str, summary: str, start: date, end: date, description: str = "") -> bytes:
|
||||||
|
"""Erzeugt einen VCALENDAR-Blob für ein ganztägiges Ereignis."""
|
||||||
|
cal = Calendar()
|
||||||
|
cal.add("prodid", "-//TimeMaster//DE")
|
||||||
|
cal.add("version", "2.0")
|
||||||
|
|
||||||
|
ev = Event()
|
||||||
|
ev.add("uid", f"{uid}@timemaster")
|
||||||
|
ev.add("dtstart", start)
|
||||||
|
ev.add("dtend", end + timedelta(days=1)) # DTEND ist exklusiv
|
||||||
|
ev.add("summary", summary)
|
||||||
|
if description:
|
||||||
|
ev.add("description", description)
|
||||||
|
ev.add("status", "CONFIRMED")
|
||||||
|
ev.add("transp", "TRANSPARENT") # zeigt keine Verfügbarkeit als blockiert
|
||||||
|
ev.add("dtstamp", datetime.now(timezone.utc))
|
||||||
|
|
||||||
|
cal.add_component(ev)
|
||||||
|
return cal.to_ical()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Kalender-Titel formatieren ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _format_summary(user: "User", absence_type: str, name_template: str) -> str:
|
||||||
|
"""
|
||||||
|
Ersetzt Platzhalter im name_template:
|
||||||
|
$vorname → vollständiger Vorname
|
||||||
|
$nachname → vollständiger Nachname
|
||||||
|
$vorname_short → erster Buchstabe Vorname
|
||||||
|
$nachname_middle → erste 3 Buchstaben Nachname
|
||||||
|
$kuerzel → manuell gesetztes Kürzel (Fallback: Initialen)
|
||||||
|
$personalnummer → Personalnummer (leer wenn nicht gesetzt)
|
||||||
|
$typ → Abwesenheitsart
|
||||||
|
"""
|
||||||
|
kuerzel = user.kuerzel if user.kuerzel else (user.first_name[:1] + user.last_name[:1]).upper()
|
||||||
|
result = name_template
|
||||||
|
result = result.replace("$vorname_short", user.first_name[:1])
|
||||||
|
result = result.replace("$nachname_middle", user.last_name[:3])
|
||||||
|
result = result.replace("$vorname", user.first_name)
|
||||||
|
result = result.replace("$nachname", user.last_name)
|
||||||
|
result = result.replace("$kuerzel", kuerzel)
|
||||||
|
result = result.replace("$personalnummer", user.personnel_number or "")
|
||||||
|
result = result.replace("$typ", absence_type)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ── HTTP helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _event_url(calendar_url: str, uid: str) -> str:
|
||||||
|
return calendar_url.rstrip("/") + f"/{uid}.ics"
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_put(
|
||||||
|
calendar_url: str, username: str, password: str, uid: str,
|
||||||
|
ical: bytes, verify_ssl: bool,
|
||||||
|
) -> str:
|
||||||
|
"""PUT event. Returns ETag (empty string if server doesn't send one)."""
|
||||||
|
url = _event_url(calendar_url, uid)
|
||||||
|
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
|
||||||
|
resp = await client.put(
|
||||||
|
url, content=ical,
|
||||||
|
headers={"Content-Type": "text/calendar; charset=utf-8"},
|
||||||
|
auth=(username, password),
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.headers.get("ETag", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_delete(
|
||||||
|
calendar_url: str, username: str, password: str, uid: str, verify_ssl: bool,
|
||||||
|
) -> None:
|
||||||
|
url = _event_url(calendar_url, uid)
|
||||||
|
async with httpx.AsyncClient(verify=verify_ssl, timeout=15) as client:
|
||||||
|
resp = await client.delete(url, auth=(username, password))
|
||||||
|
if resp.status_code not in (200, 204, 404):
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_propfind(
|
||||||
|
calendar_url: str, username: str, password: str, verify_ssl: bool,
|
||||||
|
) -> int:
|
||||||
|
"""Einfacher Verbindungstest via PROPFIND Depth:0. Gibt HTTP-Status zurück."""
|
||||||
|
body = b'<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:resourcetype/></d:prop></d:propfind>'
|
||||||
|
async with httpx.AsyncClient(verify=verify_ssl, timeout=10) as client:
|
||||||
|
resp = await client.request(
|
||||||
|
"PROPFIND", calendar_url.rstrip("/") + "/",
|
||||||
|
content=body,
|
||||||
|
headers={"Content-Type": "application/xml; charset=utf-8", "Depth": "0"},
|
||||||
|
auth=(username, password),
|
||||||
|
)
|
||||||
|
return resp.status_code
|
||||||
|
|
||||||
|
|
||||||
|
# ── Service ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CalDavService:
|
||||||
|
|
||||||
|
# ── Config laden ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_company_config(
|
||||||
|
self, company_id: uuid.UUID, db: AsyncSession
|
||||||
|
) -> CaldavCompanyConfig | None:
|
||||||
|
return await db.scalar(
|
||||||
|
select(CaldavCompanyConfig).where(CaldavCompanyConfig.company_id == company_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_user_config(
|
||||||
|
self, user_id: uuid.UUID, db: AsyncSession
|
||||||
|
) -> CaldavUserConfig | None:
|
||||||
|
return await db.scalar(
|
||||||
|
select(CaldavUserConfig).where(CaldavUserConfig.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Sync-Operationen ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def sync_approved(self, absence: Absence, db: AsyncSession) -> None:
|
||||||
|
"""Wird nach Genehmigung gerufen: Event in beide Kalender einpflegen."""
|
||||||
|
# User und AbsenceType laden (für VEVENT-Titel)
|
||||||
|
from app.models.user import User
|
||||||
|
from app.models.absence_type import AbsenceType
|
||||||
|
|
||||||
|
user = await db.get(User, absence.user_id)
|
||||||
|
atype = await db.get(AbsenceType, absence.type_id)
|
||||||
|
if not user or not atype:
|
||||||
|
return
|
||||||
|
|
||||||
|
if absence.caldav_uid is None:
|
||||||
|
absence.caldav_uid = str(uuid.uuid4())
|
||||||
|
|
||||||
|
description = absence.note or ""
|
||||||
|
|
||||||
|
# Persönlicher Kalender: nur Abwesenheitsart, kein Name
|
||||||
|
personal_ical = _build_ical(
|
||||||
|
absence.caldav_uid, atype.name,
|
||||||
|
absence.start_date, absence.end_date, description,
|
||||||
|
)
|
||||||
|
user_cfg = await self.get_user_config(user.id, db)
|
||||||
|
if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
|
||||||
|
try:
|
||||||
|
pw = decrypt_password(user_cfg.password_encrypted)
|
||||||
|
etag = await _http_put(
|
||||||
|
user_cfg.calendar_url, user_cfg.username, pw,
|
||||||
|
absence.caldav_uid, personal_ical, user_cfg.verify_ssl,
|
||||||
|
)
|
||||||
|
absence.caldav_user_etag = etag
|
||||||
|
except Exception as exc:
|
||||||
|
absence.caldav_last_error = f"User-Kalender: {exc}"
|
||||||
|
log.warning("CalDAV user sync failed for absence %s: %s", absence.id, exc)
|
||||||
|
|
||||||
|
# Firmenkalender: Titelformat per Konfiguration
|
||||||
|
company_cfg = await self.get_company_config(user.company_id, db)
|
||||||
|
if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
|
||||||
|
company_summary = _format_summary(user, atype.name, company_cfg.name_template)
|
||||||
|
company_ical = _build_ical(
|
||||||
|
absence.caldav_uid, company_summary,
|
||||||
|
absence.start_date, absence.end_date, description,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
pw = decrypt_password(company_cfg.password_encrypted)
|
||||||
|
etag = await _http_put(
|
||||||
|
company_cfg.calendar_url, company_cfg.username, pw,
|
||||||
|
absence.caldav_uid, company_ical, company_cfg.verify_ssl,
|
||||||
|
)
|
||||||
|
absence.caldav_company_etag = etag
|
||||||
|
except Exception as exc:
|
||||||
|
err = f"Firmen-Kalender: {exc}"
|
||||||
|
absence.caldav_last_error = (
|
||||||
|
(absence.caldav_last_error + " | " + err) if absence.caldav_last_error else err
|
||||||
|
)
|
||||||
|
log.warning("CalDAV company sync failed for absence %s: %s", absence.id, exc)
|
||||||
|
|
||||||
|
absence.caldav_synced_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
async def sync_removed(self, absence: Absence, db: AsyncSession) -> None:
|
||||||
|
"""Wird nach Ablehnung/Stornierung gerufen: Event aus Kalendern löschen."""
|
||||||
|
if not absence.caldav_uid:
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
user = await db.get(User, absence.user_id)
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Persönlicher Kalender
|
||||||
|
user_cfg = await self.get_user_config(user.id, db)
|
||||||
|
if user_cfg and user_cfg.enabled and user_cfg.calendar_url:
|
||||||
|
try:
|
||||||
|
pw = decrypt_password(user_cfg.password_encrypted)
|
||||||
|
await _http_delete(
|
||||||
|
user_cfg.calendar_url, user_cfg.username, pw,
|
||||||
|
absence.caldav_uid, user_cfg.verify_ssl,
|
||||||
|
)
|
||||||
|
absence.caldav_user_etag = None
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("CalDAV user delete failed for absence %s: %s", absence.id, exc)
|
||||||
|
|
||||||
|
# Firmenkalender
|
||||||
|
company_cfg = await self.get_company_config(user.company_id, db)
|
||||||
|
if company_cfg and company_cfg.enabled and company_cfg.calendar_url:
|
||||||
|
try:
|
||||||
|
pw = decrypt_password(company_cfg.password_encrypted)
|
||||||
|
await _http_delete(
|
||||||
|
company_cfg.calendar_url, company_cfg.username, pw,
|
||||||
|
absence.caldav_uid, company_cfg.verify_ssl,
|
||||||
|
)
|
||||||
|
absence.caldav_company_etag = None
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning("CalDAV company delete failed for absence %s: %s", absence.id, exc)
|
||||||
|
|
||||||
|
absence.caldav_last_error = None
|
||||||
|
absence.caldav_synced_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
async def resync_all_approved(self, company_id: uuid.UUID, db: AsyncSession) -> dict:
|
||||||
|
"""Alle genehmigten Abwesenheiten der Firma neu synchronisieren."""
|
||||||
|
from app.models.absence import AbsenceStatus
|
||||||
|
from app.models.user import User
|
||||||
|
result = await db.scalars(
|
||||||
|
select(Absence)
|
||||||
|
.join(Absence.user)
|
||||||
|
.where(
|
||||||
|
Absence.status == AbsenceStatus.APPROVED,
|
||||||
|
User.company_id == company_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
absences = list(result.all())
|
||||||
|
|
||||||
|
ok = 0
|
||||||
|
failed = 0
|
||||||
|
for absence in absences:
|
||||||
|
try:
|
||||||
|
await self.sync_approved(absence, db)
|
||||||
|
ok += 1
|
||||||
|
except Exception as exc:
|
||||||
|
failed += 1
|
||||||
|
log.error("Resync failed for absence %s: %s", absence.id, exc)
|
||||||
|
|
||||||
|
return {"synced": ok, "failed": failed, "total": len(absences)}
|
||||||
|
|
||||||
|
# ── Verbindungstest ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def test_config(
|
||||||
|
self, cfg: Union[CaldavCompanyConfig, CaldavUserConfig]
|
||||||
|
) -> dict:
|
||||||
|
if not cfg.calendar_url:
|
||||||
|
return {"ok": False, "error": "Keine Kalender-URL konfiguriert."}
|
||||||
|
try:
|
||||||
|
pw = decrypt_password(cfg.password_encrypted)
|
||||||
|
status = await _http_propfind(cfg.calendar_url, cfg.username, pw, cfg.verify_ssl)
|
||||||
|
if status in (200, 207):
|
||||||
|
return {"ok": True, "status": status}
|
||||||
|
return {"ok": False, "error": f"Server antwortete mit HTTP {status}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
caldav_service = CalDavService()
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""
|
||||||
|
E-Mail-Versand via SMTP (smtplib + asyncio.to_thread).
|
||||||
|
Konfiguration pro Firma in smtp_configs. Kein externer Mail-Dienst nötig.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import smtplib
|
||||||
|
import ssl
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.smtp_config import SmtpConfig
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
def _html_wrapper(title: str, body: str) -> str:
|
||||||
|
return f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head><meta charset="UTF-8"><title>{title}</title>
|
||||||
|
<style>
|
||||||
|
body {{ font-family: system-ui, sans-serif; background: #f4f4f4; margin: 0; padding: 40px 20px; }}
|
||||||
|
.card {{ background: white; border-radius: 12px; padding: 40px; max-width: 520px; margin: 0 auto; }}
|
||||||
|
.logo {{ font-size: 22px; font-weight: 600; color: #2563EB; margin-bottom: 32px; }}
|
||||||
|
h1 {{ font-size: 20px; font-weight: 600; color: #111; margin: 0 0 12px; }}
|
||||||
|
p {{ font-size: 15px; color: #555; line-height: 1.6; margin: 0 0 16px; }}
|
||||||
|
.btn {{ display: inline-block; padding: 12px 28px; background: #2563EB; color: white !important;
|
||||||
|
text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 15px; margin: 8px 0 24px; }}
|
||||||
|
.footer {{ font-size: 12px; color: #999; margin-top: 32px; padding-top: 16px; border-top: 1px solid #eee; }}
|
||||||
|
</style></head>
|
||||||
|
<body><div class="card">
|
||||||
|
<div class="logo">⏱ {settings.app_name}</div>
|
||||||
|
{body}
|
||||||
|
<div class="footer">Diese E-Mail wurde automatisch von {settings.app_name} gesendet.</div>
|
||||||
|
</div></body></html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_password(encrypted: str) -> str:
|
||||||
|
"""Fernet-Entschlüsselung (gleiche Implementierung wie ldap_service)."""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
key = hashlib.sha256(settings.secret_key.encode()).digest()
|
||||||
|
f = Fernet(base64.urlsafe_b64encode(key))
|
||||||
|
return f.decrypt(encrypted.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _smtp_send_sync(cfg: SmtpConfig, to: str, subject: str, html: str) -> None:
|
||||||
|
"""Synchroner SMTP-Versand – wird via asyncio.to_thread() aufgerufen."""
|
||||||
|
password = _decrypt_password(cfg.password_encrypted) if cfg.password_encrypted else None
|
||||||
|
|
||||||
|
msg = MIMEMultipart("alternative")
|
||||||
|
msg["Subject"] = subject
|
||||||
|
msg["From"] = f"{cfg.from_name} <{cfg.from_email}>"
|
||||||
|
msg["To"] = to
|
||||||
|
msg.attach(MIMEText(html, "html", "utf-8"))
|
||||||
|
|
||||||
|
if cfg.use_tls:
|
||||||
|
context = ssl.create_default_context()
|
||||||
|
with smtplib.SMTP_SSL(cfg.host, cfg.port, context=context) as smtp:
|
||||||
|
if cfg.username and password:
|
||||||
|
smtp.login(cfg.username, password)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(cfg.host, cfg.port) as smtp:
|
||||||
|
if cfg.use_starttls:
|
||||||
|
smtp.starttls(context=ssl.create_default_context())
|
||||||
|
if cfg.username and password:
|
||||||
|
smtp.login(cfg.username, password)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailService:
|
||||||
|
|
||||||
|
async def _load_smtp(self, company_id: UUID, db: AsyncSession) -> SmtpConfig | None:
|
||||||
|
return await db.scalar(
|
||||||
|
select(SmtpConfig).where(
|
||||||
|
SmtpConfig.company_id == company_id,
|
||||||
|
SmtpConfig.is_enabled == True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send(self, to: str, subject: str, html: str, cfg: SmtpConfig | None) -> None:
|
||||||
|
if not cfg:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"EMAIL (kein SMTP konfiguriert – nicht versendet)")
|
||||||
|
print(f" To: {to}")
|
||||||
|
print(f" Subject: {subject}")
|
||||||
|
print(f"{'='*60}\n")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(_smtp_send_sync, cfg, to, subject, html)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"SMTP Fehler: {exc}")
|
||||||
|
|
||||||
|
async def send_welcome(self, user: "User", db: AsyncSession) -> None:
|
||||||
|
cfg = await self._load_smtp(user.company_id, db)
|
||||||
|
body = f"""
|
||||||
|
<h1>Willkommen bei {settings.app_name}, {user.first_name}!</h1>
|
||||||
|
<p>Dein Firmen-Account wurde erfolgreich erstellt. Du kannst dich ab sofort anmelden.</p>
|
||||||
|
<a href="{settings.frontend_url}/login" class="btn">Zum Login</a>
|
||||||
|
"""
|
||||||
|
await self._send(user.email, f"Willkommen bei {settings.app_name}", _html_wrapper("Willkommen", body), cfg)
|
||||||
|
|
||||||
|
async def send_invite(self, user: "User", invited_by: "User", raw_token: str, db: AsyncSession) -> None:
|
||||||
|
cfg = await self._load_smtp(user.company_id, db)
|
||||||
|
invite_url = f"{settings.frontend_url}/invite/accept?token={raw_token}"
|
||||||
|
body = f"""
|
||||||
|
<h1>Du wurdest eingeladen!</h1>
|
||||||
|
<p><strong>{invited_by.full_name}</strong> hat dich zu <strong>{settings.app_name}</strong> eingeladen.</p>
|
||||||
|
<p>Klicke auf den Button, um dein Konto zu aktivieren und ein Passwort festzulegen.<br>
|
||||||
|
Der Link ist <strong>7 Tage</strong> gültig.</p>
|
||||||
|
<a href="{invite_url}" class="btn">Einladung annehmen</a>
|
||||||
|
<p style="font-size:13px;color:#999;">Oder kopiere diesen Link: {invite_url}</p>
|
||||||
|
"""
|
||||||
|
await self._send(
|
||||||
|
user.email,
|
||||||
|
f"{invited_by.full_name} hat dich zu {settings.app_name} eingeladen",
|
||||||
|
_html_wrapper("Einladung", body),
|
||||||
|
cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_password_reset(self, user: "User", raw_token: str, db: AsyncSession) -> None:
|
||||||
|
cfg = await self._load_smtp(user.company_id, db)
|
||||||
|
reset_url = f"{settings.frontend_url}/auth/reset-password?token={raw_token}"
|
||||||
|
body = f"""
|
||||||
|
<h1>Passwort zurücksetzen</h1>
|
||||||
|
<p>Hallo {user.first_name},</p>
|
||||||
|
<p>du hast eine Anfrage zum Zurücksetzen deines Passworts gestellt.<br>
|
||||||
|
Klicke auf den Button – der Link ist <strong>1 Stunde</strong> gültig.</p>
|
||||||
|
<a href="{reset_url}" class="btn">Passwort zurücksetzen</a>
|
||||||
|
<p>Falls du diese Anfrage nicht gestellt hast, kannst du diese E-Mail ignorieren.</p>
|
||||||
|
"""
|
||||||
|
await self._send(user.email, "Passwort zurücksetzen", _html_wrapper("Passwort zurücksetzen", body), cfg)
|
||||||
|
|
||||||
|
async def send_test(self, cfg: SmtpConfig, to: str) -> None:
|
||||||
|
"""Test-E-Mail direkt mit übergebenem Konfigurationsobjekt."""
|
||||||
|
body = f"""
|
||||||
|
<h1>SMTP-Test erfolgreich!</h1>
|
||||||
|
<p>Deine SMTP-Konfiguration für <strong>{settings.app_name}</strong> funktioniert korrekt.</p>
|
||||||
|
<p>Server: {cfg.host}:{cfg.port}</p>
|
||||||
|
"""
|
||||||
|
await self._send(to, f"{settings.app_name} – SMTP-Test", _html_wrapper("SMTP-Test", body), cfg)
|
||||||
|
|
||||||
|
|
||||||
|
email_service = EmailService()
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Feiertags-Service: berechnet und befüllt deutsche Feiertage per Bundesland."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.public_holiday import PublicHoliday
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Osterformel (Gauß/Anonymous)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _easter(year: int) -> date:
|
||||||
|
"""Berechnet Ostersonntag nach der anonymen Gregorianischen Osterformel."""
|
||||||
|
a = year % 19
|
||||||
|
b = year // 100
|
||||||
|
c = year % 100
|
||||||
|
d = b // 4
|
||||||
|
e = b % 4
|
||||||
|
f = (b + 8) // 25
|
||||||
|
g = (b - f + 1) // 3
|
||||||
|
h = (19 * a + b - d - g + 15) % 30
|
||||||
|
i = c // 4
|
||||||
|
k = c % 4
|
||||||
|
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||||
|
m = (a + 11 * h + 22 * l) // 451
|
||||||
|
month = (h + l - 7 * m + 114) // 31
|
||||||
|
day = ((h + l - 7 * m + 114) % 31) + 1
|
||||||
|
return date(year, month, day)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feiertage berechnen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _holidays_for_state(year: int, state: str) -> list[tuple[date, str, bool]]:
|
||||||
|
"""
|
||||||
|
Gibt Liste von (date, name, is_high_rate) für das Bundesland zurück.
|
||||||
|
is_high_rate = True → 150% Zuschlag nach §3b EStG
|
||||||
|
"""
|
||||||
|
easter = _easter(year)
|
||||||
|
holidays: list[tuple[date, str, bool]] = []
|
||||||
|
|
||||||
|
def add(d: date, name: str, high: bool = False) -> None:
|
||||||
|
holidays.append((d, name, high))
|
||||||
|
|
||||||
|
# ── Bundesweit gültige Feiertage ────────────────────────────────────────
|
||||||
|
add(date(year, 1, 1), "Neujahr")
|
||||||
|
add(easter - timedelta(days=2), "Karfreitag")
|
||||||
|
add(easter, "Ostersonntag")
|
||||||
|
add(easter + timedelta(days=1), "Ostermontag")
|
||||||
|
add(date(year, 5, 1), "Tag der Arbeit", high=True)
|
||||||
|
add(easter + timedelta(days=39), "Christi Himmelfahrt")
|
||||||
|
add(easter + timedelta(days=49), "Pfingstsonntag")
|
||||||
|
add(easter + timedelta(days=50), "Pfingstmontag")
|
||||||
|
add(date(year, 10, 3), "Tag der Deutschen Einheit")
|
||||||
|
add(date(year, 12, 25), "1. Weihnachtstag", high=True)
|
||||||
|
add(date(year, 12, 26), "2. Weihnachtstag", high=True)
|
||||||
|
|
||||||
|
# ── Heilige Drei Könige: BY, BW, ST ────────────────────────────────────
|
||||||
|
if state in ("BY", "BW", "ST"):
|
||||||
|
add(date(year, 1, 6), "Heilige Drei Könige")
|
||||||
|
|
||||||
|
# ── Frauentag: BE (ab 2019) ─────────────────────────────────────────────
|
||||||
|
if state == "BE" and year >= 2019:
|
||||||
|
add(date(year, 3, 8), "Internationaler Frauentag")
|
||||||
|
|
||||||
|
# ── Gründonnerstag: BY (nur Schulen) – nicht als gesetzlicher Feiertag
|
||||||
|
|
||||||
|
# ── Fronleichnam: BW, BY, HE, NW, RP, SL (+ Teile ST, TH) ─────────────
|
||||||
|
if state in ("BW", "BY", "HE", "NW", "RP", "SL"):
|
||||||
|
add(easter + timedelta(days=60), "Fronleichnam")
|
||||||
|
|
||||||
|
# ── Mariä Himmelfahrt: BY (kath. Gemeinden), SL ─────────────────────────
|
||||||
|
if state in ("BY", "SL"):
|
||||||
|
add(date(year, 8, 15), "Mariä Himmelfahrt")
|
||||||
|
|
||||||
|
# ── Weltkindertag: TH (ab 2019) ─────────────────────────────────────────
|
||||||
|
if state == "TH" and year >= 2019:
|
||||||
|
add(date(year, 9, 20), "Weltkindertag")
|
||||||
|
|
||||||
|
# ── Reformationstag: BB, HB, HH, MV, NI, SH, SN, ST, TH ───────────────
|
||||||
|
if state in ("BB", "HB", "HH", "MV", "NI", "SH", "SN", "ST", "TH"):
|
||||||
|
add(date(year, 10, 31), "Reformationstag")
|
||||||
|
|
||||||
|
# ── Allerheiligen: BW, BY, NW, RP, SL ───────────────────────────────────
|
||||||
|
if state in ("BW", "BY", "NW", "RP", "SL"):
|
||||||
|
add(date(year, 11, 1), "Allerheiligen")
|
||||||
|
|
||||||
|
# ── Buß- und Bettag: SN ──────────────────────────────────────────────────
|
||||||
|
if state == "SN":
|
||||||
|
# Mittwoch vor dem 23. November
|
||||||
|
nov23 = date(year, 11, 23)
|
||||||
|
bbt = nov23 - timedelta(days=(nov23.weekday() + 3) % 7 + 1)
|
||||||
|
if bbt.weekday() != 2:
|
||||||
|
# Fallback: letzter Mittwoch vor 23.11.
|
||||||
|
bbt = nov23 - timedelta(days=(nov23.weekday() - 2) % 7 + 7)
|
||||||
|
add(bbt, "Buß- und Bettag")
|
||||||
|
|
||||||
|
return holidays
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DB-Funktionen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def ensure_holidays_for_year(year: int, state: str, db: AsyncSession) -> int:
|
||||||
|
"""
|
||||||
|
Stellt sicher dass Feiertage für (year, state) in der DB vorhanden sind.
|
||||||
|
Löscht ggf. alte Einträge und schreibt neu.
|
||||||
|
Gibt Anzahl geschriebener Einträge zurück.
|
||||||
|
"""
|
||||||
|
# Löschen falls schon vorhanden (refresh)
|
||||||
|
await db.execute(
|
||||||
|
delete(PublicHoliday).where(
|
||||||
|
PublicHoliday.country == "DE",
|
||||||
|
PublicHoliday.state == state,
|
||||||
|
PublicHoliday.year == year,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
holidays = _holidays_for_state(year, state)
|
||||||
|
for d, name, high in holidays:
|
||||||
|
db.add(PublicHoliday(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
country="DE",
|
||||||
|
state=state,
|
||||||
|
date=d,
|
||||||
|
name=name,
|
||||||
|
year=year,
|
||||||
|
is_high_rate=high,
|
||||||
|
))
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
return len(holidays)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_holidays_set(
|
||||||
|
date_from: date,
|
||||||
|
date_to: date,
|
||||||
|
state: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> dict[date, tuple[str, bool]]:
|
||||||
|
"""
|
||||||
|
Gibt dict {date: (name, is_high_rate)} für den Zeitraum zurück.
|
||||||
|
Befüllt fehlende Jahre automatisch.
|
||||||
|
"""
|
||||||
|
years = set(range(date_from.year, date_to.year + 1))
|
||||||
|
|
||||||
|
# Prüfen welche Jahre schon in der DB sind
|
||||||
|
existing_years_q = await db.execute(
|
||||||
|
select(PublicHoliday.year).where(
|
||||||
|
PublicHoliday.country == "DE",
|
||||||
|
PublicHoliday.state == state,
|
||||||
|
).distinct()
|
||||||
|
)
|
||||||
|
existing_years = {r[0] for r in existing_years_q.all()}
|
||||||
|
|
||||||
|
for year in years - existing_years:
|
||||||
|
await ensure_holidays_for_year(year, state, db)
|
||||||
|
|
||||||
|
result_q = await db.execute(
|
||||||
|
select(PublicHoliday).where(
|
||||||
|
PublicHoliday.country == "DE",
|
||||||
|
PublicHoliday.state == state,
|
||||||
|
PublicHoliday.date >= date_from,
|
||||||
|
PublicHoliday.date <= date_to,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {h.date: (h.name, h.is_high_rate) for h in result_q.scalars().all()}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
"""Kimai CSV Import Service – parst Kimai-Export und erzeugt TimeEntries + Absences."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import date, datetime, time, timedelta
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.absence import Absence, AbsenceStatus
|
||||||
|
from app.models.absence_type import AbsenceType
|
||||||
|
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Datenstrukturen für die Vorschau
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class KimaiRow:
|
||||||
|
date: date
|
||||||
|
start: time
|
||||||
|
end: time
|
||||||
|
duration_sec: int
|
||||||
|
projekt: str
|
||||||
|
taetigkeit: str
|
||||||
|
beschreibung: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportPreviewEntry:
|
||||||
|
kind: str # "time" | "absence"
|
||||||
|
date_from: str
|
||||||
|
date_to: str
|
||||||
|
start: str | None
|
||||||
|
end: str | None
|
||||||
|
break_minutes: int
|
||||||
|
worked_hours: float | None
|
||||||
|
absence_type: str | None
|
||||||
|
note: str | None
|
||||||
|
skipped: bool = False
|
||||||
|
skip_reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportResult:
|
||||||
|
preview: list[ImportPreviewEntry] = field(default_factory=list)
|
||||||
|
time_imported: int = 0
|
||||||
|
absence_imported: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
errors: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hilfsfunktionen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _parse_time(s: str) -> time:
|
||||||
|
parts = s.strip().split(":")
|
||||||
|
return time(int(parts[0]), int(parts[1]))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(s: str) -> date:
|
||||||
|
return datetime.strptime(s.strip(), "%Y-%m-%d").date()
|
||||||
|
|
||||||
|
|
||||||
|
def _gross_minutes(start: time, end: time) -> int:
|
||||||
|
return (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute)
|
||||||
|
|
||||||
|
|
||||||
|
def _break_minutes(row: KimaiRow) -> int:
|
||||||
|
gross = _gross_minutes(row.start, row.end)
|
||||||
|
net = row.duration_sec // 60
|
||||||
|
return max(0, gross - net)
|
||||||
|
|
||||||
|
|
||||||
|
def _worked_hours(row: KimaiRow) -> float:
|
||||||
|
net_min = row.duration_sec / 60
|
||||||
|
return round(net_min / 60, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_vacation_row(row: KimaiRow) -> bool:
|
||||||
|
return row.projekt.strip().lower() == "urlaub"
|
||||||
|
|
||||||
|
|
||||||
|
def _note(row: KimaiRow) -> str | None:
|
||||||
|
"""
|
||||||
|
Notiz aus Beschreibung; falls leer, Tätigkeit – außer 'Reguläre Arbeitszeit'
|
||||||
|
(das ist der Standard und braucht keine eigene Notiz).
|
||||||
|
"""
|
||||||
|
desc = row.beschreibung.strip()
|
||||||
|
if desc:
|
||||||
|
return desc
|
||||||
|
taet = row.taetigkeit.strip()
|
||||||
|
if taet and taet.lower() != "reguläre arbeitszeit":
|
||||||
|
return taet
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _absence_type_name(row: KimaiRow) -> str:
|
||||||
|
"""Ermittelt Abwesenheitstyp aus Beschreibung."""
|
||||||
|
desc = row.beschreibung.strip().lower()
|
||||||
|
if "sonderurlaub" in desc:
|
||||||
|
return "Sonderurlaub"
|
||||||
|
return "Urlaub"
|
||||||
|
|
||||||
|
|
||||||
|
def _group_vacation_rows(rows: list[KimaiRow]) -> list[tuple[date, date, str, str]]:
|
||||||
|
"""
|
||||||
|
Gruppiert aufeinanderfolgende Urlaubszeilen (gleicher Typ) zu Abwesenheitsblöcken.
|
||||||
|
Gibt Liste von (start_date, end_date, abs_type_name, note) zurück.
|
||||||
|
"""
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows_sorted = sorted(rows, key=lambda r: r.date)
|
||||||
|
groups: list[tuple[date, date, str, str]] = []
|
||||||
|
|
||||||
|
cur_start = rows_sorted[0].date
|
||||||
|
cur_end = rows_sorted[0].date
|
||||||
|
cur_type = _absence_type_name(rows_sorted[0])
|
||||||
|
cur_note = rows_sorted[0].beschreibung.strip()
|
||||||
|
|
||||||
|
for row in rows_sorted[1:]:
|
||||||
|
t = _absence_type_name(row)
|
||||||
|
# Aufeinanderfolgend = max. 3 Tage Abstand (Wochenende überbrücken)
|
||||||
|
gap = (row.date - cur_end).days
|
||||||
|
if t == cur_type and gap <= 3:
|
||||||
|
cur_end = row.date
|
||||||
|
if row.beschreibung.strip() and row.beschreibung.strip() not in cur_note:
|
||||||
|
cur_note = (cur_note + " / " + row.beschreibung.strip()).strip(" /")
|
||||||
|
else:
|
||||||
|
groups.append((cur_start, cur_end, cur_type, cur_note))
|
||||||
|
cur_start = row.date
|
||||||
|
cur_end = row.date
|
||||||
|
cur_type = t
|
||||||
|
cur_note = row.beschreibung.strip()
|
||||||
|
|
||||||
|
groups.append((cur_start, cur_end, cur_type, cur_note))
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSV-Parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def parse_kimai_csv(content: bytes) -> tuple[list[KimaiRow], list[str]]:
|
||||||
|
"""Parst Kimai-CSV-Bytes, gibt (rows, errors) zurück."""
|
||||||
|
rows: list[KimaiRow] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
text = content.decode("utf-8-sig") # BOM-safe
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
|
||||||
|
for i, row in enumerate(reader, start=2):
|
||||||
|
try:
|
||||||
|
rows.append(KimaiRow(
|
||||||
|
date=_parse_date(row["Datum"]),
|
||||||
|
start=_parse_time(row["Von"]),
|
||||||
|
end=_parse_time(row["Bis"]),
|
||||||
|
duration_sec=int(row["Dauer"]),
|
||||||
|
projekt=row.get("Projekt", ""),
|
||||||
|
taetigkeit=row.get("Tätigkeit", ""),
|
||||||
|
beschreibung=row.get("Beschreibung", ""),
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
errors.append(f"Zeile {i}: {e}")
|
||||||
|
|
||||||
|
return rows, errors
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preview (keine DB-Änderungen)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def preview_kimai_import(
|
||||||
|
content: bytes,
|
||||||
|
target_user_id: uuid.UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> ImportResult:
|
||||||
|
result = ImportResult()
|
||||||
|
rows, parse_errors = parse_kimai_csv(content)
|
||||||
|
result.errors.extend(parse_errors)
|
||||||
|
|
||||||
|
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
|
||||||
|
existing_q = await db.execute(
|
||||||
|
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
|
||||||
|
.where(TimeEntry.user_id == target_user_id)
|
||||||
|
)
|
||||||
|
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
|
||||||
|
|
||||||
|
# Abwesenheitstypen laden
|
||||||
|
types_q = await db.execute(select(AbsenceType))
|
||||||
|
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
|
||||||
|
|
||||||
|
time_rows = [r for r in rows if not _is_vacation_row(r)]
|
||||||
|
vac_rows = [r for r in rows if _is_vacation_row(r)]
|
||||||
|
|
||||||
|
# Zeiteinträge
|
||||||
|
seen_slots: set[tuple] = set()
|
||||||
|
for row in time_rows:
|
||||||
|
slot = (row.date, row.start, row.end)
|
||||||
|
skip = slot in existing_slots or slot in seen_slots
|
||||||
|
if not skip:
|
||||||
|
seen_slots.add(slot)
|
||||||
|
brk = _break_minutes(row)
|
||||||
|
result.preview.append(ImportPreviewEntry(
|
||||||
|
kind="time",
|
||||||
|
date_from=row.date.isoformat(),
|
||||||
|
date_to=row.date.isoformat(),
|
||||||
|
start=row.start.strftime("%H:%M"),
|
||||||
|
end=row.end.strftime("%H:%M"),
|
||||||
|
break_minutes=brk,
|
||||||
|
worked_hours=_worked_hours(row),
|
||||||
|
absence_type=None,
|
||||||
|
note=_note(row),
|
||||||
|
skipped=skip,
|
||||||
|
skip_reason="Bereits vorhanden (gleiche Zeit)" if skip else None,
|
||||||
|
))
|
||||||
|
|
||||||
|
# Bestehende Abwesenheiten für Duplikat-Prüfung
|
||||||
|
existing_abs_q = await db.execute(
|
||||||
|
select(AbsenceType.id).where(AbsenceType.id.in_([t.id for t in abs_types.values()]))
|
||||||
|
)
|
||||||
|
from app.models.absence import Absence as AbsenceModel
|
||||||
|
existing_abs_q2 = await db.execute(
|
||||||
|
select(AbsenceModel.start_date, AbsenceModel.end_date, AbsenceModel.type_id)
|
||||||
|
.where(AbsenceModel.user_id == target_user_id)
|
||||||
|
)
|
||||||
|
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q2}
|
||||||
|
|
||||||
|
# Urlaubsblöcke
|
||||||
|
for start, end, type_name, note in _group_vacation_rows(vac_rows):
|
||||||
|
t = abs_types.get(type_name)
|
||||||
|
already_exists = t is not None and (start, end, t.id) in existing_absences
|
||||||
|
skip = t is None or already_exists
|
||||||
|
result.preview.append(ImportPreviewEntry(
|
||||||
|
kind="absence",
|
||||||
|
date_from=start.isoformat(),
|
||||||
|
date_to=end.isoformat(),
|
||||||
|
start=None,
|
||||||
|
end=None,
|
||||||
|
break_minutes=0,
|
||||||
|
worked_hours=None,
|
||||||
|
absence_type=type_name,
|
||||||
|
note=note or None,
|
||||||
|
skipped=skip,
|
||||||
|
skip_reason=(
|
||||||
|
f"Abwesenheitstyp '{type_name}' nicht gefunden" if t is None
|
||||||
|
else "Bereits vorhanden" if already_exists
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
))
|
||||||
|
|
||||||
|
result.skipped = sum(1 for p in result.preview if p.skipped)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Eigentlicher Import (mit DB-Änderungen)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def run_kimai_import(
|
||||||
|
content: bytes,
|
||||||
|
target_user_id: uuid.UUID,
|
||||||
|
approver_id: uuid.UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> ImportResult:
|
||||||
|
result = ImportResult()
|
||||||
|
rows, parse_errors = parse_kimai_csv(content)
|
||||||
|
result.errors.extend(parse_errors)
|
||||||
|
|
||||||
|
# User + Company laden
|
||||||
|
user_q = await db.execute(select(User).where(User.id == target_user_id))
|
||||||
|
user = user_q.scalar_one_or_none()
|
||||||
|
if not user:
|
||||||
|
result.errors.append("Ziel-User nicht gefunden.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Bestehende Zeiteinträge: Duplikat-Prüfung auf (date, start_time, end_time)
|
||||||
|
existing_q = await db.execute(
|
||||||
|
select(TimeEntry.date, TimeEntry.start_time, TimeEntry.end_time)
|
||||||
|
.where(TimeEntry.user_id == target_user_id)
|
||||||
|
)
|
||||||
|
existing_slots: set[tuple] = {(r.date, r.start_time, r.end_time) for r in existing_q}
|
||||||
|
|
||||||
|
# Abwesenheitstypen
|
||||||
|
types_q = await db.execute(
|
||||||
|
select(AbsenceType).where(AbsenceType.company_id == user.company_id)
|
||||||
|
)
|
||||||
|
abs_types: dict[str, AbsenceType] = {t.name: t for t in types_q.scalars().all()}
|
||||||
|
|
||||||
|
time_rows = [r for r in rows if not _is_vacation_row(r)]
|
||||||
|
vac_rows = [r for r in rows if _is_vacation_row(r)]
|
||||||
|
|
||||||
|
# ---- Zeiteinträge ----
|
||||||
|
seen_slots: set[tuple] = set()
|
||||||
|
for row in time_rows:
|
||||||
|
slot = (row.date, row.start, row.end)
|
||||||
|
if slot in existing_slots or slot in seen_slots:
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
seen_slots.add(slot)
|
||||||
|
brk = _break_minutes(row)
|
||||||
|
entry = TimeEntry(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=target_user_id,
|
||||||
|
date=row.date,
|
||||||
|
start_time=row.start,
|
||||||
|
end_time=row.end,
|
||||||
|
break_minutes=brk,
|
||||||
|
status=EntryStatus.APPROVED,
|
||||||
|
source=EntrySource.API,
|
||||||
|
approved_by=approver_id,
|
||||||
|
note=_note(row),
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
result.time_imported += 1
|
||||||
|
|
||||||
|
# Bestehende Abwesenheiten für Duplikat-Prüfung
|
||||||
|
existing_abs_q = await db.execute(
|
||||||
|
select(Absence.start_date, Absence.end_date, Absence.type_id)
|
||||||
|
.where(Absence.user_id == target_user_id)
|
||||||
|
)
|
||||||
|
existing_absences: set[tuple] = {(r.start_date, r.end_date, r.type_id) for r in existing_abs_q}
|
||||||
|
|
||||||
|
# ---- Urlaubsblöcke ----
|
||||||
|
for start, end, type_name, note in _group_vacation_rows(vac_rows):
|
||||||
|
t = abs_types.get(type_name)
|
||||||
|
if not t:
|
||||||
|
result.errors.append(f"Abwesenheitstyp '{type_name}' nicht gefunden – übersprungen.")
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if (start, end, t.id) in existing_absences:
|
||||||
|
result.skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Arbeitstage zählen (Mo–Fr, keine Feiertage)
|
||||||
|
working_days = sum(
|
||||||
|
1 for n in range((end - start).days + 1)
|
||||||
|
if (start + timedelta(days=n)).weekday() < 5
|
||||||
|
)
|
||||||
|
|
||||||
|
absence = Absence(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
user_id=target_user_id,
|
||||||
|
type_id=t.id,
|
||||||
|
start_date=start,
|
||||||
|
end_date=end,
|
||||||
|
working_days=working_days,
|
||||||
|
status=AbsenceStatus.APPROVED,
|
||||||
|
approved_by=approver_id,
|
||||||
|
note=note or None,
|
||||||
|
)
|
||||||
|
db.add(absence)
|
||||||
|
result.absence_imported += 1
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import hash_token
|
||||||
|
from app.models.kiosk_device import KioskDevice
|
||||||
|
from app.schemas.kiosk import KioskDeviceCreate, KioskDeviceUpdate
|
||||||
|
|
||||||
|
|
||||||
|
class KioskService:
|
||||||
|
|
||||||
|
async def list_devices(self, company_id: UUID, db: AsyncSession) -> list[KioskDevice]:
|
||||||
|
result = await db.scalars(
|
||||||
|
select(KioskDevice)
|
||||||
|
.where(KioskDevice.company_id == company_id)
|
||||||
|
.order_by(KioskDevice.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
async def create_device(
|
||||||
|
self, company_id: UUID, data: KioskDeviceCreate, db: AsyncSession
|
||||||
|
) -> tuple[KioskDevice, str]:
|
||||||
|
"""Gerät anlegen. Gibt (device, raw_token) zurück – raw_token nur einmalig."""
|
||||||
|
raw_token = secrets.token_urlsafe(48)
|
||||||
|
device = KioskDevice(
|
||||||
|
company_id=company_id,
|
||||||
|
name=data.name,
|
||||||
|
location=data.location,
|
||||||
|
token_hash=hash_token(raw_token),
|
||||||
|
)
|
||||||
|
db.add(device)
|
||||||
|
await db.flush()
|
||||||
|
return device, raw_token
|
||||||
|
|
||||||
|
async def get_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> KioskDevice:
|
||||||
|
device = await db.scalar(
|
||||||
|
select(KioskDevice).where(
|
||||||
|
KioskDevice.id == device_id,
|
||||||
|
KioskDevice.company_id == company_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if device is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Gerät nicht gefunden.")
|
||||||
|
return device
|
||||||
|
|
||||||
|
async def update_device(
|
||||||
|
self, device_id: UUID, company_id: UUID, data: KioskDeviceUpdate, db: AsyncSession
|
||||||
|
) -> KioskDevice:
|
||||||
|
device = await self.get_device(device_id, company_id, db)
|
||||||
|
changes = data.model_dump(exclude_none=True)
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(device, field, value)
|
||||||
|
return device
|
||||||
|
|
||||||
|
async def rotate_token(
|
||||||
|
self, device_id: UUID, company_id: UUID, db: AsyncSession
|
||||||
|
) -> tuple[KioskDevice, str]:
|
||||||
|
"""Token rotieren – altes Token wird sofort ungültig."""
|
||||||
|
device = await self.get_device(device_id, company_id, db)
|
||||||
|
raw_token = secrets.token_urlsafe(48)
|
||||||
|
device.token_hash = hash_token(raw_token)
|
||||||
|
return device, raw_token
|
||||||
|
|
||||||
|
async def delete_device(self, device_id: UUID, company_id: UUID, db: AsyncSession) -> None:
|
||||||
|
device = await self.get_device(device_id, company_id, db)
|
||||||
|
await db.delete(device)
|
||||||
|
|
||||||
|
async def authenticate_device(self, raw_token: str, db: AsyncSession) -> KioskDevice:
|
||||||
|
"""Gerät per Token authentifizieren (für Kiosk-Endpoints)."""
|
||||||
|
token_hash = hash_token(raw_token)
|
||||||
|
device = await db.scalar(
|
||||||
|
select(KioskDevice).where(
|
||||||
|
KioskDevice.token_hash == token_hash,
|
||||||
|
KioskDevice.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if device is None:
|
||||||
|
raise HTTPException(status_code=401, detail="Ungültiges oder deaktiviertes Gerät.")
|
||||||
|
device.last_seen_at = datetime.now(timezone.utc)
|
||||||
|
return device
|
||||||
|
|
||||||
|
|
||||||
|
kiosk_service = KioskService()
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
"""LDAP integration service.
|
||||||
|
|
||||||
|
Supports ActiveDirectory and OpenLDAP via ldap3 (pure Python).
|
||||||
|
Bind passwords are stored Fernet-encrypted using the app SECRET_KEY.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.ldap_config import LdapConfig
|
||||||
|
from app.models.user import AuthProvider, User, UserRole
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet():
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
key = hashlib.sha256(settings.secret_key.encode()).digest()
|
||||||
|
return Fernet(base64.urlsafe_b64encode(key))
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_password(plain: str) -> str:
|
||||||
|
return _fernet().encrypt(plain.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_password(encrypted: str) -> str:
|
||||||
|
return _fernet().decrypt(encrypted.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ConnectionResult:
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SyncResult:
|
||||||
|
created: int
|
||||||
|
updated: int
|
||||||
|
deactivated: int
|
||||||
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_attr(entry_attrs: dict, attr_name: str) -> str:
|
||||||
|
"""Safely extract a string attribute value from ldap3 entry attributes."""
|
||||||
|
val = entry_attrs.get(attr_name)
|
||||||
|
if not val:
|
||||||
|
return ""
|
||||||
|
if isinstance(val, list):
|
||||||
|
return str(val[0]) if val else ""
|
||||||
|
return str(val)
|
||||||
|
|
||||||
|
|
||||||
|
class LdapService:
|
||||||
|
|
||||||
|
def _build_server(self, config: LdapConfig):
|
||||||
|
from ldap3 import Server, Tls
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
tls = None
|
||||||
|
if config.use_tls:
|
||||||
|
if config.tls_verify:
|
||||||
|
tls = Tls(validate=ssl.CERT_REQUIRED)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"LDAP TLS certificate validation is DISABLED for host %s (company_id=%s). "
|
||||||
|
"Set tls_verify=True in production to prevent MITM attacks.",
|
||||||
|
config.host,
|
||||||
|
config.company_id,
|
||||||
|
)
|
||||||
|
tls = Tls(validate=ssl.CERT_NONE)
|
||||||
|
|
||||||
|
return Server(
|
||||||
|
config.host,
|
||||||
|
port=config.port,
|
||||||
|
use_ssl=config.use_ssl,
|
||||||
|
tls=tls,
|
||||||
|
get_info="ALL",
|
||||||
|
connect_timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _bind_connection(self, config: LdapConfig, dn: str | None = None, password: str | None = None):
|
||||||
|
"""Return an authenticated ldap3 Connection (simple bind)."""
|
||||||
|
from ldap3 import Connection, SIMPLE, SYNC
|
||||||
|
|
||||||
|
bind_dn = dn or config.bind_dn
|
||||||
|
bind_pw = password or decrypt_password(config.bind_password_encrypted)
|
||||||
|
server = self._build_server(config)
|
||||||
|
conn = Connection(
|
||||||
|
server,
|
||||||
|
user=bind_dn,
|
||||||
|
password=bind_pw,
|
||||||
|
authentication=SIMPLE,
|
||||||
|
client_strategy=SYNC,
|
||||||
|
auto_bind="NO_TLS",
|
||||||
|
raise_exceptions=False,
|
||||||
|
)
|
||||||
|
if not conn.bind():
|
||||||
|
return None
|
||||||
|
return conn
|
||||||
|
|
||||||
|
# ── Public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_config(self, company_id: uuid.UUID, db: AsyncSession) -> LdapConfig | None:
|
||||||
|
return await db.scalar(
|
||||||
|
select(LdapConfig).where(LdapConfig.company_id == company_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_config_or_404(self, company_id: uuid.UUID, db: AsyncSession) -> LdapConfig:
|
||||||
|
cfg = await self.get_config(company_id, db)
|
||||||
|
if not cfg:
|
||||||
|
raise HTTPException(status_code=404, detail="LDAP configuration not found")
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
def test_connection(self, config: LdapConfig) -> ConnectionResult:
|
||||||
|
try:
|
||||||
|
conn = self._bind_connection(config)
|
||||||
|
if conn is None:
|
||||||
|
return ConnectionResult(success=False, message="Bind fehlgeschlagen – DN oder Passwort falsch")
|
||||||
|
conn.unbind()
|
||||||
|
return ConnectionResult(success=True, message="Verbindung erfolgreich")
|
||||||
|
except Exception as exc:
|
||||||
|
return ConnectionResult(success=False, message=str(exc))
|
||||||
|
|
||||||
|
def search_users(self, config: LdapConfig) -> list[dict[str, Any]]:
|
||||||
|
"""Return raw list of user dicts from LDAP directory."""
|
||||||
|
from ldap3 import SUBTREE
|
||||||
|
|
||||||
|
conn = self._bind_connection(config)
|
||||||
|
if conn is None:
|
||||||
|
raise HTTPException(status_code=502, detail="LDAP bind fehlgeschlagen")
|
||||||
|
|
||||||
|
attrs = [
|
||||||
|
config.attr_email,
|
||||||
|
config.attr_firstname,
|
||||||
|
config.attr_lastname,
|
||||||
|
config.attr_username,
|
||||||
|
]
|
||||||
|
if config.attr_department:
|
||||||
|
attrs.append(config.attr_department)
|
||||||
|
|
||||||
|
conn.search(
|
||||||
|
search_base=config.base_dn,
|
||||||
|
search_filter=config.user_search_filter,
|
||||||
|
search_scope=SUBTREE,
|
||||||
|
attributes=attrs,
|
||||||
|
)
|
||||||
|
results = []
|
||||||
|
for entry in conn.entries:
|
||||||
|
raw = {a: entry[a].value for a in attrs if a in entry}
|
||||||
|
raw["dn"] = entry.entry_dn
|
||||||
|
results.append(raw)
|
||||||
|
conn.unbind()
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def sync_users(
|
||||||
|
self,
|
||||||
|
config: LdapConfig,
|
||||||
|
db: AsyncSession,
|
||||||
|
default_role: UserRole = UserRole.EMPLOYEE,
|
||||||
|
) -> SyncResult:
|
||||||
|
"""Sync LDAP users into the local database."""
|
||||||
|
from ldap3 import SUBTREE
|
||||||
|
|
||||||
|
conn = self._bind_connection(config)
|
||||||
|
if conn is None:
|
||||||
|
raise HTTPException(status_code=502, detail="LDAP bind fehlgeschlagen")
|
||||||
|
|
||||||
|
attrs = [
|
||||||
|
config.attr_email,
|
||||||
|
config.attr_firstname,
|
||||||
|
config.attr_lastname,
|
||||||
|
]
|
||||||
|
if config.attr_department:
|
||||||
|
attrs.append(config.attr_department)
|
||||||
|
if config.attr_personnel_number:
|
||||||
|
attrs.append(config.attr_personnel_number)
|
||||||
|
|
||||||
|
conn.search(
|
||||||
|
search_base=config.base_dn,
|
||||||
|
search_filter=config.user_search_filter,
|
||||||
|
search_scope=SUBTREE,
|
||||||
|
attributes=attrs,
|
||||||
|
)
|
||||||
|
entries = list(conn.entries)
|
||||||
|
conn.unbind()
|
||||||
|
|
||||||
|
result = SyncResult(created=0, updated=0, deactivated=0, errors=[])
|
||||||
|
ldap_emails: set[str] = set()
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
email = _get_attr(entry, config.attr_email).lower().strip()
|
||||||
|
if not email:
|
||||||
|
continue
|
||||||
|
ldap_emails.add(email)
|
||||||
|
|
||||||
|
first = _get_attr(entry, config.attr_firstname) or "?"
|
||||||
|
last = _get_attr(entry, config.attr_lastname) or "?"
|
||||||
|
dn = entry.entry_dn
|
||||||
|
|
||||||
|
ldap_personnel = (
|
||||||
|
_get_attr(entry, config.attr_personnel_number).strip()
|
||||||
|
if config.attr_personnel_number else ""
|
||||||
|
)
|
||||||
|
# nur Ziffern akzeptieren (Format-Vorgabe)
|
||||||
|
if ldap_personnel and not ldap_personnel.isdigit():
|
||||||
|
logger.warning(
|
||||||
|
"LDAP personnel_number for %s contains non-digits (%r), skipping mapping.",
|
||||||
|
email, ldap_personnel,
|
||||||
|
)
|
||||||
|
ldap_personnel = ""
|
||||||
|
|
||||||
|
existing = await db.scalar(select(User).where(User.email == email))
|
||||||
|
if existing:
|
||||||
|
existing.first_name = first
|
||||||
|
existing.last_name = last
|
||||||
|
existing.ldap_dn = dn
|
||||||
|
existing.auth_provider = AuthProvider.LDAP
|
||||||
|
existing.is_active = True
|
||||||
|
if ldap_personnel and existing.personnel_number != ldap_personnel:
|
||||||
|
await self._apply_personnel_from_ldap(
|
||||||
|
existing, ldap_personnel, db, result,
|
||||||
|
)
|
||||||
|
result.updated += 1
|
||||||
|
else:
|
||||||
|
user = User(
|
||||||
|
company_id=config.company_id,
|
||||||
|
email=email,
|
||||||
|
first_name=first,
|
||||||
|
last_name=last,
|
||||||
|
password_hash=None,
|
||||||
|
auth_provider=AuthProvider.LDAP,
|
||||||
|
ldap_dn=dn,
|
||||||
|
role=default_role,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
if ldap_personnel:
|
||||||
|
await self._apply_personnel_from_ldap(
|
||||||
|
user, ldap_personnel, db, result, company_id=config.company_id,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
result.created += 1
|
||||||
|
except Exception as exc:
|
||||||
|
result.errors.append(str(exc))
|
||||||
|
|
||||||
|
# Deactivate LDAP users no longer in directory
|
||||||
|
existing_ldap = await db.scalars(
|
||||||
|
select(User).where(
|
||||||
|
User.company_id == config.company_id,
|
||||||
|
User.auth_provider == AuthProvider.LDAP,
|
||||||
|
User.is_active.is_(True),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for user in existing_ldap:
|
||||||
|
if user.email not in ldap_emails:
|
||||||
|
user.is_active = False
|
||||||
|
result.deactivated += 1
|
||||||
|
|
||||||
|
config.last_sync_at = datetime.now(timezone.utc)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _apply_personnel_from_ldap(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
ldap_value: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
result: SyncResult,
|
||||||
|
company_id: uuid.UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Apply personnel_number from LDAP, but skip on conflict (no override of reserved numbers)."""
|
||||||
|
cid = company_id or user.company_id
|
||||||
|
# Konflikt mit anderem User in derselben Firma?
|
||||||
|
conflict = await db.scalar(
|
||||||
|
select(User.id).where(
|
||||||
|
User.company_id == cid,
|
||||||
|
User.personnel_number == ldap_value,
|
||||||
|
User.id != user.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if conflict is not None:
|
||||||
|
msg = (
|
||||||
|
f"LDAP-Personalnummer {ldap_value!r} für {user.email} kollidiert mit "
|
||||||
|
f"vergebener Nummer – Wert verworfen."
|
||||||
|
)
|
||||||
|
logger.warning(msg)
|
||||||
|
result.errors.append(msg)
|
||||||
|
return
|
||||||
|
user.personnel_number = ldap_value
|
||||||
|
|
||||||
|
def authenticate_ldap(self, config: LdapConfig, email: str, password: str) -> bool:
|
||||||
|
"""Authenticate a user by finding their DN and attempting a bind."""
|
||||||
|
from ldap3 import SUBTREE
|
||||||
|
from ldap3.utils.conv import escape_filter_chars
|
||||||
|
|
||||||
|
conn = self._bind_connection(config)
|
||||||
|
if conn is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
safe_email = escape_filter_chars(email)
|
||||||
|
conn.search(
|
||||||
|
search_base=config.base_dn,
|
||||||
|
search_filter=f"({config.attr_email}={safe_email})",
|
||||||
|
search_scope=SUBTREE,
|
||||||
|
attributes=[config.attr_email],
|
||||||
|
)
|
||||||
|
if not conn.entries:
|
||||||
|
conn.unbind()
|
||||||
|
return False
|
||||||
|
|
||||||
|
user_dn = conn.entries[0].entry_dn
|
||||||
|
conn.unbind()
|
||||||
|
|
||||||
|
# Try binding as the found user
|
||||||
|
user_conn = self._bind_connection(config, dn=user_dn, password=password)
|
||||||
|
if user_conn is None:
|
||||||
|
return False
|
||||||
|
user_conn.unbind()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
ldap_service = LdapService()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,524 @@
|
|||||||
|
from datetime import date, datetime, time, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.time_entry import EntrySource, EntryStatus, TimeEntry
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.models.work_schedule import WorkSchedule
|
||||||
|
from app.schemas.time_entry import (
|
||||||
|
BalanceResponse,
|
||||||
|
ManualEntryCreate,
|
||||||
|
StampInRequest,
|
||||||
|
TimeEntryUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_arbzg(start: time, end: time, break_minutes: int) -> list[str]:
|
||||||
|
"""ArbZG §3 und §4 Prüfung. Gibt Warnungen zurück, blockiert nicht."""
|
||||||
|
start_mins = start.hour * 60 + start.minute
|
||||||
|
end_mins = end.hour * 60 + end.minute
|
||||||
|
if end_mins <= start_mins:
|
||||||
|
end_mins += 24 * 60 # Nachtschicht
|
||||||
|
total_mins = end_mins - start_mins
|
||||||
|
worked_mins = total_mins - break_minutes
|
||||||
|
worked_hours = worked_mins / 60
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if worked_hours > 10:
|
||||||
|
warnings.append(
|
||||||
|
f"Maximale Arbeitszeit von 10 Stunden überschritten "
|
||||||
|
f"({worked_hours:.1f}h gearbeitet) – ArbZG §3"
|
||||||
|
)
|
||||||
|
if total_mins >= 9 * 60 and break_minutes < 45:
|
||||||
|
warnings.append(
|
||||||
|
"Bei mehr als 9h Anwesenheit sind mind. 45 min Pause vorgeschrieben – ArbZG §4"
|
||||||
|
)
|
||||||
|
elif total_mins >= 6 * 60 and break_minutes < 30:
|
||||||
|
warnings.append(
|
||||||
|
"Bei mehr als 6h Anwesenheit sind mind. 30 min Pause vorgeschrieben – ArbZG §4"
|
||||||
|
)
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rest_period(prev_end: time | None, prev_date: date | None,
|
||||||
|
new_start: time, new_date: date) -> list[str]:
|
||||||
|
"""Mindestruhezeit 11h zwischen Schichten – ArbZG §5.
|
||||||
|
Nur relevant bei Schichtwechsel über Tagesgrenzen, nicht bei mehrfachen
|
||||||
|
Stempelungen am gleichen Tag (z.B. Korrektur oder Pause).
|
||||||
|
"""
|
||||||
|
if prev_end is None or prev_date is None:
|
||||||
|
return []
|
||||||
|
# Gleicher Tag → kein Schichtwechsel, §5 nicht anwendbar
|
||||||
|
if prev_date == new_date:
|
||||||
|
return []
|
||||||
|
prev_end_dt = datetime.combine(prev_date, prev_end, tzinfo=None)
|
||||||
|
new_start_dt = datetime.combine(new_date, new_start, tzinfo=None)
|
||||||
|
rest_hours = (new_start_dt - prev_end_dt).total_seconds() / 3600
|
||||||
|
# Nur warnen wenn tatsächlich weniger als 11h Ruhe zwischen zwei verschiedenen Tagen
|
||||||
|
if 0 < rest_hours < 11:
|
||||||
|
return [
|
||||||
|
f"Mindestruhezeit von 11h unterschritten "
|
||||||
|
f"({rest_hours:.1f}h seit letzter Schicht) – ArbZG §5"
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class TimeService:
|
||||||
|
|
||||||
|
# ── Stempeluhr ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def stamp_in(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
data: StampInRequest,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[TimeEntry, list[str]]:
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Offenen Eintrag für heute prüfen
|
||||||
|
open_entry = await self._get_open_entry(user.id, db)
|
||||||
|
if open_entry is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Bereits eingestempelt. Bitte zuerst ausstempeln.")
|
||||||
|
|
||||||
|
# Letzten abgeschlossenen Eintrag für Ruhezeit-Check holen
|
||||||
|
last_entry = await db.scalar(
|
||||||
|
select(TimeEntry)
|
||||||
|
.where(TimeEntry.user_id == user.id, TimeEntry.end_time.isnot(None))
|
||||||
|
.order_by(TimeEntry.date.desc(), TimeEntry.end_time.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
warnings = _check_rest_period(
|
||||||
|
last_entry.end_time if last_entry else None,
|
||||||
|
last_entry.date if last_entry else None,
|
||||||
|
now_time,
|
||||||
|
today,
|
||||||
|
)
|
||||||
|
|
||||||
|
entry = TimeEntry(
|
||||||
|
user_id=user.id,
|
||||||
|
date=today,
|
||||||
|
start_time=now_time,
|
||||||
|
break_minutes=0,
|
||||||
|
source=data.source,
|
||||||
|
project_id=data.project_id,
|
||||||
|
note=data.note,
|
||||||
|
status=EntryStatus.PENDING,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
await db.flush()
|
||||||
|
return entry, warnings
|
||||||
|
|
||||||
|
async def stamp_out(
|
||||||
|
self,
|
||||||
|
user: User,
|
||||||
|
note: str | None,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[TimeEntry, list[str]]:
|
||||||
|
entry = await self._get_open_entry(user.id, db)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
|
||||||
|
|
||||||
|
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Aktive Pause beenden falls vergessen
|
||||||
|
if entry.break_start is not None:
|
||||||
|
extra_break = self._calc_break_minutes(entry.break_start, now_time)
|
||||||
|
entry.break_minutes += extra_break
|
||||||
|
entry.break_start = None
|
||||||
|
|
||||||
|
entry.end_time = now_time
|
||||||
|
entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
if note:
|
||||||
|
entry.note = note
|
||||||
|
|
||||||
|
warnings = _check_arbzg(entry.start_time, entry.end_time, entry.break_minutes)
|
||||||
|
return entry, warnings
|
||||||
|
|
||||||
|
async def break_start(self, user: User, db: AsyncSession) -> TimeEntry:
|
||||||
|
entry = await self._get_open_entry(user.id, db)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
|
||||||
|
if entry.break_start is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="Pause bereits aktiv.")
|
||||||
|
|
||||||
|
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
|
||||||
|
entry.break_start = now_time
|
||||||
|
entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def break_end(self, user: User, db: AsyncSession) -> TimeEntry:
|
||||||
|
entry = await self._get_open_entry(user.id, db)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Kein offener Zeitstempel gefunden.")
|
||||||
|
if entry.break_start is None:
|
||||||
|
raise HTTPException(status_code=409, detail="Keine aktive Pause.")
|
||||||
|
|
||||||
|
now_time = datetime.now(timezone.utc).time().replace(tzinfo=None)
|
||||||
|
extra = self._calc_break_minutes(entry.break_start, now_time)
|
||||||
|
entry.break_minutes += extra
|
||||||
|
entry.break_start = None
|
||||||
|
entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
# ── Einträge ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def get_today(self, user: User, db: AsyncSession) -> list[TimeEntry]:
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
result = await db.scalars(
|
||||||
|
select(TimeEntry)
|
||||||
|
.where(TimeEntry.user_id == user.id, TimeEntry.date == today)
|
||||||
|
.order_by(TimeEntry.start_time)
|
||||||
|
)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
async def list_entries(
|
||||||
|
self,
|
||||||
|
company_id: UUID,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
user_id: UUID | None = None,
|
||||||
|
date_from: date | None = None,
|
||||||
|
date_to: date | None = None,
|
||||||
|
status: EntryStatus | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> tuple[int, list[TimeEntry]]:
|
||||||
|
# Basis: nur Einträge der eigenen Company
|
||||||
|
# Subquery: JOIN user für company_id Filter
|
||||||
|
q = (
|
||||||
|
select(TimeEntry)
|
||||||
|
.join(User, TimeEntry.user_id == User.id)
|
||||||
|
.where(User.company_id == company_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# EMPLOYEE sieht nur eigene Einträge
|
||||||
|
if current_user.role == UserRole.EMPLOYEE:
|
||||||
|
q = q.where(TimeEntry.user_id == current_user.id)
|
||||||
|
elif user_id:
|
||||||
|
q = q.where(TimeEntry.user_id == user_id)
|
||||||
|
|
||||||
|
if date_from:
|
||||||
|
q = q.where(TimeEntry.date >= date_from)
|
||||||
|
if date_to:
|
||||||
|
q = q.where(TimeEntry.date <= date_to)
|
||||||
|
if status:
|
||||||
|
q = q.where(TimeEntry.status == status)
|
||||||
|
|
||||||
|
total = await db.scalar(select(func.count()).select_from(q.subquery()))
|
||||||
|
entries = await db.scalars(q.order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).offset(skip).limit(limit))
|
||||||
|
return total or 0, list(entries.all())
|
||||||
|
|
||||||
|
async def create_manual(
|
||||||
|
self,
|
||||||
|
data: ManualEntryCreate,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> tuple[TimeEntry, list[str]]:
|
||||||
|
target_user_id = current_user.id
|
||||||
|
|
||||||
|
# Employees need explicit permission to create manual entries
|
||||||
|
_elevated = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
if current_user.role == UserRole.EMPLOYEE and not current_user.can_manual_time_entry:
|
||||||
|
raise HTTPException(status_code=403, detail="Manuelle Zeiterfassung ist für Ihr Konto nicht freigeschaltet.")
|
||||||
|
|
||||||
|
if data.user_id and data.user_id != current_user.id:
|
||||||
|
if current_user.role not in _elevated:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für andere Benutzer.")
|
||||||
|
target = await db.get(User, data.user_id)
|
||||||
|
if target is None or target.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Mitarbeiter nicht gefunden.")
|
||||||
|
target_user_id = data.user_id
|
||||||
|
|
||||||
|
entry = TimeEntry(
|
||||||
|
user_id=target_user_id,
|
||||||
|
date=data.date,
|
||||||
|
start_time=data.start_time,
|
||||||
|
end_time=data.end_time,
|
||||||
|
break_minutes=data.break_minutes,
|
||||||
|
project_id=data.project_id,
|
||||||
|
note=data.note,
|
||||||
|
source=data.source,
|
||||||
|
status=EntryStatus.PENDING,
|
||||||
|
)
|
||||||
|
db.add(entry)
|
||||||
|
await db.flush()
|
||||||
|
|
||||||
|
warnings = _check_arbzg(data.start_time, data.end_time, data.break_minutes)
|
||||||
|
return entry, warnings
|
||||||
|
|
||||||
|
async def update_entry(
|
||||||
|
self,
|
||||||
|
entry_id: UUID,
|
||||||
|
data: TimeEntryUpdate,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> TimeEntry:
|
||||||
|
_manager_roles = (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN)
|
||||||
|
entry = await self._get_entry_or_404(entry_id, db)
|
||||||
|
await self._assert_access(entry, current_user, db)
|
||||||
|
|
||||||
|
if entry.status == EntryStatus.APPROVED:
|
||||||
|
if current_user.role not in _manager_roles:
|
||||||
|
raise HTTPException(status_code=403, detail="Genehmigte Einträge können nur von Vorgesetzten geändert werden.")
|
||||||
|
if not data.correction_note:
|
||||||
|
raise HTTPException(status_code=422, detail="Änderungsgrund (correction_note) ist bei genehmigten Einträgen Pflicht.")
|
||||||
|
|
||||||
|
# Vorherigen Zustand für AuditLog sichern
|
||||||
|
old_snapshot = {
|
||||||
|
"started_at": entry.started_at.isoformat() if entry.started_at else None,
|
||||||
|
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
|
||||||
|
"break_minutes": entry.break_minutes,
|
||||||
|
"note": entry.note,
|
||||||
|
"correction_note": entry.correction_note,
|
||||||
|
}
|
||||||
|
|
||||||
|
changes = data.model_dump(exclude_none=True)
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(entry, field, value)
|
||||||
|
entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if entry.status == EntryStatus.APPROVED:
|
||||||
|
new_snapshot = {
|
||||||
|
"started_at": entry.started_at.isoformat() if entry.started_at else None,
|
||||||
|
"ended_at": entry.ended_at.isoformat() if entry.ended_at else None,
|
||||||
|
"break_minutes": entry.break_minutes,
|
||||||
|
"note": entry.note,
|
||||||
|
"correction_note": entry.correction_note,
|
||||||
|
}
|
||||||
|
user_obj = await db.get(User, entry.user_id)
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="time_entry_approved_edit",
|
||||||
|
entity_type="time_entry",
|
||||||
|
entity_id=entry.id,
|
||||||
|
old_value=old_snapshot,
|
||||||
|
new_value={**new_snapshot, "changed_by": str(current_user.id),
|
||||||
|
"target_user": str(entry.user_id),
|
||||||
|
"target_user_name": user_obj.full_name if user_obj else None},
|
||||||
|
))
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def approve_entry(
|
||||||
|
self,
|
||||||
|
entry_id: UUID,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> TimeEntry:
|
||||||
|
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Genehmigen.")
|
||||||
|
|
||||||
|
entry = await self._get_entry_or_404(entry_id, db)
|
||||||
|
|
||||||
|
# Cross-Tenant-Schutz
|
||||||
|
entry_user = await db.get(User, entry.user_id)
|
||||||
|
if entry_user is None or entry_user.company_id != current_user.company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||||
|
|
||||||
|
# Self-Approval-Schutz (L-03)
|
||||||
|
if entry.user_id == current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Eigene Zeiteinträge können nicht selbst genehmigt werden."
|
||||||
|
)
|
||||||
|
|
||||||
|
if entry.status != EntryStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können genehmigt werden.")
|
||||||
|
|
||||||
|
entry.status = EntryStatus.APPROVED
|
||||||
|
entry.approved_by = current_user.id
|
||||||
|
entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def reject_entry(
|
||||||
|
self,
|
||||||
|
entry_id: UUID,
|
||||||
|
current_user: User,
|
||||||
|
correction_note: str | None,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> TimeEntry:
|
||||||
|
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung zum Ablehnen.")
|
||||||
|
|
||||||
|
entry = await self._get_entry_or_404(entry_id, db)
|
||||||
|
if entry.status != EntryStatus.PENDING:
|
||||||
|
raise HTTPException(status_code=409, detail="Nur ausstehende Einträge können abgelehnt werden.")
|
||||||
|
|
||||||
|
entry.status = EntryStatus.REJECTED
|
||||||
|
entry.approved_by = current_user.id
|
||||||
|
if correction_note:
|
||||||
|
entry.correction_note = correction_note
|
||||||
|
entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def delete_entry(
|
||||||
|
self,
|
||||||
|
entry_id: UUID,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> None:
|
||||||
|
entry = await self._get_entry_or_404(entry_id, db)
|
||||||
|
await self._assert_access(entry, current_user, db)
|
||||||
|
|
||||||
|
# Genehmigte Einträge dürfen nur von HR/Admin gelöscht werden
|
||||||
|
if entry.status == EntryStatus.APPROVED:
|
||||||
|
if current_user.role not in (UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Genehmigte Einträge können nur von Vorgesetzten gelöscht werden."
|
||||||
|
)
|
||||||
|
|
||||||
|
await db.delete(entry)
|
||||||
|
|
||||||
|
async def get_balance(
|
||||||
|
self,
|
||||||
|
user_id: UUID,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
period_start: date | None = None,
|
||||||
|
period_end: date | None = None,
|
||||||
|
) -> BalanceResponse:
|
||||||
|
# Zugriff prüfen
|
||||||
|
if user_id != current_user.id and current_user.role == UserRole.EMPLOYEE:
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
|
||||||
|
today = datetime.now(timezone.utc).date()
|
||||||
|
if period_start is None:
|
||||||
|
period_start = today.replace(day=1)
|
||||||
|
if period_end is None:
|
||||||
|
period_end = today
|
||||||
|
|
||||||
|
# Genehmigte Einträge summieren
|
||||||
|
approved_entries = await db.scalars(
|
||||||
|
select(TimeEntry).where(
|
||||||
|
and_(
|
||||||
|
TimeEntry.user_id == user_id,
|
||||||
|
TimeEntry.date >= period_start,
|
||||||
|
TimeEntry.date <= period_end,
|
||||||
|
TimeEntry.status == EntryStatus.APPROVED,
|
||||||
|
TimeEntry.end_time.isnot(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
approved_list = list(approved_entries.all())
|
||||||
|
|
||||||
|
total_worked = sum(e.worked_hours or 0.0 for e in approved_list)
|
||||||
|
|
||||||
|
# Ausstehende Einträge zählen
|
||||||
|
pending_count = await db.scalar(
|
||||||
|
select(func.count(TimeEntry.id)).where(
|
||||||
|
and_(
|
||||||
|
TimeEntry.user_id == user_id,
|
||||||
|
TimeEntry.date >= period_start,
|
||||||
|
TimeEntry.date <= period_end,
|
||||||
|
TimeEntry.status == EntryStatus.PENDING,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
) or 0
|
||||||
|
|
||||||
|
# Soll-Stunden aus Arbeitsplan ermitteln (neuester gültiger Plan)
|
||||||
|
schedule = await db.scalar(
|
||||||
|
select(WorkSchedule)
|
||||||
|
.join(User, WorkSchedule.company_id == User.company_id)
|
||||||
|
.where(
|
||||||
|
User.id == user_id,
|
||||||
|
WorkSchedule.valid_from <= period_start,
|
||||||
|
)
|
||||||
|
.order_by(WorkSchedule.valid_from.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
expected = self._calc_expected_hours(period_start, period_end, schedule)
|
||||||
|
|
||||||
|
return BalanceResponse(
|
||||||
|
user_id=user_id,
|
||||||
|
period_start=period_start,
|
||||||
|
period_end=period_end,
|
||||||
|
total_hours_worked=round(total_worked, 2),
|
||||||
|
expected_hours=round(expected, 2),
|
||||||
|
overtime_hours=round(total_worked - expected, 2),
|
||||||
|
approved_entries=len(approved_list),
|
||||||
|
pending_entries=pending_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Arbeitspläne ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def create_work_schedule(
|
||||||
|
self,
|
||||||
|
company_id: UUID,
|
||||||
|
data,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> WorkSchedule:
|
||||||
|
schedule = WorkSchedule(company_id=company_id, **data.model_dump())
|
||||||
|
db.add(schedule)
|
||||||
|
await db.flush()
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
async def list_work_schedules(self, company_id: UUID, db: AsyncSession) -> list[WorkSchedule]:
|
||||||
|
result = await db.scalars(
|
||||||
|
select(WorkSchedule)
|
||||||
|
.where(WorkSchedule.company_id == company_id)
|
||||||
|
.order_by(WorkSchedule.valid_from.desc())
|
||||||
|
)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_open_entry(self, user_id: UUID, db: AsyncSession) -> TimeEntry | None:
|
||||||
|
return await db.scalar(
|
||||||
|
select(TimeEntry).where(
|
||||||
|
TimeEntry.user_id == user_id,
|
||||||
|
TimeEntry.end_time.is_(None),
|
||||||
|
).order_by(TimeEntry.date.desc(), TimeEntry.start_time.desc()).limit(1)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_entry_or_404(self, entry_id: UUID, db: AsyncSession) -> TimeEntry:
|
||||||
|
entry = await db.get(TimeEntry, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Zeiterfassungseintrag nicht gefunden.")
|
||||||
|
return entry
|
||||||
|
|
||||||
|
async def _assert_access(self, entry: TimeEntry, user: User, db: AsyncSession) -> None:
|
||||||
|
if entry.user_id != user.id and user.role not in (
|
||||||
|
UserRole.MANAGER, UserRole.HR, UserRole.COMPANY_ADMIN, UserRole.SUPER_ADMIN
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=403, detail="Keine Berechtigung.")
|
||||||
|
entry_user = await db.get(User, entry.user_id)
|
||||||
|
if entry_user is None or entry_user.company_id != user.company_id:
|
||||||
|
raise HTTPException(status_code=403, detail="Zugriff verweigert.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_break_minutes(start: time, end: time) -> int:
|
||||||
|
s = start.hour * 60 + start.minute
|
||||||
|
e = end.hour * 60 + end.minute
|
||||||
|
if e < s:
|
||||||
|
e += 24 * 60
|
||||||
|
return max(0, e - s)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _calc_expected_hours(period_start: date, period_end: date, schedule: WorkSchedule | None) -> float:
|
||||||
|
"""Soll-Stunden für den Zeitraum berechnen."""
|
||||||
|
from datetime import timedelta
|
||||||
|
total = 0.0
|
||||||
|
current = period_start
|
||||||
|
while current <= period_end:
|
||||||
|
wd = current.weekday() # 0=Mon
|
||||||
|
if schedule:
|
||||||
|
total += float(schedule.hours_for_weekday(wd))
|
||||||
|
else:
|
||||||
|
# Fallback: 8h Mo-Fr
|
||||||
|
if wd < 5:
|
||||||
|
total += 8.0
|
||||||
|
current += timedelta(days=1)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
time_service = TimeService()
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
"""User CSV Bulk Import – validates, creates new users or reactivates deactivated ones."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import generate_invite_token, hash_password
|
||||||
|
from app.models.company import Company, PersonnelNumberMode
|
||||||
|
from app.models.user import User, UserRole
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
from app.services.user_service import user_service
|
||||||
|
|
||||||
|
|
||||||
|
REQUIRED_HEADERS = ["email", "first_name", "last_name"]
|
||||||
|
OPTIONAL_HEADERS = ["role", "personnel_number", "kuerzel"]
|
||||||
|
TEMPLATE_HEADERS = REQUIRED_HEADERS + OPTIONAL_HEADERS
|
||||||
|
|
||||||
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
PERSONNEL_RE = re.compile(r"^[0-9]+$")
|
||||||
|
VALID_ROLES = {r.value for r in UserRole if r != UserRole.SUPER_ADMIN}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Datenstrukturen ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportRowResult:
|
||||||
|
row: int
|
||||||
|
email: str
|
||||||
|
personnel_number: str | None
|
||||||
|
action: str # created | reactivated | error
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImportResult:
|
||||||
|
total_rows: int
|
||||||
|
created: int
|
||||||
|
reactivated: int
|
||||||
|
errors: int
|
||||||
|
items: list[ImportRowResult]
|
||||||
|
|
||||||
|
|
||||||
|
# ── CSV-Parsing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def build_template_csv() -> str:
|
||||||
|
"""CSV template returned via /users/import-template.csv."""
|
||||||
|
buf = io.StringIO()
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(TEMPLATE_HEADERS)
|
||||||
|
writer.writerow([
|
||||||
|
"max@firma.de", "Max", "Mustermann",
|
||||||
|
"EMPLOYEE", "0042", "MM",
|
||||||
|
])
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(value: str | None) -> str:
|
||||||
|
return (value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_csv(content: bytes) -> tuple[list[dict[str, str]], list[str]]:
|
||||||
|
"""Parse CSV bytes (BOM-safe). Returns (rows, header_errors)."""
|
||||||
|
text = content.decode("utf-8-sig")
|
||||||
|
reader = csv.DictReader(io.StringIO(text))
|
||||||
|
if reader.fieldnames is None:
|
||||||
|
return [], ["CSV ist leer oder kein gültiger Header gefunden."]
|
||||||
|
headers = [h.strip() for h in reader.fieldnames]
|
||||||
|
missing = [h for h in REQUIRED_HEADERS if h not in headers]
|
||||||
|
if missing:
|
||||||
|
return [], [f"Pflicht-Spalten fehlen: {', '.join(missing)}"]
|
||||||
|
rows = list(reader)
|
||||||
|
return rows, []
|
||||||
|
|
||||||
|
|
||||||
|
# ── Import-Kern (Preview & Apply gemeinsam) ──────────────────────────────────
|
||||||
|
|
||||||
|
async def _process_import(
|
||||||
|
*,
|
||||||
|
content: bytes,
|
||||||
|
company_id: UUID,
|
||||||
|
invited_by: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
apply: bool,
|
||||||
|
) -> ImportResult:
|
||||||
|
"""Process CSV bulk import. apply=False = validation only (no DB writes, rolled back)."""
|
||||||
|
rows, header_errors = _parse_csv(content)
|
||||||
|
items: list[ImportRowResult] = []
|
||||||
|
|
||||||
|
if header_errors:
|
||||||
|
for msg in header_errors:
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=0, email="", personnel_number=None, action="error", message=msg,
|
||||||
|
))
|
||||||
|
return ImportResult(total_rows=0, created=0, reactivated=0, errors=len(items), items=items)
|
||||||
|
|
||||||
|
company = await db.get(Company, company_id)
|
||||||
|
if company is None:
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=0, email="", personnel_number=None, action="error", message="Firma nicht gefunden.",
|
||||||
|
))
|
||||||
|
return ImportResult(total_rows=0, created=0, reactivated=0, errors=1, items=items)
|
||||||
|
|
||||||
|
seen_emails_in_csv: set[str] = set()
|
||||||
|
used_personnel_in_csv: set[str] = set()
|
||||||
|
created = 0
|
||||||
|
reactivated = 0
|
||||||
|
errors = 0
|
||||||
|
|
||||||
|
for idx, raw in enumerate(rows, start=2): # CSV row numbers start at 2 (after header)
|
||||||
|
email = _normalize(raw.get("email")).lower()
|
||||||
|
first_name = _normalize(raw.get("first_name"))
|
||||||
|
last_name = _normalize(raw.get("last_name"))
|
||||||
|
role_str = _normalize(raw.get("role")) or UserRole.EMPLOYEE.value
|
||||||
|
personnel_number = _normalize(raw.get("personnel_number")) or None
|
||||||
|
kuerzel = _normalize(raw.get("kuerzel")) or None
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if not email or not EMAIL_RE.match(email):
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="error", message="Ungültige E-Mail-Adresse.",
|
||||||
|
))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
if not first_name or not last_name:
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="error", message="Vor- und Nachname sind Pflicht.",
|
||||||
|
))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
if role_str not in VALID_ROLES:
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="error", message=f"Ungültige Rolle: {role_str}",
|
||||||
|
))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
if personnel_number is not None and not PERSONNEL_RE.match(personnel_number):
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="error", message="Personalnummer darf nur Ziffern enthalten.",
|
||||||
|
))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Doppelte Mail im Import → Fehler
|
||||||
|
if email in seen_emails_in_csv:
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="error", message="E-Mail kommt im Import mehrfach vor.",
|
||||||
|
))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
seen_emails_in_csv.add(email)
|
||||||
|
|
||||||
|
# Doppelte Personalnr. im Import → Fehler
|
||||||
|
if personnel_number and personnel_number in used_personnel_in_csv:
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="error", message="Personalnummer kommt im Import mehrfach vor.",
|
||||||
|
))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Personalnr.-Konflikt mit DB?
|
||||||
|
if personnel_number:
|
||||||
|
taken = await db.scalar(
|
||||||
|
select(User.id).where(
|
||||||
|
User.company_id == company_id,
|
||||||
|
User.personnel_number == personnel_number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if taken is not None:
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="error", message="Personalnummer ist bereits vergeben.",
|
||||||
|
))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Auto-Vergabe wenn leer (auch im Manuell-Modus laut Anforderung)
|
||||||
|
if not personnel_number:
|
||||||
|
personnel_number = await user_service._next_personnel_number(company_id, db)
|
||||||
|
|
||||||
|
# E-Mail-Konflikt prüfen (auch deaktivierte User in derselben Firma)
|
||||||
|
existing_user = await db.scalar(
|
||||||
|
select(User).where(User.email == email)
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_user is not None and existing_user.is_active:
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="error", message="E-Mail bereits aktiv vergeben.",
|
||||||
|
))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if existing_user is not None and not existing_user.is_active:
|
||||||
|
# Reaktivieren
|
||||||
|
if existing_user.company_id != company_id:
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="error", message="E-Mail existiert in anderer Firma.",
|
||||||
|
))
|
||||||
|
errors += 1
|
||||||
|
continue
|
||||||
|
existing_user.first_name = first_name
|
||||||
|
existing_user.last_name = last_name
|
||||||
|
existing_user.role = UserRole(role_str)
|
||||||
|
if kuerzel:
|
||||||
|
existing_user.kuerzel = kuerzel
|
||||||
|
# Personalnr.: behalten, falls schon vorhanden (Reservierung), sonst setzen
|
||||||
|
if not existing_user.personnel_number:
|
||||||
|
existing_user.personnel_number = personnel_number
|
||||||
|
else:
|
||||||
|
personnel_number = existing_user.personnel_number
|
||||||
|
existing_user.is_active = True
|
||||||
|
used_personnel_in_csv.add(personnel_number)
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="reactivated",
|
||||||
|
))
|
||||||
|
reactivated += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Neuanlage: Invite-Token generieren, User inaktiv (warten auf Annahme)
|
||||||
|
raw_token, token_hash = generate_invite_token()
|
||||||
|
new_user = User(
|
||||||
|
company_id=company_id,
|
||||||
|
email=email,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
role=UserRole(role_str),
|
||||||
|
kuerzel=kuerzel,
|
||||||
|
personnel_number=personnel_number,
|
||||||
|
password_hash=hash_password(raw_token),
|
||||||
|
invite_token_hash=token_hash,
|
||||||
|
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
db.add(new_user)
|
||||||
|
await db.flush()
|
||||||
|
used_personnel_in_csv.add(personnel_number)
|
||||||
|
|
||||||
|
if apply:
|
||||||
|
try:
|
||||||
|
await email_service.send_invite(new_user, invited_by, raw_token, db)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# Mail-Fehler darf Import nicht abbrechen, wird aber gemeldet
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="created",
|
||||||
|
message=f"Anlage OK, aber Einladungs-Mail fehlgeschlagen: {e}",
|
||||||
|
))
|
||||||
|
created += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
items.append(ImportRowResult(
|
||||||
|
row=idx, email=email, personnel_number=personnel_number,
|
||||||
|
action="created",
|
||||||
|
))
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
return ImportResult(
|
||||||
|
total_rows=len(rows),
|
||||||
|
created=created,
|
||||||
|
reactivated=reactivated,
|
||||||
|
errors=errors,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def preview_csv(
|
||||||
|
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
|
||||||
|
) -> ImportResult:
|
||||||
|
"""Validiert CSV ohne DB-Schreibvorgänge (Rollback am Ende)."""
|
||||||
|
result = await _process_import(
|
||||||
|
content=content,
|
||||||
|
company_id=company_id,
|
||||||
|
invited_by=invited_by,
|
||||||
|
db=db,
|
||||||
|
apply=False,
|
||||||
|
)
|
||||||
|
await db.rollback()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def apply_csv(
|
||||||
|
content: bytes, company_id: UUID, invited_by: User, db: AsyncSession,
|
||||||
|
) -> ImportResult:
|
||||||
|
"""Führt Import durch und committet."""
|
||||||
|
result = await _process_import(
|
||||||
|
content=content,
|
||||||
|
company_id=company_id,
|
||||||
|
invited_by=invited_by,
|
||||||
|
db=db,
|
||||||
|
apply=True,
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy import func, or_, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.security import (
|
||||||
|
generate_invite_token,
|
||||||
|
hash_password,
|
||||||
|
hash_token,
|
||||||
|
verify_password,
|
||||||
|
)
|
||||||
|
from app.models import User, UserRole
|
||||||
|
from app.models.audit_log import AuditLog
|
||||||
|
from app.models.company import Company, PersonnelNumberMode
|
||||||
|
from app.schemas.user import InviteAccept, InviteRequest, UserUpdate
|
||||||
|
from app.services.email_service import email_service
|
||||||
|
|
||||||
|
|
||||||
|
PERSONNEL_NUMBER_MIN_DIGITS = 4
|
||||||
|
|
||||||
|
|
||||||
|
class UserService:
|
||||||
|
|
||||||
|
# ── Personalnummer-Helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _get_company(self, company_id: UUID, db: AsyncSession) -> Company:
|
||||||
|
company = await db.get(Company, company_id)
|
||||||
|
if not company:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
return company
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_personnel_number(value: int) -> str:
|
||||||
|
return str(value).zfill(PERSONNEL_NUMBER_MIN_DIGITS)
|
||||||
|
|
||||||
|
async def _next_personnel_number(self, company_id: UUID, db: AsyncSession) -> str:
|
||||||
|
"""Atomic increment + return next personnel number for the company.
|
||||||
|
|
||||||
|
Uses UPDATE ... RETURNING to avoid race conditions with parallel inserts.
|
||||||
|
Skips numbers that are already taken (e.g. manual override) by retrying.
|
||||||
|
"""
|
||||||
|
for _ in range(50): # safety bound, in practice 1-2 iterations max
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE companies "
|
||||||
|
"SET personnel_number_next = personnel_number_next + 1 "
|
||||||
|
"WHERE id = :cid "
|
||||||
|
"RETURNING personnel_number_next - 1 AS used"
|
||||||
|
),
|
||||||
|
{"cid": company_id},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Company not found")
|
||||||
|
candidate = self._format_personnel_number(int(row.used))
|
||||||
|
existing = await db.scalar(
|
||||||
|
select(User.id).where(
|
||||||
|
User.company_id == company_id,
|
||||||
|
User.personnel_number == candidate,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
return candidate
|
||||||
|
raise HTTPException(status_code=500, detail="Could not allocate personnel number")
|
||||||
|
|
||||||
|
async def next_personnel_suggestion(self, company_id: UUID, db: AsyncSession) -> str:
|
||||||
|
"""Preview next personnel number without consuming the counter."""
|
||||||
|
company = await self._get_company(company_id, db)
|
||||||
|
candidate_int = company.personnel_number_next
|
||||||
|
while True:
|
||||||
|
candidate = self._format_personnel_number(candidate_int)
|
||||||
|
taken = await db.scalar(
|
||||||
|
select(User.id).where(
|
||||||
|
User.company_id == company_id,
|
||||||
|
User.personnel_number == candidate,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if taken is None:
|
||||||
|
return candidate
|
||||||
|
candidate_int += 1
|
||||||
|
|
||||||
|
async def _check_personnel_unique(
|
||||||
|
self,
|
||||||
|
company_id: UUID,
|
||||||
|
number: str,
|
||||||
|
db: AsyncSession,
|
||||||
|
exclude_user_id: UUID | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Raise 409 if personnel number is already taken (incl. deactivated/reserved)."""
|
||||||
|
q = select(User.id).where(
|
||||||
|
User.company_id == company_id,
|
||||||
|
User.personnel_number == number,
|
||||||
|
)
|
||||||
|
if exclude_user_id is not None:
|
||||||
|
q = q.where(User.id != exclude_user_id)
|
||||||
|
existing = await db.scalar(q)
|
||||||
|
if existing is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail=f"Personalnummer '{number}' ist bereits vergeben (auch reservierte Nummern bleiben belegt).",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_by_personnel_number(
|
||||||
|
self, number: str, company_id: UUID, db: AsyncSession
|
||||||
|
) -> User:
|
||||||
|
user = await db.scalar(
|
||||||
|
select(User).where(
|
||||||
|
User.company_id == company_id,
|
||||||
|
User.personnel_number == number,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Personalnummer nicht gefunden")
|
||||||
|
return user
|
||||||
|
|
||||||
|
# ── Invite ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def invite(
|
||||||
|
self,
|
||||||
|
data: InviteRequest,
|
||||||
|
company_id: UUID,
|
||||||
|
invited_by: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> User:
|
||||||
|
existing = await db.scalar(select(User).where(User.email == data.email))
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="Email already registered")
|
||||||
|
|
||||||
|
company = await self._get_company(company_id, db)
|
||||||
|
personnel_number = data.personnel_number
|
||||||
|
if personnel_number:
|
||||||
|
await self._check_personnel_unique(company_id, personnel_number, db)
|
||||||
|
else:
|
||||||
|
if company.personnel_number_mode == PersonnelNumberMode.AUTO.value:
|
||||||
|
personnel_number = await self._next_personnel_number(company_id, db)
|
||||||
|
elif company.personnel_number_required:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Personalnummer ist in dieser Firma Pflicht.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.initial_password:
|
||||||
|
# Direktanlage mit Passwort – sofort aktiv, kein E-Mail-Invite
|
||||||
|
user = User(
|
||||||
|
company_id=company_id,
|
||||||
|
email=data.email,
|
||||||
|
first_name=data.first_name,
|
||||||
|
last_name=data.last_name,
|
||||||
|
role=data.role,
|
||||||
|
department_id=data.department_id,
|
||||||
|
personnel_number=personnel_number,
|
||||||
|
password_hash=hash_password(data.initial_password),
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=company_id,
|
||||||
|
user_id=invited_by.id,
|
||||||
|
action="user_created",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
new_value={"email": user.email, "role": user.role.value, "direct": True},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
raw_token, token_hash = generate_invite_token()
|
||||||
|
user = User(
|
||||||
|
company_id=company_id,
|
||||||
|
email=data.email,
|
||||||
|
first_name=data.first_name,
|
||||||
|
last_name=data.last_name,
|
||||||
|
role=data.role,
|
||||||
|
department_id=data.department_id,
|
||||||
|
personnel_number=personnel_number,
|
||||||
|
password_hash=hash_password(raw_token), # Temp – overwritten on accept
|
||||||
|
invite_token_hash=token_hash,
|
||||||
|
invite_expires=datetime.now(timezone.utc) + timedelta(days=7),
|
||||||
|
is_active=False,
|
||||||
|
)
|
||||||
|
db.add(user)
|
||||||
|
await db.flush()
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=company_id,
|
||||||
|
user_id=invited_by.id,
|
||||||
|
action="user_invited",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
new_value={"email": user.email, "role": user.role.value},
|
||||||
|
))
|
||||||
|
await email_service.send_invite(user, invited_by, raw_token, db)
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def accept_invite(self, data: InviteAccept, db: AsyncSession) -> User:
|
||||||
|
token_hash = hash_token(data.token)
|
||||||
|
user = await db.scalar(
|
||||||
|
select(User).where(User.invite_token_hash == token_hash)
|
||||||
|
)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid invite token")
|
||||||
|
if user.invite_expires and user.invite_expires < datetime.now(timezone.utc):
|
||||||
|
raise HTTPException(status_code=400, detail="Invite token expired")
|
||||||
|
|
||||||
|
user.password_hash = hash_password(data.password)
|
||||||
|
user.invite_token_hash = None
|
||||||
|
user.invite_expires = None
|
||||||
|
user.is_active = True
|
||||||
|
return user
|
||||||
|
|
||||||
|
# ── Listing ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def list_users(
|
||||||
|
self,
|
||||||
|
company_id: UUID,
|
||||||
|
db: AsyncSession,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 50,
|
||||||
|
active_only: bool = True,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> tuple[int, list[User]]:
|
||||||
|
q = select(User).where(User.company_id == company_id)
|
||||||
|
if active_only:
|
||||||
|
q = q.where(User.is_active == True)
|
||||||
|
if search:
|
||||||
|
pattern = f"%{search.strip()}%"
|
||||||
|
q = q.where(
|
||||||
|
or_(
|
||||||
|
User.email.ilike(pattern),
|
||||||
|
User.first_name.ilike(pattern),
|
||||||
|
User.last_name.ilike(pattern),
|
||||||
|
User.personnel_number.ilike(pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total = await db.scalar(select(func.count()).select_from(q.subquery()))
|
||||||
|
users = await db.scalars(q.offset(skip).limit(limit))
|
||||||
|
return total, list(users.all())
|
||||||
|
|
||||||
|
async def get_by_id(self, user_id: UUID, company_id: UUID, db: AsyncSession) -> User:
|
||||||
|
user = await db.get(User, user_id)
|
||||||
|
if not user or user.company_id != company_id:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
return user
|
||||||
|
|
||||||
|
# ── Update / De/Reactivate ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
user_id: UUID,
|
||||||
|
data: UserUpdate,
|
||||||
|
current_user: User,
|
||||||
|
db: AsyncSession,
|
||||||
|
) -> User:
|
||||||
|
user = await self.get_by_id(user_id, current_user.company_id, db)
|
||||||
|
changes = data.model_dump(exclude_unset=True)
|
||||||
|
old_personnel = user.personnel_number
|
||||||
|
|
||||||
|
if "personnel_number" in changes:
|
||||||
|
new_value = changes["personnel_number"]
|
||||||
|
if new_value:
|
||||||
|
await self._check_personnel_unique(
|
||||||
|
current_user.company_id, new_value, db, exclude_user_id=user.id
|
||||||
|
)
|
||||||
|
elif user.personnel_number is not None:
|
||||||
|
# Explizites Löschen erlauben? Plan sagt Reservierung – wir verbieten Clear.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Personalnummer kann nicht gelöscht werden (Reservierung).",
|
||||||
|
)
|
||||||
|
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(user, field, value)
|
||||||
|
|
||||||
|
if "personnel_number" in changes and changes["personnel_number"] != old_personnel:
|
||||||
|
db.add(AuditLog(
|
||||||
|
company_id=current_user.company_id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
action="user_personnel_number_changed",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
old_value={"personnel_number": old_personnel},
|
||||||
|
new_value={"personnel_number": user.personnel_number},
|
||||||
|
))
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def deactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
|
||||||
|
if user_id == current_user.id:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot deactivate your own account")
|
||||||
|
user = await self.get_by_id(user_id, current_user.company_id, db)
|
||||||
|
user.is_active = False
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def reactivate(self, user_id: UUID, current_user: User, db: AsyncSession) -> User:
|
||||||
|
user = await self.get_by_id(user_id, current_user.company_id, db)
|
||||||
|
user.is_active = True
|
||||||
|
return user
|
||||||
|
|
||||||
|
# ── Kiosk ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def set_kiosk_pin(self, user: User, pin: str, db: AsyncSession) -> None:
|
||||||
|
user.kiosk_pin_hash = hash_password(pin)
|
||||||
|
|
||||||
|
async def verify_kiosk_pin(self, user: User, pin: str) -> bool:
|
||||||
|
if not user.kiosk_pin_hash:
|
||||||
|
return False
|
||||||
|
return verify_password(pin, user.kiosk_pin_hash)
|
||||||
|
|
||||||
|
|
||||||
|
user_service = UserService()
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import asyncio
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
# Import all models so Alembic sees them
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url)
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
asyncio.run(run_async_migrations())
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"""initial schema - auth tables
|
||||||
|
|
||||||
|
Revision ID: 0001_initial
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-03-25
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0001_initial"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── companies ─────────────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"companies",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("slug", sa.String(100), nullable=False, unique=True),
|
||||||
|
sa.Column("plan", sa.String(50), server_default="trial"),
|
||||||
|
sa.Column("logo_url", sa.Text),
|
||||||
|
sa.Column("country", sa.String(10), server_default="DE"),
|
||||||
|
sa.Column("state", sa.String(10)),
|
||||||
|
sa.Column("settings", postgresql.JSONB, server_default="{}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── departments (before users – FK target) ─────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"departments",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("manager_id", postgresql.UUID(as_uuid=True)), # FK added after users
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── users ─────────────────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"users",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE")),
|
||||||
|
sa.Column("department_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("departments.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("email", sa.String(255), nullable=False, unique=True),
|
||||||
|
sa.Column("password_hash", sa.Text, nullable=False),
|
||||||
|
sa.Column("first_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("last_name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("role", sa.Enum(
|
||||||
|
"SUPER_ADMIN", "COMPANY_ADMIN", "HR", "MANAGER", "EMPLOYEE",
|
||||||
|
name="userrole",
|
||||||
|
), nullable=False),
|
||||||
|
sa.Column("kiosk_pin_hash", sa.Text),
|
||||||
|
sa.Column("kiosk_qr_token", sa.Text, unique=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true"),
|
||||||
|
sa.Column("invite_token_hash", sa.Text),
|
||||||
|
sa.Column("invite_expires", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("last_login", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_users_email", "users", ["email"])
|
||||||
|
|
||||||
|
# Now add FK for departments.manager_id → users
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_departments_manager", "departments", "users", ["manager_id"], ["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── sessions ──────────────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"sessions",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("refresh_token_hash", sa.Text, nullable=False, unique=True),
|
||||||
|
sa.Column("device", sa.String(255)),
|
||||||
|
sa.Column("ip", sa.String(45)),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||||
|
|
||||||
|
# ── password_resets ───────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"password_resets",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("token_hash", sa.Text, nullable=False, unique=True),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("used_at", sa.DateTime(timezone=True)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_password_resets_user_id", "password_resets", ["user_id"])
|
||||||
|
|
||||||
|
# ── audit_logs ────────────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"audit_logs",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("action", sa.String(100), nullable=False),
|
||||||
|
sa.Column("entity_type", sa.String(100)),
|
||||||
|
sa.Column("entity_id", postgresql.UUID(as_uuid=True)),
|
||||||
|
sa.Column("old_value", postgresql.JSONB),
|
||||||
|
sa.Column("new_value", postgresql.JSONB),
|
||||||
|
sa.Column("ip", sa.String(45)),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.func.now(), index=True),
|
||||||
|
)
|
||||||
|
op.create_index("ix_audit_logs_company_id", "audit_logs", ["company_id"])
|
||||||
|
op.create_index("ix_audit_logs_user_id", "audit_logs", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("audit_logs")
|
||||||
|
op.drop_table("password_resets")
|
||||||
|
op.drop_table("sessions")
|
||||||
|
op.drop_constraint("fk_departments_manager", "departments", type_="foreignkey")
|
||||||
|
op.drop_table("users")
|
||||||
|
op.execute("DROP TYPE IF EXISTS userrole")
|
||||||
|
op.drop_table("departments")
|
||||||
|
op.drop_table("companies")
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""time entries and work schedules
|
||||||
|
|
||||||
|
Revision ID: 0002_time_entries
|
||||||
|
Revises: 0001_initial
|
||||||
|
Create Date: 2026-03-27
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0002_time_entries"
|
||||||
|
down_revision = "0001_initial"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Enum-Typen explizit definieren (create_type=False → sa.Enum erstellt sie selbst via op.create_table)
|
||||||
|
entrystatus = sa.Enum("pending", "approved", "rejected", name="entrystatus")
|
||||||
|
entrysource = sa.Enum("web", "kiosk", "api", "manual", name="entrysource")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── work_schedules ─────────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"work_schedules",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("mon_h", sa.Numeric(4, 2), server_default="8.00"),
|
||||||
|
sa.Column("tue_h", sa.Numeric(4, 2), server_default="8.00"),
|
||||||
|
sa.Column("wed_h", sa.Numeric(4, 2), server_default="8.00"),
|
||||||
|
sa.Column("thu_h", sa.Numeric(4, 2), server_default="8.00"),
|
||||||
|
sa.Column("fri_h", sa.Numeric(4, 2), server_default="8.00"),
|
||||||
|
sa.Column("sat_h", sa.Numeric(4, 2), server_default="0.00"),
|
||||||
|
sa.Column("sun_h", sa.Numeric(4, 2), server_default="0.00"),
|
||||||
|
sa.Column("valid_from", sa.Date, nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_work_schedules_company_id", "work_schedules", ["company_id"])
|
||||||
|
|
||||||
|
# ── time_entries ───────────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"time_entries",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("date", sa.Date, nullable=False),
|
||||||
|
sa.Column("start_time", sa.Time(timezone=False), nullable=False),
|
||||||
|
sa.Column("end_time", sa.Time(timezone=False)),
|
||||||
|
sa.Column("break_minutes", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("break_start", sa.Time(timezone=False)),
|
||||||
|
sa.Column("project_id", postgresql.UUID(as_uuid=True)),
|
||||||
|
sa.Column("note", sa.Text),
|
||||||
|
sa.Column("status", entrystatus, nullable=False, server_default="pending"),
|
||||||
|
sa.Column("source", entrysource, nullable=False, server_default="web"),
|
||||||
|
sa.Column("approved_by", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("correction_note", sa.Text),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_time_entries_user_id", "time_entries", ["user_id"])
|
||||||
|
op.create_index("ix_time_entries_date", "time_entries", ["date"])
|
||||||
|
op.create_index("ix_time_entries_user_date", "time_entries", ["user_id", "date"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("time_entries")
|
||||||
|
op.drop_table("work_schedules")
|
||||||
|
entrystatus.drop(op.get_bind(), checkfirst=True)
|
||||||
|
entrysource.drop(op.get_bind(), checkfirst=True)
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""absence management
|
||||||
|
|
||||||
|
Revision ID: 0003_absences
|
||||||
|
Revises: 0002_time_entries
|
||||||
|
Create Date: 2026-03-27
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0003_absences"
|
||||||
|
down_revision = "0002_time_entries"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
absencestatus = sa.Enum("pending", "approved", "rejected", "cancelled", name="absencestatus")
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── absence_types ──────────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"absence_types",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("color", sa.String(7), server_default="#3B82F6"),
|
||||||
|
sa.Column("requires_approval", sa.Boolean, server_default="true"),
|
||||||
|
sa.Column("deducts_vacation", sa.Boolean, server_default="false"),
|
||||||
|
sa.Column("is_paid", sa.Boolean, server_default="true"),
|
||||||
|
sa.Column("max_days_per_year", sa.Integer),
|
||||||
|
sa.Column("is_active", sa.Boolean, server_default="true"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_absence_types_company_id", "absence_types", ["company_id"])
|
||||||
|
|
||||||
|
# ── absences ───────────────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"absences",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("type_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("absence_types.id", ondelete="RESTRICT"), nullable=False),
|
||||||
|
sa.Column("start_date", sa.Date, nullable=False),
|
||||||
|
sa.Column("end_date", sa.Date, nullable=False),
|
||||||
|
sa.Column("half_day_start", sa.Boolean, server_default="false"),
|
||||||
|
sa.Column("half_day_end", sa.Boolean, server_default="false"),
|
||||||
|
sa.Column("working_days", sa.Numeric(5, 1), server_default="0"),
|
||||||
|
sa.Column("status", absencestatus, nullable=False, server_default="pending"),
|
||||||
|
sa.Column("approved_by", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("substitute_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL")),
|
||||||
|
sa.Column("note", sa.Text),
|
||||||
|
sa.Column("rejection_reason", sa.Text),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_absences_user_id", "absences", ["user_id"])
|
||||||
|
op.create_index("ix_absences_start_date", "absences", ["start_date"])
|
||||||
|
|
||||||
|
# ── vacation_balances ──────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"vacation_balances",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("year", sa.Integer, nullable=False),
|
||||||
|
sa.Column("entitled_days", sa.Integer, server_default="30"),
|
||||||
|
sa.Column("carried_over", sa.Integer, server_default="0"),
|
||||||
|
sa.Column("used_days", sa.Integer, server_default="0"),
|
||||||
|
sa.UniqueConstraint("user_id", "year", name="uq_vacation_balance_user_year"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_vacation_balances_user_id", "vacation_balances", ["user_id"])
|
||||||
|
|
||||||
|
# ── public_holidays ────────────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"public_holidays",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("country", sa.String(10), nullable=False),
|
||||||
|
sa.Column("state", sa.String(10)),
|
||||||
|
sa.Column("date", sa.Date, nullable=False),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("year", sa.Integer, nullable=False),
|
||||||
|
sa.UniqueConstraint("country", "state", "date", name="uq_public_holiday"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_public_holidays_date", "public_holidays", ["date"])
|
||||||
|
op.create_index("ix_public_holidays_year", "public_holidays", ["year"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("public_holidays")
|
||||||
|
op.drop_table("vacation_balances")
|
||||||
|
op.drop_table("absences")
|
||||||
|
op.drop_table("absence_types")
|
||||||
|
absencestatus.drop(op.get_bind(), checkfirst=True)
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""LDAP integration: ldap_configs table + user auth_provider/ldap_dn columns
|
||||||
|
|
||||||
|
Revision ID: 0004_ldap
|
||||||
|
Revises: 0003_absences
|
||||||
|
Create Date: 2026-03-27
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0004_ldap"
|
||||||
|
down_revision = "0003_absences"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add auth_provider enum
|
||||||
|
authprovider = postgresql.ENUM("local", "ldap", name="authprovider", create_type=False)
|
||||||
|
authprovider.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
# Alter users table
|
||||||
|
op.add_column("users", sa.Column(
|
||||||
|
"auth_provider",
|
||||||
|
sa.Enum("local", "ldap", name="authprovider"),
|
||||||
|
nullable=False,
|
||||||
|
server_default="local",
|
||||||
|
))
|
||||||
|
op.add_column("users", sa.Column("ldap_dn", sa.Text(), nullable=True))
|
||||||
|
op.alter_column("users", "password_hash", nullable=True)
|
||||||
|
|
||||||
|
# Create ldap_configs table
|
||||||
|
op.create_table(
|
||||||
|
"ldap_configs",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, unique=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("host", sa.String(255), nullable=False),
|
||||||
|
sa.Column("port", sa.Integer(), nullable=False, server_default="389"),
|
||||||
|
sa.Column("use_ssl", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("use_tls", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("bind_dn", sa.Text(), nullable=False),
|
||||||
|
sa.Column("bind_password_encrypted", sa.Text(), nullable=False),
|
||||||
|
sa.Column("base_dn", sa.Text(), nullable=False),
|
||||||
|
sa.Column("user_search_filter", sa.String(512), nullable=False,
|
||||||
|
server_default="(objectClass=person)"),
|
||||||
|
sa.Column("attr_email", sa.String(100), nullable=False, server_default="mail"),
|
||||||
|
sa.Column("attr_firstname", sa.String(100), nullable=False, server_default="givenName"),
|
||||||
|
sa.Column("attr_lastname", sa.String(100), nullable=False, server_default="sn"),
|
||||||
|
sa.Column("attr_username", sa.String(100), nullable=False, server_default="sAMAccountName"),
|
||||||
|
sa.Column("attr_department", sa.String(100), nullable=True),
|
||||||
|
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_ldap_configs_company_id", "ldap_configs", ["company_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("ldap_configs")
|
||||||
|
op.drop_column("users", "ldap_dn")
|
||||||
|
op.drop_column("users", "auth_provider")
|
||||||
|
# LDAP users have no password_hash — set placeholder before restoring NOT NULL
|
||||||
|
op.execute("UPDATE users SET password_hash = 'LDAP_USER_NO_PASSWORD' WHERE password_hash IS NULL")
|
||||||
|
op.alter_column("users", "password_hash", nullable=False)
|
||||||
|
op.execute("DROP TYPE IF EXISTS authprovider")
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Work schedule linkage, absence extensions, overtime balance, CalDAV configs
|
||||||
|
|
||||||
|
Revision ID: 0005_extensions
|
||||||
|
Revises: 0004_ldap
|
||||||
|
Create Date: 2026-03-27
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0005_extensions"
|
||||||
|
down_revision = "0004_ldap"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── 1. AbsenceCategory enum ───────────────────────────────────────────────
|
||||||
|
absence_category = postgresql.ENUM(
|
||||||
|
"vacation", "sick", "overtime_comp", "training", "business_trip", "other",
|
||||||
|
name="absencecategory", create_type=False,
|
||||||
|
)
|
||||||
|
absence_category.create(op.get_bind(), checkfirst=True)
|
||||||
|
|
||||||
|
# ── 2. absence_types – neue Spalten ───────────────────────────────────────
|
||||||
|
op.add_column("absence_types", sa.Column(
|
||||||
|
"category",
|
||||||
|
sa.Enum("vacation", "sick", "overtime_comp", "training", "business_trip", "other",
|
||||||
|
name="absencecategory"),
|
||||||
|
nullable=False, server_default="other",
|
||||||
|
))
|
||||||
|
op.add_column("absence_types", sa.Column(
|
||||||
|
"affects_overtime_balance", sa.Boolean(), nullable=False, server_default="false"
|
||||||
|
))
|
||||||
|
op.add_column("absence_types", sa.Column(
|
||||||
|
"requires_certificate", sa.Boolean(), nullable=False, server_default="false"
|
||||||
|
))
|
||||||
|
op.add_column("absence_types", sa.Column(
|
||||||
|
"certificate_after_days", sa.Integer(), nullable=False, server_default="3"
|
||||||
|
))
|
||||||
|
|
||||||
|
# Bestehende Typen klassifizieren
|
||||||
|
op.execute("""
|
||||||
|
UPDATE absence_types SET category = 'vacation'
|
||||||
|
WHERE lower(name) IN ('urlaub', 'sonderurlaub')
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE absence_types SET category = 'sick', requires_certificate = true
|
||||||
|
WHERE lower(name) = 'krankheit'
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE absence_types SET category = 'business_trip'
|
||||||
|
WHERE lower(name) = 'dienstreise'
|
||||||
|
""")
|
||||||
|
op.execute("""
|
||||||
|
UPDATE absence_types SET category = 'training'
|
||||||
|
WHERE lower(name) IN ('weiterbildung', 'bildungsurlaub')
|
||||||
|
""")
|
||||||
|
|
||||||
|
# ── 3. absences – CalDAV + Meta + Zertifikat ──────────────────────────────
|
||||||
|
op.add_column("absences", sa.Column("meta", postgresql.JSONB(), nullable=True))
|
||||||
|
op.add_column("absences", sa.Column("certificate_required_by", sa.Date(), nullable=True))
|
||||||
|
op.add_column("absences", sa.Column("certificate_received_at", sa.Date(), nullable=True))
|
||||||
|
op.add_column("absences", sa.Column("caldav_uid", sa.String(255), nullable=True))
|
||||||
|
op.add_column("absences", sa.Column("caldav_user_etag", sa.Text(), nullable=True))
|
||||||
|
op.add_column("absences", sa.Column("caldav_company_etag", sa.Text(), nullable=True))
|
||||||
|
op.add_column("absences", sa.Column("caldav_last_error", sa.Text(), nullable=True))
|
||||||
|
op.add_column("absences", sa.Column(
|
||||||
|
"caldav_synced_at", sa.DateTime(timezone=True), nullable=True
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── 4. users – work_schedule_id ───────────────────────────────────────────
|
||||||
|
op.add_column("users", sa.Column(
|
||||||
|
"work_schedule_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("work_schedules.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
))
|
||||||
|
|
||||||
|
# ── 5. overtime_balances ──────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"overtime_balances",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, unique=True),
|
||||||
|
sa.Column("company_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False),
|
||||||
|
sa.Column("total_hours", sa.Numeric(8, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("taken_hours", sa.Numeric(8, 2), nullable=False, server_default="0"),
|
||||||
|
sa.Column("last_calculated", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_overtime_balances_user_id", "overtime_balances", ["user_id"])
|
||||||
|
op.create_index("ix_overtime_balances_company_id", "overtime_balances", ["company_id"])
|
||||||
|
|
||||||
|
# ── 6. caldav_company_configs ─────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"caldav_company_configs",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, unique=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("principal_url", sa.Text(), nullable=False),
|
||||||
|
sa.Column("calendar_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("username", sa.String(255), nullable=False),
|
||||||
|
sa.Column("password_encrypted", sa.Text(), nullable=False),
|
||||||
|
sa.Column("calendar_display_name", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("verify_ssl", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_caldav_company_configs_company_id", "caldav_company_configs", ["company_id"])
|
||||||
|
|
||||||
|
# ── 7. caldav_user_configs ────────────────────────────────────────────────
|
||||||
|
op.create_table(
|
||||||
|
"caldav_user_configs",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, unique=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("principal_url", sa.Text(), nullable=False),
|
||||||
|
sa.Column("calendar_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("username", sa.String(255), nullable=False),
|
||||||
|
sa.Column("password_encrypted", sa.Text(), nullable=False),
|
||||||
|
sa.Column("calendar_display_name", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("verify_ssl", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_caldav_user_configs_user_id", "caldav_user_configs", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("caldav_user_configs")
|
||||||
|
op.drop_table("caldav_company_configs")
|
||||||
|
op.drop_table("overtime_balances")
|
||||||
|
op.drop_column("users", "work_schedule_id")
|
||||||
|
for col in ["caldav_synced_at", "caldav_last_error", "caldav_company_etag",
|
||||||
|
"caldav_user_etag", "caldav_uid", "certificate_received_at",
|
||||||
|
"certificate_required_by", "meta"]:
|
||||||
|
op.drop_column("absences", col)
|
||||||
|
for col in ["certificate_after_days", "requires_certificate",
|
||||||
|
"affects_overtime_balance", "category"]:
|
||||||
|
op.drop_column("absence_types", col)
|
||||||
|
op.execute("DROP TYPE IF EXISTS absencecategory")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""smtp_configs table
|
||||||
|
|
||||||
|
Revision ID: 0006_smtp
|
||||||
|
Revises: 0005_extensions
|
||||||
|
Create Date: 2026-03-27
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0006_smtp"
|
||||||
|
down_revision = "0005_extensions"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"smtp_configs",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE"),
|
||||||
|
nullable=False),
|
||||||
|
sa.Column("host", sa.String(255), nullable=False),
|
||||||
|
sa.Column("port", sa.Integer, nullable=False, server_default="587"),
|
||||||
|
sa.Column("use_tls", sa.Boolean, nullable=False, server_default="false"),
|
||||||
|
sa.Column("use_starttls", sa.Boolean, nullable=False, server_default="true"),
|
||||||
|
sa.Column("username", sa.String(255)),
|
||||||
|
sa.Column("password_encrypted", sa.Text),
|
||||||
|
sa.Column("from_email", sa.String(255), nullable=False),
|
||||||
|
sa.Column("from_name", sa.String(255), nullable=False, server_default="TimeMaster"),
|
||||||
|
sa.Column("is_enabled", sa.Boolean, nullable=False, server_default="true"),
|
||||||
|
sa.UniqueConstraint("company_id", name="uq_smtp_configs_company"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_smtp_configs_company_id", "smtp_configs", ["company_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_smtp_configs_company_id")
|
||||||
|
op.drop_table("smtp_configs")
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
"""caldav_configs tables + fix smtp_configs missing FK
|
||||||
|
|
||||||
|
Revision ID: 0007_caldav_and_fixes
|
||||||
|
Revises: 0006_smtp
|
||||||
|
Create Date: 2026-03-27
|
||||||
|
|
||||||
|
Fixes:
|
||||||
|
- smtp_configs.company_id had no FK constraint (orphaned records possible)
|
||||||
|
- caldav_company_configs and caldav_user_configs were created via create_all,
|
||||||
|
not tracked by Alembic — now formally managed here
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0007_caldav_and_fixes"
|
||||||
|
down_revision = "0006_smtp"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ── Fix 1: smtp_configs missing FK ───────────────────────────────────────
|
||||||
|
# The FK was omitted in 0006_smtp. Add it now.
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_smtp_configs_company_id",
|
||||||
|
"smtp_configs", "companies",
|
||||||
|
["company_id"], ["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Fix 2: caldav tables (created via create_all, now Alembic-managed) ───
|
||||||
|
# Use checkfirst=True so fresh installs that don't have the tables yet
|
||||||
|
# get them created, while existing installs skip creation silently.
|
||||||
|
bind = op.get_bind()
|
||||||
|
|
||||||
|
if not bind.dialect.has_table(bind, "caldav_company_configs"):
|
||||||
|
op.create_table(
|
||||||
|
"caldav_company_configs",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, unique=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("principal_url", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("calendar_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("username", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("password_encrypted", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("calendar_display_name", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("verify_ssl", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_caldav_company_configs_company_id", "caldav_company_configs", ["company_id"])
|
||||||
|
|
||||||
|
if not bind.dialect.has_table(bind, "caldav_user_configs"):
|
||||||
|
op.create_table(
|
||||||
|
"caldav_user_configs",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False, unique=True),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
sa.Column("principal_url", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("calendar_url", sa.Text(), nullable=True),
|
||||||
|
sa.Column("username", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("password_encrypted", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("calendar_display_name", sa.String(255), nullable=False, server_default=""),
|
||||||
|
sa.Column("verify_ssl", sa.Boolean(), nullable=False, server_default="true"),
|
||||||
|
sa.Column("last_error", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
op.create_index("ix_caldav_user_configs_user_id", "caldav_user_configs", ["user_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove the smtp FK (restores state to 0006_smtp)
|
||||||
|
op.drop_constraint("fk_smtp_configs_company_id", "smtp_configs", type_="foreignkey")
|
||||||
|
|
||||||
|
# Drop caldav tables only if they were created by this migration
|
||||||
|
# (i.e., on a fresh install path — existing installs: tables stay)
|
||||||
|
op.drop_index("ix_caldav_user_configs_user_id", table_name="caldav_user_configs")
|
||||||
|
op.drop_table("caldav_user_configs")
|
||||||
|
op.drop_index("ix_caldav_company_configs_company_id", table_name="caldav_company_configs")
|
||||||
|
op.drop_table("caldav_company_configs")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Add tls_verify column to ldap_configs (H-07 security fix)
|
||||||
|
|
||||||
|
Revision ID: 0008_ldap_tls_verify
|
||||||
|
Revises: 0007_caldav_and_fixes
|
||||||
|
Create Date: 2026-03-27
|
||||||
|
|
||||||
|
Security fix H-07: make LDAP certificate validation configurable.
|
||||||
|
Default is False for backwards compatibility; set True in production
|
||||||
|
to enforce CERT_REQUIRED and prevent MITM attacks.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "0008_ldap_tls_verify"
|
||||||
|
down_revision = "0007_caldav_and_fixes"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ldap_configs",
|
||||||
|
sa.Column("tls_verify", sa.Boolean(), nullable=False, server_default="false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ldap_configs", "tls_verify")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""add correction_note to absences
|
||||||
|
|
||||||
|
Revision ID: 0009
|
||||||
|
Revises: 0008
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '0009_absence_correction_note'
|
||||||
|
down_revision = '0008_ldap_tls_verify'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('absences', sa.Column('correction_note', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('absences', 'correction_note')
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""add is_high_rate to public_holidays
|
||||||
|
|
||||||
|
Revision ID: 0010
|
||||||
|
Revises: 0009_absence_correction_note
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '0010_public_holidays'
|
||||||
|
down_revision = '0009_absence_correction_note'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('public_holidays', sa.Column(
|
||||||
|
'is_high_rate', sa.Boolean(), nullable=False, server_default='false'
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('public_holidays', 'is_high_rate')
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""add name_format to caldav_company_configs
|
||||||
|
|
||||||
|
Revision ID: 0011
|
||||||
|
Revises: 0010_public_holidays
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '0011_caldav_name_format'
|
||||||
|
down_revision = '0010_public_holidays'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('caldav_company_configs', sa.Column(
|
||||||
|
'name_format', sa.String(20), nullable=False, server_default='full'
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('caldav_company_configs', 'name_format')
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""caldav name_template + users.kuerzel
|
||||||
|
|
||||||
|
Revision ID: 0012
|
||||||
|
Revises: 0011_caldav_name_format
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = '0012_caldav_template_and_kuerzel'
|
||||||
|
down_revision = '0011_caldav_name_format'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
# Mapping alter Festwert → neues Template
|
||||||
|
_MIGRATION_MAP = {
|
||||||
|
'full': '$vorname $nachname – $typ',
|
||||||
|
'short': '$vorname_short. $nachname – $typ',
|
||||||
|
'initials': '$kuerzel – $typ',
|
||||||
|
'type_only': '$typ',
|
||||||
|
}
|
||||||
|
_DEFAULT = '$vorname $nachname – $typ'
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# 1. Neue Spalte name_template hinzufügen
|
||||||
|
op.add_column('caldav_company_configs', sa.Column(
|
||||||
|
'name_template', sa.Text(), nullable=False,
|
||||||
|
server_default=_DEFAULT,
|
||||||
|
))
|
||||||
|
|
||||||
|
# 2. Bestehende Werte aus name_format migrieren
|
||||||
|
for old_val, new_val in _MIGRATION_MAP.items():
|
||||||
|
op.execute(
|
||||||
|
f"UPDATE caldav_company_configs "
|
||||||
|
f"SET name_template = '{new_val}' "
|
||||||
|
f"WHERE name_format = '{old_val}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Alte Spalte entfernen
|
||||||
|
op.drop_column('caldav_company_configs', 'name_format')
|
||||||
|
|
||||||
|
# 4. Kürzel-Spalte zu users hinzufügen
|
||||||
|
op.add_column('users', sa.Column(
|
||||||
|
'kuerzel', sa.String(20), nullable=True
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.add_column('caldav_company_configs', sa.Column(
|
||||||
|
'name_format', sa.String(20), nullable=False, server_default='full'
|
||||||
|
))
|
||||||
|
op.drop_column('caldav_company_configs', 'name_template')
|
||||||
|
op.drop_column('users', 'kuerzel')
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""projects table + time_entries FK
|
||||||
|
|
||||||
|
Revision ID: 0013_projects
|
||||||
|
Revises: 0012_caldav_template_and_kuerzel
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "0013_projects"
|
||||||
|
down_revision = "0012_caldav_template_and_kuerzel"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Projekte-Tabelle
|
||||||
|
op.create_table(
|
||||||
|
"projects",
|
||||||
|
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("company_id", UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("companies.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("name", sa.String(100), nullable=False),
|
||||||
|
sa.Column("description", sa.Text, nullable=True),
|
||||||
|
sa.Column("color", sa.String(7), nullable=False, server_default="#3B82F6"),
|
||||||
|
sa.Column("budget_hours", sa.Numeric(8, 2), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean, nullable=False, server_default="true"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# FK von time_entries.project_id → projects.id
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_time_entries_project_id",
|
||||||
|
"time_entries", "projects",
|
||||||
|
["project_id"], ["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_time_entries_project_id", "time_entries", type_="foreignkey")
|
||||||
|
op.drop_table("projects")
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""remove projects table
|
||||||
|
|
||||||
|
Revision ID: 0014_remove_projects
|
||||||
|
Revises: 0013_projects
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "0014_remove_projects"
|
||||||
|
down_revision = "0013_projects"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_constraint("fk_time_entries_project_id", "time_entries", type_="foreignkey")
|
||||||
|
op.drop_table("projects")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""add TOTP fields to users
|
||||||
|
|
||||||
|
Revision ID: 0015_totp
|
||||||
|
Revises: 0014_remove_projects
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0015_totp"
|
||||||
|
down_revision = "0014_remove_projects"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("users", sa.Column("totp_secret", sa.String(64), nullable=True))
|
||||||
|
op.add_column("users", sa.Column("totp_enabled", sa.Boolean(), nullable=False, server_default="false"))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "totp_enabled")
|
||||||
|
op.drop_column("users", "totp_secret")
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""add special_days to vacation_balances
|
||||||
|
|
||||||
|
Revision ID: 0016_vacation_special_days
|
||||||
|
Revises: 0015_totp
|
||||||
|
Create Date: 2026-03-28
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0016_vacation_special_days"
|
||||||
|
down_revision = "0015_totp"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"vacation_balances",
|
||||||
|
sa.Column("special_days", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("vacation_balances", "special_days")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user