Phase 8: CSV + ICS export, print view, MkDocs docs, SBOM + dev-mode auth

Auth (Test-Mode):
  - middleware.AuthMiddleware now takes a devMode flag. In dev,
    requests without Authorization fall back to a deterministic dev
    UUID (00000000-...-001) and role=teacher. ENVIRONMENT=production
    re-enables the strict 401 path.
  - main.go wires devMode = cfg.Environment != "production".
  - page.tsx replaces the red 'Anmeldung noch nicht integriert' banner
    with a softer Testumgebung notice; the manual-token form moves
    behind a nested details block.

Export endpoints (school-service):
  - LoadExportLessons joins tt_lesson with tt_period for wall-clock
    times; one query feeds both CSV and ICS.
  - WriteCSV streams 10 columns including pinned flag.
  - WriteICS emits one VEVENT per lesson anchored to a Monday — caller
    overridable via ?start=YYYY-MM-DD. RFC 5545 escapes for ',', ';',
    '\n' in icsEscape().
  - NextMonday helper for the default anchor.
  - GET /timetable/solutions/:id/export.{csv,ics} handlers attach
    Content-Disposition: attachment so browsers download instead of
    rendering.

Frontend:
  - lib/stundenplan/api.ts downloadSolutionExport() fetches as blob,
    triggers a synthetic <a download> click, and forwards the JWT when
    present.
  - PlanView gains CSV / ICS / Drucken buttons next to the perspective
    selector. The toolbar carries class 'no-print' so window.print()
    yields only the grid.
  - globals.css @media print rule hides chrome, forces white
    background, gives the table proper borders for A4.

Docs:
  - docs-src/services/stundenplan/{index,architecture,constraints,
    solver-tuning,export}.md with nav entry in mkdocs.yml under
    Services → Stundenplaner.
  - sbom/stundenplan/README.md lists manually-verified key dependencies
    and the policy reference. scripts/stundenplan-sbom.sh generates
    full machine-readable inventories via go-licenses + pip-licenses
    + license-checker when those tools are available.

Tests:
  - internal/services/timetable_exports_test.go: 4 unit tests covering
    CSV column layout + quoting, ICS structure + DTSTART formatting,
    icsEscape special chars, NextMonday weekday math.
  - studio-v2/e2e/stundenplan-export.spec.ts split out of the main spec
    file (LOC budget) — 3 tests for button render, CSV download,
    ICS download.
  - mockSchoolApi extended with export.csv + export.ics routes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-05-22 08:57:07 +02:00
