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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user