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:
@@ -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 13–17 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
|
||||
@@ -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.
|
||||
@@ -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 (5–600 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 5–10 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): 30–60 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)
|
||||
Reference in New Issue
Block a user