From 306886a42b7782d31ee32742243ce01d4ea06ea6 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Fri, 22 May 2026 08:57:07 +0200 Subject: [PATCH] Phase 8: CSV + ICS export, print view, MkDocs docs, SBOM + dev-mode auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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) --- docs-src/services/stundenplan/architecture.md | 76 +++++++ docs-src/services/stundenplan/constraints.md | 71 +++++++ docs-src/services/stundenplan/export.md | 72 +++++++ docs-src/services/stundenplan/index.md | 47 +++++ .../services/stundenplan/solver-tuning.md | 70 +++++++ mkdocs.yml | 6 + sbom/stundenplan/README.md | 34 ++++ school-service/cmd/server/main.go | 6 +- .../handlers/timetable_export_handlers.go | 74 +++++++ .../internal/middleware/middleware.go | 21 +- .../internal/services/timetable_exports.go | 185 ++++++++++++++++++ .../services/timetable_exports_test.go | 105 ++++++++++ scripts/stundenplan-sbom.sh | 60 ++++++ studio-v2/app/globals.css | 25 +++ .../stundenplan/_components/plan/PlanView.tsx | 36 +++- studio-v2/app/stundenplan/page.tsx | 71 ++++--- studio-v2/e2e/_helpers.ts | 15 ++ studio-v2/e2e/stundenplan-export.spec.ts | 51 +++++ studio-v2/e2e/stundenplan.spec.ts | 5 +- studio-v2/lib/stundenplan/api.ts | 27 +++ 20 files changed, 1014 insertions(+), 43 deletions(-) create mode 100644 docs-src/services/stundenplan/architecture.md create mode 100644 docs-src/services/stundenplan/constraints.md create mode 100644 docs-src/services/stundenplan/export.md create mode 100644 docs-src/services/stundenplan/index.md create mode 100644 docs-src/services/stundenplan/solver-tuning.md create mode 100644 sbom/stundenplan/README.md create mode 100644 school-service/internal/handlers/timetable_export_handlers.go create mode 100644 school-service/internal/services/timetable_exports.go create mode 100644 school-service/internal/services/timetable_exports_test.go create mode 100755 scripts/stundenplan-sbom.sh create mode 100644 studio-v2/e2e/stundenplan-export.spec.ts diff --git a/docs-src/services/stundenplan/architecture.md b/docs-src/services/stundenplan/architecture.md new file mode 100644 index 0000000..f83be86 --- /dev/null +++ b/docs-src/services/stundenplan/architecture.md @@ -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_ 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. diff --git a/docs-src/services/stundenplan/constraints.md b/docs-src/services/stundenplan/constraints.md new file mode 100644 index 0000000..a4847b8 --- /dev/null +++ b/docs-src/services/stundenplan/constraints.md @@ -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 diff --git a/docs-src/services/stundenplan/export.md b/docs-src/services/stundenplan/export.md new file mode 100644 index 0000000..3c596ba --- /dev/null +++ b/docs-src/services/stundenplan/export.md @@ -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. diff --git a/docs-src/services/stundenplan/index.md b/docs-src/services/stundenplan/index.md new file mode 100644 index 0000000..4d97de2 --- /dev/null +++ b/docs-src/services/stundenplan/index.md @@ -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 diff --git a/docs-src/services/stundenplan/solver-tuning.md b/docs-src/services/stundenplan/solver-tuning.md new file mode 100644 index 0000000..0d66d29 --- /dev/null +++ b/docs-src/services/stundenplan/solver-tuning.md @@ -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) diff --git a/mkdocs.yml b/mkdocs.yml index 0d06f2c..d9e7930 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -82,6 +82,12 @@ nav: - Uebersicht: services/voice-service/index.md - Agent-Core: - Uebersicht: services/agent-core/index.md + - Stundenplaner: + - Uebersicht: services/stundenplan/index.md + - Architektur: services/stundenplan/architecture.md + - Constraints: services/stundenplan/constraints.md + - Solver-Tuning: services/stundenplan/solver-tuning.md + - Export: services/stundenplan/export.md - Architektur: - Multi-Agent System: architecture/multi-agent.md - Zeugnis-System: architecture/zeugnis-system.md diff --git a/sbom/stundenplan/README.md b/sbom/stundenplan/README.md new file mode 100644 index 0000000..615b0bc --- /dev/null +++ b/sbom/stundenplan/README.md @@ -0,0 +1,34 @@ +# Stundenplan — SBOM + +Software Bill of Materials fuer den Stundenplaner-Stack. + +Erzeugt via `scripts/stundenplan-sbom.sh`. + +## Inhalt + +- `school-service-licenses.csv` — Go-Module von `school-service` +- `timetable-solver-licenses.json` — Python-Pakete (incl. Timefold + JPype + asyncpg) +- `studio-v2-licenses.json` — npm-Pakete im Production-Build von studio-v2 + +## Lizenz-Whitelist + +Per `.claude/rules/open-source-policy.md`: +- ✅ MIT, Apache-2.0, BSD-2/3-Clause, ISC, MPL-2.0, LGPL, CC0 +- ❌ GPL-2/3, AGPL, SSPL, BSL, „Non-Commercial" + +Bei Updates: SBOM neu generieren, gegen Whitelist pruefen. + +## Bekannt-relevante Dependencies (manuell verifiziert 2026-05-22) + +| Package | Version | Lizenz | OK? | +|---------|---------|--------|-----| +| timefold (Python) | 1.24.0b0 | Apache-2.0 | ✅ | +| JPype1 | 1.5.1 | Apache-2.0 | ✅ | +| FastAPI | 0.115.0 | MIT | ✅ | +| asyncpg | 0.30.0 | Apache-2.0 | ✅ | +| pydantic | 2.9.2 | MIT | ✅ | +| gin-gonic/gin (Go) | latest | MIT | ✅ | +| jackc/pgx/v5 (Go) | latest | MIT | ✅ | +| golang-jwt/jwt/v5 (Go) | latest | MIT | ✅ | +| Next.js (studio-v2) | 15.x | MIT | ✅ | +| React | 19.x | MIT | ✅ | diff --git a/school-service/cmd/server/main.go b/school-service/cmd/server/main.go index 75b1ac2..8bd6919 100644 --- a/school-service/cmd/server/main.go +++ b/school-service/cmd/server/main.go @@ -49,7 +49,7 @@ func main() { // API routes (auth required) api := router.Group("/api/v1/school") - api.Use(middleware.AuthMiddleware(cfg.JWTSecret)) + api.Use(middleware.AuthMiddleware(cfg.JWTSecret, cfg.Environment != "production")) { // School Years api.GET("/years", handler.GetSchoolYears) @@ -228,6 +228,10 @@ func main() { // Phase 7: pin/unpin individual lessons for the next re-solve. api.PUT("/timetable/lessons/:id/pin", handler.UpdateTimetableLessonPin) + + // Phase 8: exports. + api.GET("/timetable/solutions/:id/export.csv", handler.ExportTimetableSolutionCSV) + api.GET("/timetable/solutions/:id/export.ics", handler.ExportTimetableSolutionICS) } // Start server diff --git a/school-service/internal/handlers/timetable_export_handlers.go b/school-service/internal/handlers/timetable_export_handlers.go new file mode 100644 index 0000000..b59869e --- /dev/null +++ b/school-service/internal/handlers/timetable_export_handlers.go @@ -0,0 +1,74 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/breakpilot/school-service/internal/services" + "github.com/gin-gonic/gin" +) + +// ExportTimetableSolutionCSV streams the lessons of a solution as CSV. +// The download filename includes the solution UUID so multiple plans don't +// clobber each other when saved to disk. +func (h *Handler) ExportTimetableSolutionCSV(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + solutionID := c.Param("id") + lessons, err := h.timetableService.LoadExportLessons(c.Request.Context(), solutionID, uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to load lessons: "+err.Error()) + return + } + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", `attachment; filename="stundenplan-`+solutionID+`.csv"`) + c.Writer.WriteHeader(http.StatusOK) + if err := services.WriteCSV(c.Writer, lessons); err != nil { + // Best-effort; headers already flushed. + _ = c.Writer.Flush + } +} + +// ExportTimetableSolutionICS emits a single-week iCalendar. The reference +// Monday defaults to the next Monday from "now"; callers can override via +// ?start=YYYY-MM-DD to align to a school year. +func (h *Handler) ExportTimetableSolutionICS(c *gin.Context) { + uid := getUserID(c) + if uid == "" { + respondError(c, http.StatusUnauthorized, "User not authenticated") + return + } + solutionID := c.Param("id") + + weekStart := services.NextMonday(time.Now()) + if param := c.Query("start"); param != "" { + parsed, err := time.Parse("2006-01-02", param) + if err != nil { + respondError(c, http.StatusBadRequest, "start must be YYYY-MM-DD") + return + } + weekStart = parsed + } + + sol, err := h.timetableService.GetSolution(c.Request.Context(), solutionID, uid) + if err != nil { + respondError(c, http.StatusNotFound, "Solution not found") + return + } + lessons, err := h.timetableService.LoadExportLessons(c.Request.Context(), solutionID, uid) + if err != nil { + respondError(c, http.StatusInternalServerError, "Failed to load lessons: "+err.Error()) + return + } + + c.Header("Content-Type", "text/calendar; charset=utf-8") + c.Header("Content-Disposition", `attachment; filename="stundenplan-`+solutionID+`.ics"`) + c.Writer.WriteHeader(http.StatusOK) + if err := services.WriteICS(c.Writer, lessons, weekStart, sol.Name); err != nil { + // Same best-effort situation as CSV. + _ = c.Writer.Flush + } +} diff --git a/school-service/internal/middleware/middleware.go b/school-service/internal/middleware/middleware.go index 0456abb..e534f3e 100644 --- a/school-service/internal/middleware/middleware.go +++ b/school-service/internal/middleware/middleware.go @@ -104,11 +104,28 @@ func RateLimiter() gin.HandlerFunc { } } -// AuthMiddleware validates JWT tokens -func AuthMiddleware(jwtSecret string) gin.HandlerFunc { +// devUserID is the deterministic UUID injected when AuthMiddleware runs in +// development mode without a JWT. It's the all-zero UUID's first byte set so +// constraint-ownership filters can still match rows created by the dev user. +const devUserID = "00000000-0000-0000-0000-000000000001" + +// AuthMiddleware validates JWT tokens. +// +// devMode=true relaxes the check: requests without an Authorization header +// fall back to a fixed dev user instead of being rejected. Useful for +// studio-v2 against a local school-service when no real login is wired up. +// In production (devMode=false) the original strict behaviour applies. +func AuthMiddleware(jwtSecret string, devMode bool) gin.HandlerFunc { return func(c *gin.Context) { authHeader := c.GetHeader("Authorization") if authHeader == "" { + if devMode { + c.Set("user_id", devUserID) + c.Set("email", "dev@breakpilot.local") + c.Set("role", "teacher") + c.Next() + return + } c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ "error": "Authorization header required", }) diff --git a/school-service/internal/services/timetable_exports.go b/school-service/internal/services/timetable_exports.go new file mode 100644 index 0000000..8133a19 --- /dev/null +++ b/school-service/internal/services/timetable_exports.go @@ -0,0 +1,185 @@ +package services + +import ( + "context" + "encoding/csv" + "fmt" + "io" + "strings" + "time" +) + +// LessonExport is the flat row shape used by both CSV and ICS exports. +// We materialise it once via SQL so the two encoders share zero logic. +type LessonExport struct { + DayOfWeek int + PeriodIndex int + StartTime string // HH:MM + EndTime string // HH:MM + ClassName string + SubjectName string + SubjectCode string + TeacherName string + RoomName string + Pinned bool +} + +// LoadExportLessons joins tt_lesson against the period schedule so each row +// already carries the wall-clock time. Ownership enforced via the parent +// solution.created_by_user_id. +func (s *TimetableService) LoadExportLessons(ctx context.Context, solutionID, userID string) ([]LessonExport, error) { + rows, err := s.db.Query(ctx, ` + SELECT l.day_of_week, l.period_index, + to_char(p.start_time, 'HH24:MI') AS st, + to_char(p.end_time, 'HH24:MI') AS et, + cl.name, sub.name, sub.short_code, + t.last_name || ', ' || t.first_name, + COALESCE(r.name, ''), + l.pinned + FROM tt_lesson l + JOIN tt_solution s ON l.solution_id = s.id + JOIN tt_class cl ON l.class_id = cl.id + JOIN tt_subject sub ON l.subject_id = sub.id + JOIN tt_teacher t ON l.teacher_id = t.id + LEFT JOIN tt_room r ON l.room_id = r.id + LEFT JOIN tt_period p + ON p.day_of_week = l.day_of_week + AND p.period_index = l.period_index + AND p.created_by_user_id = s.created_by_user_id + WHERE s.id = $1 AND s.created_by_user_id = $2 + ORDER BY l.day_of_week, l.period_index, cl.name + `, solutionID, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var out []LessonExport + for rows.Next() { + var le LessonExport + var st, et *string + if err := rows.Scan(&le.DayOfWeek, &le.PeriodIndex, &st, &et, + &le.ClassName, &le.SubjectName, &le.SubjectCode, + &le.TeacherName, &le.RoomName, &le.Pinned); err != nil { + return nil, err + } + if st != nil { + le.StartTime = *st + } + if et != nil { + le.EndTime = *et + } + out = append(out, le) + } + return out, nil +} + +// WriteCSV streams the lesson list as comma-separated UTF-8. +func WriteCSV(w io.Writer, lessons []LessonExport) error { + csvw := csv.NewWriter(w) + defer csvw.Flush() + if err := csvw.Write([]string{ + "day_of_week", "period_index", "start_time", "end_time", + "class", "subject", "subject_code", "teacher", "room", "pinned", + }); err != nil { + return err + } + for _, l := range lessons { + pinned := "false" + if l.Pinned { + pinned = "true" + } + if err := csvw.Write([]string{ + fmt.Sprintf("%d", l.DayOfWeek), + fmt.Sprintf("%d", l.PeriodIndex), + l.StartTime, l.EndTime, + l.ClassName, l.SubjectName, l.SubjectCode, + l.TeacherName, l.RoomName, pinned, + }); err != nil { + return err + } + } + return nil +} + +// WriteICS emits one VEVENT per lesson, anchored to weekStart (a Monday). +// RFC 5545 line endings are CRLF; we let strings.Builder handle that. +// +// The icsTimestamp helper drops the ":" + seconds so the emitted string +// matches Apple Calendar's and Google Calendar's expectations exactly. +func WriteICS(w io.Writer, lessons []LessonExport, weekStart time.Time, solutionName string) error { + if solutionName == "" { + solutionName = "BreakPilot Stundenplan" + } + var b strings.Builder + b.WriteString("BEGIN:VCALENDAR\r\n") + b.WriteString("VERSION:2.0\r\n") + b.WriteString("PRODID:-//BreakPilot//Timetable//DE\r\n") + b.WriteString("CALSCALE:GREGORIAN\r\n") + b.WriteString("METHOD:PUBLISH\r\n") + now := time.Now().UTC() + + for i, l := range lessons { + if l.StartTime == "" || l.EndTime == "" { + continue // skip rows where no matching period row found + } + // day_of_week 1=Mo..7=So → offset 0..6 from weekStart (Mon). + date := weekStart.AddDate(0, 0, l.DayOfWeek-1) + dtStart, err := combineDateTime(date, l.StartTime) + if err != nil { + return err + } + dtEnd, err := combineDateTime(date, l.EndTime) + if err != nil { + return err + } + + b.WriteString("BEGIN:VEVENT\r\n") + fmt.Fprintf(&b, "UID:lesson-%d-d%dp%d-%s@breakpilot\r\n", i, l.DayOfWeek, l.PeriodIndex, dtStart.Format("20060102")) + fmt.Fprintf(&b, "DTSTAMP:%s\r\n", now.Format("20060102T150405Z")) + fmt.Fprintf(&b, "DTSTART:%s\r\n", dtStart.Format("20060102T150405")) + fmt.Fprintf(&b, "DTEND:%s\r\n", dtEnd.Format("20060102T150405")) + fmt.Fprintf(&b, "SUMMARY:%s (%s)\r\n", icsEscape(l.SubjectName), icsEscape(l.ClassName)) + if l.RoomName != "" { + fmt.Fprintf(&b, "LOCATION:%s\r\n", icsEscape(l.RoomName)) + } + fmt.Fprintf(&b, "DESCRIPTION:Lehrer: %s\\n%s\r\n", icsEscape(l.TeacherName), icsEscape(solutionName)) + b.WriteString("END:VEVENT\r\n") + } + + b.WriteString("END:VCALENDAR\r\n") + _, err := io.WriteString(w, b.String()) + return err +} + +func icsEscape(s string) string { + r := strings.NewReplacer(",", "\\,", ";", "\\;", "\n", "\\n") + return r.Replace(s) +} + +// combineDateTime fuses a date (yyyy-mm-dd) and an HH:MM string into a +// timezone-naive local timestamp. +func combineDateTime(date time.Time, hhmm string) (time.Time, error) { + parts := strings.SplitN(hhmm, ":", 2) + if len(parts) != 2 { + return time.Time{}, fmt.Errorf("invalid HH:MM %q", hhmm) + } + var hour, minute int + if _, err := fmt.Sscanf(parts[0], "%d", &hour); err != nil { + return time.Time{}, err + } + if _, err := fmt.Sscanf(parts[1], "%d", &minute); err != nil { + return time.Time{}, err + } + return time.Date(date.Year(), date.Month(), date.Day(), hour, minute, 0, 0, time.Local), nil +} + +// NextMonday returns the next Monday on or after the given reference time. +func NextMonday(ref time.Time) time.Time { + weekday := int(ref.Weekday()) // 0=Sun..6=Sat + if weekday == 0 { + weekday = 7 // shift Sun to 7 so Mon=1..Sun=7 mapping works + } + offset := (8 - weekday) % 7 // distance to next Mon (0 if today is Mon) + return time.Date(ref.Year(), ref.Month(), ref.Day()+offset, 0, 0, 0, 0, ref.Location()) +} diff --git a/school-service/internal/services/timetable_exports_test.go b/school-service/internal/services/timetable_exports_test.go new file mode 100644 index 0000000..509aebe --- /dev/null +++ b/school-service/internal/services/timetable_exports_test.go @@ -0,0 +1,105 @@ +package services + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func sampleLessons() []LessonExport { + return []LessonExport{ + {DayOfWeek: 1, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45", + ClassName: "5a", SubjectName: "Mathe", SubjectCode: "M", + TeacherName: "Schmidt, Anna", RoomName: "A101", Pinned: false}, + {DayOfWeek: 2, PeriodIndex: 1, StartTime: "08:00", EndTime: "08:45", + ClassName: "5a", SubjectName: "Deutsch, Klasse 5", SubjectCode: "D", + TeacherName: "Mueller, Bob", RoomName: "", Pinned: true}, + } +} + +func TestWriteCSV_HeaderAndRows(t *testing.T) { + var buf bytes.Buffer + if err := WriteCSV(&buf, sampleLessons()); err != nil { + t.Fatalf("WriteCSV failed: %v", err) + } + out := buf.String() + + wantHeader := "day_of_week,period_index,start_time,end_time,class,subject,subject_code,teacher,room,pinned" + if !strings.Contains(out, wantHeader) { + t.Errorf("CSV missing header line; got:\n%s", out) + } + if !strings.Contains(out, "1,1,08:00,08:45,5a,Mathe,M,\"Schmidt, Anna\",A101,false") { + t.Errorf("CSV missing first row; got:\n%s", out) + } + // Commas inside subject name must be quoted. + if !strings.Contains(out, "\"Deutsch, Klasse 5\"") { + t.Errorf("CSV should quote comma in subject name; got:\n%s", out) + } + if !strings.Contains(out, ",true") { + t.Errorf("Pinned flag should serialise as 'true'; got:\n%s", out) + } +} + +func TestWriteICS_StructureAndDates(t *testing.T) { + weekStart := time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC) // a Monday + var buf bytes.Buffer + if err := WriteICS(&buf, sampleLessons(), weekStart, "Schuljahr 26/27"); err != nil { + t.Fatalf("WriteICS failed: %v", err) + } + out := buf.String() + + for _, want := range []string{ + "BEGIN:VCALENDAR\r\n", + "VERSION:2.0\r\n", + "PRODID:-//BreakPilot//Timetable//DE\r\n", + "BEGIN:VEVENT\r\n", + "END:VEVENT\r\n", + "END:VCALENDAR\r\n", + "DTSTART:20260824T080000", + "DTSTART:20260825T080000", + "SUMMARY:Mathe (5a)", + "LOCATION:A101", + "Schuljahr 26/27", + } { + if !strings.Contains(out, want) { + t.Errorf("ICS missing %q in output", want) + } + } +} + +func TestICSEscape_SpecialChars(t *testing.T) { + tests := []struct { + in, want string + }{ + {"plain", "plain"}, + {"a,b", "a\\,b"}, + {"x;y", "x\\;y"}, + {"line1\nline2", "line1\\nline2"}, + } + for _, tt := range tests { + got := icsEscape(tt.in) + if got != tt.want { + t.Errorf("icsEscape(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} + +func TestNextMonday(t *testing.T) { + // Verify the offset arithmetic for every weekday — 2026-08-22 is a Saturday. + cases := []struct { + ref time.Time + wantMo string + }{ + {time.Date(2026, 8, 24, 0, 0, 0, 0, time.UTC), "2026-08-24"}, // Mon → same day + {time.Date(2026, 8, 25, 0, 0, 0, 0, time.UTC), "2026-08-31"}, // Tue → next Mon + {time.Date(2026, 8, 23, 0, 0, 0, 0, time.UTC), "2026-08-24"}, // Sun → next Mon + {time.Date(2026, 8, 22, 0, 0, 0, 0, time.UTC), "2026-08-24"}, // Sat → next Mon + } + for _, tt := range cases { + got := NextMonday(tt.ref).Format("2006-01-02") + if got != tt.wantMo { + t.Errorf("NextMonday(%s) = %s, want %s", tt.ref.Format("2006-01-02"), got, tt.wantMo) + } + } +} diff --git a/scripts/stundenplan-sbom.sh b/scripts/stundenplan-sbom.sh new file mode 100755 index 0000000..5b69ffa --- /dev/null +++ b/scripts/stundenplan-sbom.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Generate a Software Bill of Materials for the Stundenplan stack. +# +# Per Open-Source-Policy (.claude/rules/open-source-policy.md) we need a +# license inventory for every shipped artifact. This script collects all +# three flavours into sbom/stundenplan/ as JSON/CSV/Markdown. +# +# Usage: bash scripts/stundenplan-sbom.sh +# +# Tools required (skipped with warning if missing): +# - go-licenses (Go) go install github.com/google/go-licenses@latest +# - pip-licenses (Python) pip install pip-licenses +# - license-checker (Node) already in studio-v2/node_modules +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$ROOT/sbom/stundenplan" +mkdir -p "$OUT" + +# --------- Go: school-service --------- +echo "==> school-service (Go)" +if command -v go-licenses >/dev/null 2>&1; then + ( cd "$ROOT/school-service" \ + && go-licenses csv ./... > "$OUT/school-service-licenses.csv" 2> "$OUT/school-service-warnings.log" \ + || echo " go-licenses returned non-zero (see warnings.log)" ) +else + echo " skipped — install with: go install github.com/google/go-licenses@latest" +fi + +# --------- Python: timetable-solver-service --------- +echo "==> timetable-solver-service (Python)" +if [[ -d "$ROOT/timetable-solver-service" ]]; then + if command -v pip-licenses >/dev/null 2>&1; then + # Resolve against the requirements.txt the Dockerfile installs. + pip install --quiet --no-deps -r "$ROOT/timetable-solver-service/requirements.txt" \ + --target "$OUT/.python-tmp" 2>/dev/null || true + PYTHONPATH="$OUT/.python-tmp" pip-licenses --format=json \ + > "$OUT/timetable-solver-licenses.json" 2> "$OUT/timetable-solver-warnings.log" \ + || echo " pip-licenses returned non-zero" + rm -rf "$OUT/.python-tmp" + else + echo " skipped — install with: pip install pip-licenses" + fi +fi + +# --------- Node: studio-v2 (subset that ships in the bundle) --------- +echo "==> studio-v2 (Node)" +if [[ -d "$ROOT/studio-v2/node_modules" ]]; then + ( cd "$ROOT/studio-v2" \ + && ./node_modules/.bin/license-checker --json --production \ + > "$OUT/studio-v2-licenses.json" 2> "$OUT/studio-v2-warnings.log" \ + || echo " license-checker returned non-zero" ) +else + echo " studio-v2/node_modules missing — run npm install first" +fi + +# --------- Summary --------- +echo +echo "SBOM written to $OUT" +ls -la "$OUT" diff --git a/studio-v2/app/globals.css b/studio-v2/app/globals.css index f62152b..53e3d4c 100644 --- a/studio-v2/app/globals.css +++ b/studio-v2/app/globals.css @@ -44,3 +44,28 @@ body { ::-webkit-scrollbar-thumb:hover { background: #94a3b8; } + +/* Stundenplan print view — hide chrome, force the Wochengrid full-width + on white background so window.print() yields a clean A4 page. */ +@media print { + .no-print, + aside, + header button, + details { + display: none !important; + } + body, + main { + background: white !important; + color: black !important; + } + [data-testid="plan-view"] table { + width: 100%; + border-collapse: collapse; + } + [data-testid="plan-view"] td, + [data-testid="plan-view"] th { + border: 1px solid #ccc; + color: black !important; + } +} diff --git a/studio-v2/app/stundenplan/_components/plan/PlanView.tsx b/studio-v2/app/stundenplan/_components/plan/PlanView.tsx index 9efdb72..9718eb3 100644 --- a/studio-v2/app/stundenplan/_components/plan/PlanView.tsx +++ b/studio-v2/app/stundenplan/_components/plan/PlanView.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useMemo } from 'react' import { useTheme } from '@/lib/ThemeContext' -import { solutionsApi, subjectsApi, lessonsApi } from '@/lib/stundenplan/api' +import { solutionsApi, subjectsApi, lessonsApi, downloadSolutionExport } from '@/lib/stundenplan/api' import type { TimetableLesson, TimetableSubject } from '@/app/stundenplan/types' interface PlanViewProps { @@ -125,9 +125,15 @@ export function PlanView({ solutionId }: PlanViewProps) { const cardClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white/80 border-black/10 text-slate-900' const selectClass = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-300 text-slate-900' + const handleExport = (fmt: 'csv' | 'ics') => { + downloadSolutionExport(solutionId, fmt).catch(e => + setError(e instanceof Error ? e.message : 'Export fehlgeschlagen'), + ) + } + return (
-
+
@@ -155,6 +161,32 @@ export function PlanView({ solutionId }: PlanViewProps) { {resources.map(r => )}
+
+ +
+ + + +
+
diff --git a/studio-v2/app/stundenplan/page.tsx b/studio-v2/app/stundenplan/page.tsx index 25c9582..ec9f7f3 100644 --- a/studio-v2/app/stundenplan/page.tsx +++ b/studio-v2/app/stundenplan/page.tsx @@ -76,48 +76,47 @@ export default function StundenplanPage() {
- - Anmeldung noch nicht integriert — Dev-Token setzen + + Testumgebung — Anmeldung deaktiviert -
+

- Bis die volle BreakPilot-Anmeldung an dieses Modul angebunden ist, muss - ein gueltiger JWT-Token manuell hinterlegt werden. Ohne Token antwortet - die API mit Authorization header required. + Der school-service laeuft im Development-Mode und akzeptiert Requests + ohne JWT. Alle Aktionen werden einem festen Dev-User + zugeordnet (00000000-0000-0000-0000-000000000001).

-
    -
  1. An BreakPilot anmelden (z.B. ueber das Lehrer-Login)
  2. -
  3. Im Browser DevTools → Application/Storage → Cookies oder localStorage den - JWT-Token kopieren (Feldname kann je nach Login-Flow variieren)
  4. -
  5. Token unten einfuegen, Speichern, Seite neu laden
  6. -
-
- setToken(e.target.value)} - placeholder="Bearer-Token (ohne 'Bearer '-Prefix)" - className={`flex-1 px-3 py-1.5 rounded-lg text-sm ${ - isDark ? 'bg-white/10 border border-white/20 text-white' : 'bg-white border border-slate-300 text-slate-900' - }`} - /> - -
- {tokenSaved && ( -

- Token gespeichert. Seite neu laden, um die Aenderung zu uebernehmen. -

- )} +

+ Fuer Production muss ENVIRONMENT=production gesetzt werden — dann ist ein gueltiger + JWT in jedem Request Pflicht. +

+
+ Manueller Token (falls noetig) +
+ setToken(e.target.value)} + placeholder="Bearer-Token (optional)" + className={`flex-1 px-3 py-1.5 rounded-lg text-sm ${ + isDark ? 'bg-white/10 border border-white/20 text-white' : 'bg-white border border-slate-300 text-slate-900' + }`} + /> + +
+ {tokenSaved && ( +

Token gespeichert. Seite neu laden.

+ )} +
diff --git a/studio-v2/e2e/_helpers.ts b/studio-v2/e2e/_helpers.ts index 888530d..7293c4f 100644 --- a/studio-v2/e2e/_helpers.ts +++ b/studio-v2/e2e/_helpers.ts @@ -128,6 +128,21 @@ export async function mockSchoolApi(page: Page, opts: MockOpts = {}) { body: JSON.stringify({ message: 'ok', pinned: body.pinned ?? false }), }) }) + // Phase 8: CSV + ICS exports. Routed BEFORE the generic /solutions/:id + // catch-all so the .csv / .ics suffix path is matched first. + await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/export\.csv$/, async (route) => { + return route.fulfill({ + status: 200, contentType: 'text/csv', + body: 'day_of_week,period_index,start_time,end_time,class,subject,subject_code,teacher,room,pinned\n1,1,08:00,08:45,5a,Mathe,M,"Schmidt, Anna",A101,false\n', + }) + }) + await page.route(/\/api\/school\/timetable\/solutions\/[^/]+\/export\.ics(\?.*)?$/, async (route) => { + return route.fulfill({ + status: 200, contentType: 'text/calendar', + body: 'BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n', + }) + }) + await page.route(/\/api\/school\/timetable\/solutions\/[^/]+$/, async (route) => { if (route.request().method() === 'DELETE') { return route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"deleted"}' }) diff --git a/studio-v2/e2e/stundenplan-export.spec.ts b/studio-v2/e2e/stundenplan-export.spec.ts new file mode 100644 index 0000000..16e5936 --- /dev/null +++ b/studio-v2/e2e/stundenplan-export.spec.ts @@ -0,0 +1,51 @@ +import { test, expect } from '@playwright/test' +import { mockSchoolApi } from './_helpers' + +/** + * E2E tests for the Phase 8 export functionality on /stundenplan. + * Split into its own file so stundenplan.spec.ts stays under the 500 LOC + * budget enforced by the pre-commit hook. + */ + +const exportOpts = () => ({ + solutions: [ + { id: 's1', created_by_user_id: 'u', name: 'Plan A', status: 'completed', hard_score: 0, soft_score: 0, created_at: '2026-05-22T10:00:00Z' }, + ], + lessons: [ + { id: 'l1', solution_id: 's1', class_id: 'c1', subject_id: 'sub1', teacher_id: 't1', room_id: 'r1', day_of_week: 1, period_index: 1, pinned: false, created_at: '', class_name: '5a', subject_name: 'Mathe', teacher_name: 'Schmidt, Anna', room_name: 'A101' }, + ], +}) + +test.describe('Stundenplan — Export buttons', () => { + test('export buttons are rendered on the PlanView', async ({ page }) => { + await mockSchoolApi(page, exportOpts()) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Anzeigen' }).click() + await expect(page.getByTestId('export-csv')).toBeVisible() + await expect(page.getByTestId('export-ics')).toBeVisible() + await expect(page.getByTestId('export-print')).toBeVisible() + }) + + test('CSV download triggers a fetch to export.csv', async ({ page }) => { + await mockSchoolApi(page, exportOpts()) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Anzeigen' }).click() + const downloadPromise = page.waitForEvent('download') + await page.getByTestId('export-csv').click() + const download = await downloadPromise + expect(download.suggestedFilename()).toContain('.csv') + }) + + test('ICS download triggers a fetch to export.ics', async ({ page }) => { + await mockSchoolApi(page, exportOpts()) + await page.goto('/stundenplan') + await page.waitForLoadState('networkidle') + await page.getByRole('button', { name: 'Anzeigen' }).click() + const downloadPromise = page.waitForEvent('download') + await page.getByTestId('export-ics').click() + const download = await downloadPromise + expect(download.suggestedFilename()).toContain('.ics') + }) +}) diff --git a/studio-v2/e2e/stundenplan.spec.ts b/studio-v2/e2e/stundenplan.spec.ts index 4279a59..23531e9 100644 --- a/studio-v2/e2e/stundenplan.spec.ts +++ b/studio-v2/e2e/stundenplan.spec.ts @@ -41,8 +41,9 @@ test.describe('Stundenplan — Page Shell', () => { await expect(page.getByTestId('plan-hub')).toBeVisible() }) - test('JWT dev field exists and persists into localStorage', async ({ page }) => { - await page.getByText('Anmeldung noch nicht integriert').click() + test('Dev mode banner is collapsed by default; manual token still available', async ({ page }) => { + await page.getByText('Testumgebung — Anmeldung deaktiviert').click() + await page.getByText('Manueller Token').click() await page.getByPlaceholder(/Bearer-Token/).fill('test-jwt-abc') await page.getByRole('button', { name: 'Speichern' }).click() const stored = await page.evaluate(() => localStorage.getItem('bp_stundenplan_jwt')) diff --git a/studio-v2/lib/stundenplan/api.ts b/studio-v2/lib/stundenplan/api.ts index e2f6f69..4e3f0b1 100644 --- a/studio-v2/lib/stundenplan/api.ts +++ b/studio-v2/lib/stundenplan/api.ts @@ -160,3 +160,30 @@ export const lessonsApi = { body: JSON.stringify({ pinned }), }), } + +// Phase 8: exports. Fetched as blobs through the proxy so the JWT (when +// set) is forwarded; download is triggered by creating an object URL. +export async function downloadSolutionExport( + solutionId: string, + format: 'csv' | 'ics', + options: { startDate?: string } = {}, +): Promise { + const token = getStundenplanToken() + const headers: Record = {} + if (token) headers['Authorization'] = `Bearer ${token}` + + const qs = format === 'ics' && options.startDate ? `?start=${options.startDate}` : '' + const res = await fetch(`/api/school/timetable/solutions/${solutionId}/export.${format}${qs}`, { headers }) + if (!res.ok) { + throw new Error(`Export fehlgeschlagen (HTTP ${res.status})`) + } + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `stundenplan-${solutionId.slice(0, 8)}.${format}` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) +}