parent bf5ea860cc
commit 306886a42b
20 changed files with 1014 additions and 43 deletions
@@ -0,0 +1,76 @@
# Architektur + Datenmodell
## Verantwortung pro Service
### school-service (Go/Gin)
- Persistenz fuer alle Stammdaten + Constraints + Solutions
- API-Gateway fuer studio-v2: validiert, ownership-checked, faked Auth im
Dev-Mode
- Trigger-Aufruf an solver-service nach POST /solutions
### timetable-solver-service (Python/FastAPI + Timefold)
- Liest Problem aus PG via asyncpg
- Baut Timefold-Domain (Lessons, Timeslots, Rooms, Rules)
- Loest im ThreadPoolExecutor (Solver ist CPU-gebunden)
- Schreibt Loesung direkt nach tt_lesson, updated tt_solution.status
### studio-v2 (Next.js)
- `/stundenplan` Tab-Page mit 9 Tabs
- Next.js API-Route `/api/school/*` proxied zu school-service
- Solution-Polling alle 4 s wenn Solve laeuft
## Datenmodell
### Stammdaten (7 Tabellen)
| Tabelle | Inhalt | Owner |
|---------|--------|-------|
| tt_class | Klassen (Name, Klassenstufe) | created_by_user_id |
| tt_period | Zeitraster (Mo-So × Stunde × Start/Ende) | created_by_user_id |
| tt_room | Raeume (Name, Typ, Kapazitaet, Aufzug) | created_by_user_id |
| tt_subject | Faecher (Name, Kuerzel, Farbe, RoomType) | created_by_user_id |
| tt_teacher | Lehrer als planbare Ressource | created_by_user_id |
| tt_curriculum | Klasse × Fach → Wochenstunden | indirect via tt_class |
| tt_assignment | Lehrer × Klasse × Fach | indirect via tt_teacher |
### Constraints (15 Tabellen)
Pro Tabelle die Felder: `is_hard` (bool), `weight` (0-100), `active` (bool),
`note` (TEXT), `created_by_user_id`. Aufgeteilt nach Parent-Entitaet:
- Lehrer: `unavailable_day`, `unavailable_window`, `max_hours_day`,
`max_hours_week`, `excluded_subject`, `excluded_room`
- Fach: `min_day_gap`, `max_consecutive`, `contiguous_when_repeated`,
`preferred_period`, `double_lesson`
- Klasse: `max_hours_day`, `no_gaps`
- Raum: `requires_type`, `unavailable`
### Solutions (2 Tabellen, Phase 5+7)
| Tabelle | Inhalt |
|---------|--------|
| tt_solution | Solve-Run: Status, hard/soft Score, parent_solution_id, seconds_limit |
| tt_lesson | Eine Stunde im Plan (class, subject, teacher, room, day, period, pinned) |
`tt_lesson` hat drei `UNIQUE`-Constraints, die der DB-Layer selbst Konflikt-
Lessons ablehnen laesst:
- `(solution_id, class_id, day, period)` — Klasse nicht doppelt
- `(solution_id, teacher_id, day, period)` — Lehrer nicht doppelt
- `(solution_id, room_id, day, period)` — Raum nicht doppelt
Damit kann ein fehlerhafter Solver-Output nicht in Daten landen, die das UI
inkonsistent darstellt.
## Ownership-Modell
Alles ist single-tenant pro `created_by_user_id`. CRUD-Endpoints filtern via
`WHERE EXISTS (SELECT 1 FROM tt_<parent> WHERE id = $X AND created_by_user_id
= $user)`. Cross-Tenant-Zugriff ist auf SQL-Ebene ausgeschlossen.
Im Dev-Mode injiziert `AuthMiddleware` einen festen UUID, damit Tests ohne
JWT laufen koennen. Production-Build (`ENVIRONMENT=production`) deaktiviert
den Bypass — JWT wird Pflicht.
@@ -0,0 +1,71 @@
# Constraint-Referenz
Jeder Constraint-Eintrag im UI legt eine Row in der korrespondierenden
`tt_constraint_*` Tabelle an. Der Solver liest sie als Problem-Facts und
joined sie gegen die Lessons.
## Gemeinsame Felder
| Feld | Typ | Bedeutung |
|------|-----|-----------|
| `is_hard` | bool | true = Solver muss einhalten (HardScore -1 pro Verstoss). false = Soft-Penalty (SoftScore -weight pro Verstoss) |
| `weight` | int 0-100 | Multiplikator fuer Soft-Penalties; bei Hard ignoriert |
| `active` | bool | inaktive Rows werden vom Solver ignoriert |
| `note` | TEXT | Freier Begruendungstext fuer den Rektor |
## Constraint-Typen
### Universal (immer aktiv, nicht abschaltbar)
| Constraint | Bedeutung |
|------------|-----------|
| `class_conflict` | Eine Klasse hat nur eine Lesson pro Timeslot |
| `teacher_conflict` | Ein Lehrer haelt nur eine Lesson pro Timeslot |
| `room_conflict` | Ein Raum hostet nur eine Lesson pro Timeslot |
### DB-Driven (vom Rektor konfigurierbar)
Jeder Typ existiert als `_hard` und `_soft` Constraint im Provider:
| Typ | Tabelle | Beispiel |
|-----|---------|----------|
| Lehrer Tag nicht verfuegbar | `tt_constraint_teacher_unavailable_day` | „Anna nie Montags" |
| Lehrer Zeitfenster nicht verfuegbar | `tt_constraint_teacher_unavailable_window` | „Bob Dienstag 1317 Uhr nicht" |
| Lehrer Max h/Tag | `tt_constraint_teacher_max_hours_day` | Anti-Burnout |
| Lehrer Max h/Woche | `tt_constraint_teacher_max_hours_week` | Teilzeit-Cap |
| Lehrer Fach ausgeschlossen | `tt_constraint_teacher_excluded_subject` | Qualifikationsluecke |
| Lehrer Raum ausgeschlossen | `tt_constraint_teacher_excluded_room` | Rollstuhl, kein Fahrstuhl |
| Fach Mindest-Tagesabstand | `tt_constraint_subject_min_day_gap` | Mathe nicht 2 Tage hintereinander |
| Fach Max Stunden am Stueck | `tt_constraint_subject_max_consecutive` | Keine Dreifachstunde |
| Fach Mehrfach=zusammen | `tt_constraint_subject_contiguous_when_repeated` | Wenn 2× am Tag, dann benachbart |
| Fach Bevorzugte Stunden | `tt_constraint_subject_preferred_period` | Hauptfaecher morgens |
| Fach Doppelstunde bevorzugt | `tt_constraint_subject_double_lesson` | Sport als 90-min-Block |
| Klasse Max h/Tag | `tt_constraint_class_max_hours_day` | Jugendgerecht |
| Klasse Keine Freistunden | `tt_constraint_class_no_gaps` | Soft, minimiert Loecher |
| Raumtyp erforderlich | `tt_constraint_room_requires_type` | Sport → Sporthalle |
| Raum nicht verfuegbar | `tt_constraint_room_unavailable` | Wartung, Renovierung |
## Hard vs. Soft — Faustregel
- **Hard** wenn die Schule den Plan rechtlich oder physisch nicht
ausfuehren kann (Lehrervertrag, Behinderung, Raum existiert nicht).
- **Soft** wenn es nur eine Praeferenz ist („Mathe lieber morgens",
„keine Freistunden").
Score-Bewertung im UI:
- `hard_score = 0` → Plan ist gueltig
- `hard_score < 0` → mindestens eine harte Regel ist verletzt (Solver
meldet das als `infeasible`)
- `soft_score` → wird in den UI angezeigt; je naeher an 0, desto besser
## Erweitern um einen 16. Constraint-Typ
1. Neue Tabelle in `school-service/internal/database/timetable_constraints_migrations.go`
2. Model + DTO in `models/timetable_constraints.go`
3. Service + Handler im gleichen Paket-Pattern wie die existierenden 15
4. Route in `cmd/server/main.go`
5. Rule-Dataclass in `timetable-solver-service/app/rules.py`
6. ProblemFactCollection in `domain.py`
7. ConstraintProvider-Funktion in `constraints.py` (Hard + Soft Variante)
8. Frontend: Editor-Komponente in `_components/regeln/`, dann in
`RegelnHub.tsx` registrieren
+72
View File
@@ -0,0 +1,72 @@
# Export
Drei Export-Formate fuer fertige Solutions, alle als GET-Endpoints im
school-service.
## CSV
```
GET /api/v1/school/timetable/solutions/{id}/export.csv
Content-Type: text/csv; charset=utf-8
```
Spalten: `day_of_week,period_index,start_time,end_time,class,subject,
subject_code,teacher,room,pinned`.
Komma in Feldwerten (z.B. „Schmidt, Anna") wird automatisch escaped.
Sortierung: by `(day_of_week, period_index, class_name)`.
Anwendungsfaelle:
- Import in Excel oder Google Sheets fuer Reports
- Datenuebergabe an externes Schulverwaltungs-System
- Datenarchivierung pro Schuljahr
## ICS (iCalendar, RFC 5545)
```
GET /api/v1/school/timetable/solutions/{id}/export.ics
GET /api/v1/school/timetable/solutions/{id}/export.ics?start=2026-08-24
Content-Type: text/calendar; charset=utf-8
```
Emittiert ein VEVENT pro Lesson, anchored auf die naechste Montag-Woche
(oder via `?start=YYYY-MM-DD` ueberschreibbar). Lehrer kann die Datei
direkt im Apple Calendar, Google Calendar oder Outlook importieren.
Strukturbeispiel:
```
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//BreakPilot//Timetable//DE
BEGIN:VEVENT
UID:lesson-0-d1p1-20260824@breakpilot
DTSTAMP:20260522T144800Z
DTSTART:20260824T080000
DTEND:20260824T084500
SUMMARY:Mathe (5a)
LOCATION:A101
DESCRIPTION:Lehrer: Schmidt, Anna\nSchuljahr 26/27
END:VEVENT
...
END:VCALENDAR
```
**Aktuell nur eine Kalender-Woche** — fuer ganzes Schuljahr braeuchte es
RRULE + Ferien-Exceptions, ist als Phase 9 vorgemerkt.
## Drucken (HTML print view)
Im PlanView gibt es einen „Drucken"-Button. Der Druck-Dialog des Browsers
oeffnet sich; eigene `@media print`-Stylesheet in `globals.css` blendet
Sidebar, Tabs, Help-Panel und Token-Banner aus und zwingt das Wochengrid auf
weisses A4-Format.
Vorteil ueber serverseitiges PDF: kein zusaetzliches Backend-Tool, keine
Headless-Browser-Container, der User waehlt selbst Drucker/PDF/Format.
## Aufruf vom Frontend
`lib/stundenplan/api.ts:downloadSolutionExport(solutionId, 'csv' | 'ics')`
laedt das Blob ueber den Next.js-Proxy, sodass der JWT (falls gesetzt) im
Authorization-Header weitergegeben wird. Im Dev-Mode ohne Token funktioniert
es ebenfalls.
+47
View File
@@ -0,0 +1,47 @@
# Stundenplaner
Schulweiter Stundenplan-Generator fuer den Rektor. Erfasst Klassen, Lehrer,
Faecher, Raeume + Constraints und ruft einen Timefold-basierten Solver auf,
um einen konfliktfreien Wochenplan zu produzieren.
## Architektur auf einen Blick
```
studio-v2 /stundenplan (Next.js)
│ HTTP über Next.js Proxy /api/school/*
school-service (Go/Gin, :8084)
│ ─ CRUD Stammdaten + Constraints + Solutions in PostgreSQL
│ ─ Fire-and-forget Trigger an Solver
timetable-solver-service (Python/FastAPI + Timefold, :8095)
│ ─ Liest Problem aus PG, rechnet im Worker-Thread
│ ─ Schreibt Lessons direkt nach tt_lesson
PostgreSQL (Schema `public` in `breakpilot_db`)
24 Tabellen: 7 Stammdaten + 15 Constraints + tt_solution + tt_lesson
```
## Module
| Bereich | Doku |
|---------|------|
| [Architektur + Datenmodell](architecture.md) | DB-Schema, Ownership-Modell |
| [Constraints](constraints.md) | 15 Constraint-Typen, hard/soft Semantik |
| [Solver-Tuning](solver-tuning.md) | Timefold-Konfiguration, Zeit-Budgets |
| [Export](export.md) | CSV, ICS, Drucken |
## Status
**Phasen 1-3 + 5-8 fertig** (Stand 2026-05-22, Phase 4 Untis übersprungen).
- 24 DB-Tabellen, alle 22 CRUD-Endpoints + Solve + Export-Endpoints live
- Frontend: 9 Tabs (Plan + 7 Stammdaten + Regeln), 15 Constraint-Editoren,
Wochengrid mit Pin-Funktion, 3 Perspektiven (Klasse/Lehrer/Raum)
- Tests: 73 Go + 36 Playwright + 4 Export-Unit-Tests
## Offene Punkte
- Phase 4 (Untis-Import) — verschoben, kein Kunde fordert es aktuell
- Seed-Daten fuer Demo-Schule
- Echte Auth-Integration ablöst Dev-Bypass
@@ -0,0 +1,70 @@
# Solver-Tuning
## Timefold-Konfiguration
Der `SolverFactory` wird in `runner.py` pro Solve gebaut so dass jedes
Job-Spent-Limit aus `tt_solution.seconds_limit` einzeln zur Geltung kommt.
```python
SolverConfig(
solution_class=Timetable,
entity_class_list=[Lesson],
score_director_factory_config=ScoreDirectorFactoryConfig(
constraint_provider_function=define_constraints,
),
termination_config=TerminationConfig(
spent_limit=Duration(seconds=seconds),
),
)
```
Default Timeout: 60 s. Per Solve ueberschreibbar im UI (5600 s).
## Score-Modell
`HardSoftScore` — Hard-Komponente ist die wichtige:
- `hard_score < 0` → Solution ist `infeasible`, UI markiert in Amber.
- `hard_score == 0` → Solution gueltig, `soft_score` minimiert Praeferenz-
Verletzungen.
## Pinning fuer iterative Verbesserung
Workflow:
1. Initial-Solve laeuft → Plan A.
2. Rektor pinnt 510 Cells im UI, die ihm gefallen.
3. Neuer Solve mit `parent_solution_id = Plan A`. Der Solver nimmt die
gepinnten Cells als Fixpunkte (`@PlanningPin`) und rechnet die restlichen
Lessons neu.
4. Optional Sekunden-Limit erhoehen (z.B. 180 s) wenn die Solution-Qualitaet
wichtiger ist als die Wartezeit.
Implementierung in `repository._inherit_pinned_from_parent()`:
- Greedy First-Fit-Matching by `(class_id, subject_id)`
- Surplus pinned Rows aus dem Parent (z.B. weil curriculum-Stunden gekuerzt)
werden silently uebersprungen
- Mismatch wird in Logs ausgegeben, fuehrt aber nicht zu failed Status
## Was tun wenn der Solver `infeasible` meldet
Reihenfolge der Diagnose:
1. **Lessons-Count vs. Slots-Count**: Wenn die Summe der Wochenstunden ueber
alle Klassen > Anzahl Slots pro Woche × Anzahl Raeume ist, kann es
physisch keine Loesung geben. Stundentafel kuerzen oder mehr Raeume.
2. **Lehrer-Auslastung**: Wenn ein Lehrer mit 28 h Cap in der Stundentafel
30 h zugewiesen bekommt, ist es unloesbar. Lehrauftraege anpassen.
3. **Harte Constraints widerspruechlich**: Mathe muss morgens UND ist
`excluded_room` fuer alle Vormittags-Raeume → Konflikt. Constraints von
Hard auf Soft umstellen wo moeglich.
4. **Sekunden-Limit zu kurz**: Bei sehr restriktiven Modellen braucht der
Solver laenger zum ersten Fit-finden. 300 s probieren.
## Performance-Charakteristik
- Kleine Schule (3 Klassen, 8 Lehrer, 6 Faecher, ~80 Lessons): meist <5 s
- Mittlere Schule (15 Klassen, 30 Lehrer, ~400 Lessons): 3060 s fuer
hard_score=0, weitere Minuten fuer soft-Optimierung
- Sehr grosse Schule (>800 Lessons): Solver kommt mit 60 s Default nicht
konvergent, hoeheres Limit oder Multi-Threading evaluieren (Timefold
Enterprise)