306886a42b
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>
186 lines
5.7 KiB
Go
186 lines
5.7 KiB
Go
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())
|
|
}
|