diff --git a/README.md b/README.md index 3a07323..41739e8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ import _ "git.golem15.com/jakub/sm-bm-plugin" Students are the users of `golem15.user`: the API sits behind its `jwt.auth` guard, and reading progress points at its `users` table. Course data is loaded from a JSON file with `bm:seed` and maintained in the admin afterwards. -Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/Warsaw (`2026-10-03T08:00:00+02:00`). Dates are `YYYY-MM-DD`. A date or time typed without an offset (in the admin or in a seed file) is read as Polish time. +Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/Warsaw (`2026-10-03T08:00:00+02:00`). Dates are `YYYY-MM-DD`. A date or time without an offset in a seed file is read as Polish time. ## Features @@ -199,9 +199,9 @@ Controllers (admin API path `/api/v1/jz/bm/`), all in the "St | `jz.bm.meetings`, `jz.bm.class_sessions`, `jz.bm.deadlines` | `jz.bm.access_schedule` | | `jz.bm.materials`, `jz.bm.tags` | `jz.bm.access_materials` | -Courses, meetings, sessions and deadlines are picked through relation fields; a material's tags through a belongsToMany relation field. Dates are typed as `2026-10-03` and times as `2026-10-03 08:00` (Polish time) or RFC 3339; a value that does not parse is a 422 on its field. Labels are in Polish (`lang/pl`) and English (`lang/en`). +Courses, meetings, sessions and deadlines are picked through relation fields; a material's tags through a belongsToMany relation field. Dates and times are edited with date pickers (`type: datepicker`); the admin shows times in the browser's time zone and stores them in UTC. Labels are in Polish (`lang/pl`) and English (`lang/en`). -Two things the admin cannot do yet, because the framework does not offer them: upload a material's files (there is no file upload form widget, so files are attached as `system_files` rows outside the admin), and list a course's readings and sessions inside the course form (relation managers handle only belongsToMany links). Both are marked `TODO: requires SummerCMS change` in the code. +A material's files are uploaded in its form (`type: fileupload` on the attachMany `files`, public, up to 20 files of 50 MB). The course form embeds relation managers for its readings and class sessions, which create, edit and delete them, also before the course is first saved: `course_id` on both tables is nullable for that, the pending rows are bound to the form session and get the course on its first save, and `deferred:purge` deletes abandoned ones. The student API leaves out sessions and readings without a course. A class session added from the course form has no meeting; set it on the class sessions screen. ## Configuration diff --git a/admin_test.go b/admin_test.go index 876b524..a272dda 100644 --- a/admin_test.go +++ b/admin_test.go @@ -113,7 +113,7 @@ func TestAdminCRUDAgainstDatabase(t *testing.T) { } sessionID := createdID(t, adminSend(t, h, http.MethodPost, "/jz/bm/class_sessions", token, map[string]any{ - "course": courseID, "meeting": meetingID, "starts_at": "2030-10-05 08:00", "ends_at": "2030-10-05 09:30", "room": "", "is_online": true, + "course": courseID, "meeting": meetingID, "starts_at": "2030-10-05T06:00:00Z", "ends_at": "2030-10-05T07:30:00Z", "room": "", "is_online": true, })) var stored time.Time gdb.Raw(`SELECT starts_at FROM jz_bm_class_sessions WHERE id = ?`, sessionID).Scan(&stored) @@ -122,11 +122,11 @@ func TestAdminCRUDAgainstDatabase(t *testing.T) { } var session models.ClassSession gdb.Take(&session, sessionID) - if session.CourseID != courseID || session.MeetingID == nil || *session.MeetingID != meetingID || session.Room != nil || !session.IsOnline { + if session.CourseID == nil || *session.CourseID != courseID || session.MeetingID == nil || *session.MeetingID != meetingID || session.Room != nil || !session.IsOnline { t.Errorf("session = %+v", session) } rec := adminSend(t, h, http.MethodPost, "/jz/bm/class_sessions", token, map[string]any{ - "course": courseID, "starts_at": "2030-10-05 10:00", "ends_at": "wtorek", + "course": courseID, "starts_at": "2030-10-05T08:00:00Z", "ends_at": "2030-10-05T07:00:00Z", }) if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "ends_at") { t.Errorf("bad ends_at = %d %s", rec.Code, rec.Body.String()) @@ -140,7 +140,7 @@ func TestAdminCRUDAgainstDatabase(t *testing.T) { tagID := createdID(t, adminSend(t, h, http.MethodPost, "/jz/bm/tags", token, map[string]any{"name": "Geopolityka", "slug": "geopolityka"})) materialID := createdID(t, adminSend(t, h, http.MethodPost, "/jz/bm/materials", token, map[string]any{ - "title": "Notatka", "type": "notatka", "course": courseID, "tags": []uint{tagID}, "published_at": "", "body": "# Treść", + "title": "Notatka", "type": "notatka", "course": courseID, "tags": []uint{tagID}, "published_at": nil, "body": "# Treść", })) var material models.Material gdb.Preload("Tags").Take(&material, materialID) @@ -148,13 +148,13 @@ func TestAdminCRUDAgainstDatabase(t *testing.T) { t.Errorf("material = %+v", material) } rec = adminSend(t, h, http.MethodPut, "/jz/bm/materials/"+strconv.Itoa(int(materialID)), token, map[string]any{ - "title": "Notatka", "type": "notatka", "published_at": "2026-01-15 12:00", + "title": "Notatka", "type": "notatka", "published_at": "2026-01-15T11:00:00Z", }) if rec.Code != http.StatusOK { t.Fatalf("publish = %d %s", rec.Code, rec.Body.String()) } gdb.Take(&material, materialID) - if material.PublishedAt == nil || *material.PublishedAt != "2026-01-15T12:00:00+01:00" { + if material.PublishedAt == nil || !material.PublishedAt.Equal(time.Date(2026, 1, 15, 11, 0, 0, 0, time.UTC)) { t.Errorf("published_at = %v", material.PublishedAt) } @@ -183,3 +183,64 @@ func adminToken2(t *testing.T, gdb *gorm.DB, h http.Handler) string { } return body.Data.AccessToken } + +// TestCourseRelationManagersOnUnsavedCourse creates a reading and a class +// session from the course form before the course exists: they wait without +// a course, stay out of the student API, and get the course on its first +// save with the same form session key. +func TestCourseRelationManagersOnUnsavedCourse(t *testing.T) { + app, plugins, gdb := migrated(t) + h, err := surf.Assemble(app, plugins) + if err != nil { + t.Fatal(err) + } + token := adminToken(t, gdb, h, `{"jz.bm.access_courses":1}`) + key := strings.Repeat("k", 40) + send := func(method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(method, adminAPI+path, bytes.NewReader(raw)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept-Language", "en") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Session-Key", key) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec + } + + readingID := createdID(t, send(http.MethodPost, "/jz/bm/courses/0/relations/readings/records", map[string]any{ + "title": "Lektura w toku", "is_required": true, + })) + sessionID := createdID(t, send(http.MethodPost, "/jz/bm/courses/0/relations/sessions/records", map[string]any{ + "starts_at": "2030-10-05T06:00:00Z", "ends_at": "2030-10-05T07:30:00Z", "room": "237", + })) + if rec := send(http.MethodPost, "/jz/bm/courses/0/relations/sessions/records", map[string]any{ + "starts_at": "2030-10-05T08:00:00Z", "ends_at": "2030-10-05T07:00:00Z", + }); rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "ends_at") { + t.Errorf("session ending before it starts = %d %s", rec.Code, rec.Body.String()) + } + + var pending models.ClassSession + gdb.Take(&pending, sessionID) + if pending.CourseID != nil { + t.Fatalf("pending session has course %d", *pending.CourseID) + } + + courseID := createdID(t, send(http.MethodPost, "/jz/bm/courses", map[string]any{ + "name": "Przedmiot z lekturami", "form": "wyklad", "semester": 1, + })) + var reading models.Reading + gdb.Take(&reading, readingID) + var session models.ClassSession + gdb.Take(&session, sessionID) + if reading.CourseID == nil || *reading.CourseID != courseID || session.CourseID == nil || *session.CourseID != courseID { + t.Errorf("after save: reading course %v, session course %v, want %d", reading.CourseID, session.CourseID, courseID) + } + if !session.StartsAt.Equal(time.Date(2030, 10, 5, 6, 0, 0, 0, time.UTC)) { + t.Errorf("session starts_at = %v", session.StartsAt) + } +} diff --git a/classes/calendar.go b/classes/calendar.go index 35094e8..dcde73e 100644 --- a/classes/calendar.go +++ b/classes/calendar.go @@ -69,7 +69,7 @@ func BuildCalendar(data CalendarData, opt CalendarOptions) []byte { w.Prop("REFRESH-INTERVAL;VALUE=DURATION", "PT6H") w.Prop("X-PUBLISHED-TTL", "PT6H") for _, s := range data.Sessions { - if excluded[s.CourseID] { + if s.CourseID == nil || excluded[*s.CourseID] { continue } writeSession(&w, s, opt.SiteURL) @@ -129,8 +129,8 @@ func writeSession(w *ICS, s models.ClassSession, siteURL string) { w.Prop("UID", uid("session", s.ID)) stamp(w, s.UpdatedAt) w.Prop("SEQUENCE", "0") - w.UTC("DTSTART", s.StartsAt.MustTime()) - w.UTC("DTEND", s.EndsAt.MustTime()) + w.UTC("DTSTART", s.StartsAt) + w.UTC("DTEND", s.EndsAt) w.Text("SUMMARY", SessionSummary(s)) switch { case s.IsOnline: @@ -152,7 +152,7 @@ func writeSession(w *ICS, s models.ClassSession, siteURL string) { lines = append(lines, "Przedmiot do wyboru") } if siteURL != "" { - link := siteURL + "/przedmioty/" + strconv.FormatUint(uint64(s.CourseID), 10) + link := siteURL + "/przedmioty/" + strconv.FormatUint(uint64(*s.CourseID), 10) lines = append(lines, link) w.Prop("URL", link) } @@ -180,12 +180,13 @@ func MeetingSummary(m models.Meeting) string { } func writeMeeting(w *ICS, m models.Meeting) { - start, err := m.StartsOn.Time() - if err != nil { + if m.StartsOn.IsZero() { return } - end, err := m.EndsOn.Time() - if err != nil || end.Before(start) { + // Midnight UTC keeps the calendar date for w.Date. + start := m.StartsOn.Time(nil) + end := m.EndsOn.Time(nil) + if m.EndsOn.IsZero() || end.Before(start) { end = start } w.Begin("VEVENT") @@ -204,8 +205,8 @@ func writeMeeting(w *ICS, m models.Meeting) { } func writeDeadline(w *ICS, d models.Deadline) { - due, err := d.DueAt.Time() - if err != nil { + due := d.DueAt + if due.IsZero() { return } summary := "Termin: " + d.Title diff --git a/classes/calendar_test.go b/classes/calendar_test.go index a00fa11..3c2000c 100644 --- a/classes/calendar_test.go +++ b/classes/calendar_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/jakub/sm-bm-plugin/classes" "git.golem15.com/jakub/sm-bm-plugin/models" ) @@ -17,19 +18,19 @@ func calendarFixture() classes.CalendarData { glob := &models.Course{ID: 1, Name: "Globalizacja i regionalizacja", Form: models.FormWyklad, Lecturer: sp("prof. dr hab. Piotr Mikiewicz")} hist := &models.Course{ID: 8, Name: "Historia wojen i konfliktów do 1945 roku", Form: models.FormInne, ElectiveGroup: sp("historia-koncepcje")} konc := &models.Course{ID: 9, Name: "Koncepcje wojny i pokoju – historia i współczesność", Form: models.FormInne, ElectiveGroup: sp("historia-koncepcje")} - m3 := &models.Meeting{ID: 3, Number: 3, StartsOn: "2026-10-24", EndsOn: "2026-10-25", Notes: sp("stacjonarny (Koszarowa 3, bud. 21)"), UpdatedAt: updated} - m2 := &models.Meeting{ID: 2, Number: 2, StartsOn: "2026-10-17", EndsOn: "2026-10-18", Notes: sp("online"), UpdatedAt: updated} + m3 := &models.Meeting{ID: 3, Number: 3, StartsOn: day("2026-10-24"), EndsOn: day("2026-10-25"), Notes: sp("stacjonarny (Koszarowa 3, bud. 21)"), UpdatedAt: updated} + m2 := &models.Meeting{ID: 2, Number: 2, StartsOn: day("2026-10-17"), EndsOn: day("2026-10-18"), Notes: sp("online"), UpdatedAt: updated} return classes.CalendarData{ Sessions: []models.ClassSession{ - {ID: 11, CourseID: 1, Course: glob, MeetingID: up(3), Meeting: m3, StartsAt: "2026-10-24T08:00:00+02:00", EndsAt: "2026-10-24T09:30:00+02:00", Room: sp("237"), Notes: sp("prof. P. Mikiewicz"), UpdatedAt: updated}, - {ID: 12, CourseID: 8, Course: hist, MeetingID: up(3), Meeting: m3, StartsAt: "2026-10-25T08:00:00+01:00", EndsAt: "2026-10-25T09:30:00+01:00", Room: sp("110"), UpdatedAt: updated}, - {ID: 13, CourseID: 9, Course: konc, MeetingID: up(2), Meeting: m2, StartsAt: "2026-10-17T10:00:00+02:00", EndsAt: "2026-10-17T11:30:00+02:00", IsOnline: true, Room: sp("237"), UpdatedAt: updated}, + {ID: 11, CourseID: up(1), Course: glob, MeetingID: up(3), Meeting: m3, StartsAt: ts("2026-10-24T08:00:00+02:00"), EndsAt: ts("2026-10-24T09:30:00+02:00"), Room: sp("237"), Notes: sp("prof. P. Mikiewicz"), UpdatedAt: updated}, + {ID: 12, CourseID: up(8), Course: hist, MeetingID: up(3), Meeting: m3, StartsAt: ts("2026-10-25T08:00:00+01:00"), EndsAt: ts("2026-10-25T09:30:00+01:00"), Room: sp("110"), UpdatedAt: updated}, + {ID: 13, CourseID: up(9), Course: konc, MeetingID: up(2), Meeting: m2, StartsAt: ts("2026-10-17T10:00:00+02:00"), EndsAt: ts("2026-10-17T11:30:00+02:00"), IsOnline: true, Room: sp("237"), UpdatedAt: updated}, }, Meetings: []models.Meeting{*m2, *m3}, Deadlines: []models.Deadline{ - {ID: 21, Title: "Esej; wersja 1, szkic", DueAt: "2026-11-30T23:59:00+01:00", CourseID: up(1), Course: glob, Description: sp("Wysłać\nmailem"), UpdatedAt: updated}, - {ID: 22, Title: "Esej z koncepcji", DueAt: "2026-12-01T12:00:00+01:00", CourseID: up(9), Course: konc, UpdatedAt: updated}, - {ID: 23, Title: "Ślubowanie", DueAt: "2026-10-05T23:59:00+02:00", UpdatedAt: updated}, + {ID: 21, Title: "Esej; wersja 1, szkic", DueAt: ts("2026-11-30T23:59:00+01:00"), CourseID: up(1), Course: glob, Description: sp("Wysłać\nmailem"), UpdatedAt: updated}, + {ID: 22, Title: "Esej z koncepcji", DueAt: ts("2026-12-01T12:00:00+01:00"), CourseID: up(9), Course: konc, UpdatedAt: updated}, + {ID: 23, Title: "Ślubowanie", DueAt: ts("2026-10-05T23:59:00+02:00"), UpdatedAt: updated}, }, } } @@ -211,3 +212,19 @@ func TestSummaries(t *testing.T) { t.Errorf("meeting without notes = %q", got) } } + +func day(s string) lagoon.Date { + d, err := lagoon.ParseDate(s) + if err != nil { + panic(err) + } + return d +} + +func ts(s string) time.Time { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + panic(err) + } + return t +} diff --git a/classes/seed.go b/classes/seed.go index 5c1f06f..0d5cd4d 100644 --- a/classes/seed.go +++ b/classes/seed.go @@ -9,6 +9,7 @@ import ( "io" "slices" "strings" + "time" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/jakub/sm-bm-plugin/models" @@ -191,7 +192,7 @@ func (f *SeedFile) Validate() error { if err2 != nil { add("%s: ends_on: %v", at, err2) } - if err1 == nil && err2 == nil && end.Before(start) { + if err1 == nil && err2 == nil && end.Time(nil).Before(start.Time(nil)) { add("%s: ends_on is before starts_on", at) } } @@ -314,10 +315,8 @@ func SeedWith(ctx context.Context, db *gorm.DB, f *SeedFile, opts SeedOptions) ( m := models.Meeting{} created, changed, err := upsert(tx, &m, clause.Eq{Column: "number", Value: in.Number}, func(m *models.Meeting) { m.Number = in.Number - start, _ := models.ParseDate(in.StartsOn) - end, _ := models.ParseDate(in.EndsOn) - m.StartsOn = models.NewDate(start) - m.EndsOn = models.NewDate(end) + m.StartsOn, _ = models.ParseDate(in.StartsOn) + m.EndsOn, _ = models.ParseDate(in.EndsOn) m.Notes = nz(in.Notes) }) if err != nil { @@ -366,15 +365,16 @@ func SeedWith(ctx context.Context, db *gorm.DB, f *SeedFile, opts SeedOptions) ( clause.Eq{Column: "starts_at", Value: start.UTC()}, ) created, changed, err := upsert(tx, &s, where, func(s *models.ClassSession) { - s.CourseID = courseIDs[in.Course] + courseID := courseIDs[in.Course] + s.CourseID = &courseID s.MeetingID = nil if in.Meeting != nil { id := meetingIDs[*in.Meeting] s.MeetingID = &id } - s.StartsAt = models.NewDateTime(start) end, _ := models.ParseDateTime(in.EndsAt) - s.EndsAt = models.NewDateTime(end) + setInstant(&s.StartsAt, start) + setInstant(&s.EndsAt, end) s.Room = nz(in.Room) s.Kind = nz(in.Kind) s.IsOnline = in.IsOnline @@ -396,7 +396,7 @@ func SeedWith(ctx context.Context, db *gorm.DB, f *SeedFile, opts SeedOptions) ( ) created, changed, err := upsert(tx, &d, where, func(d *models.Deadline) { d.Title = in.Title - d.DueAt = models.NewDateTime(due) + setInstant(&d.DueAt, due) d.CourseID = nil if in.Course != nil { id := courseIDs[*in.Course] @@ -419,7 +419,8 @@ func SeedWith(ctx context.Context, db *gorm.DB, f *SeedFile, opts SeedOptions) ( clause.Eq{Column: "title", Value: in.Title}, ) created, changed, err := upsert(tx, &r, where, func(r *models.Reading) { - r.CourseID = courseIDs[in.Course] + courseID := courseIDs[in.Course] + r.CourseID = &courseID r.Title = in.Title r.Author = nz(in.Author) r.Source = nz(in.Source) @@ -486,7 +487,7 @@ func prune(tx *gorm.DB, sum *SeedSummary, semesters map[int]bool, courseIDs map[ return err } for _, s := range sessions { - sum.Removed = append(sum.Removed, fmt.Sprintf("class session of course %d at %s", s.CourseID, string(s.StartsAt))) + sum.Removed = append(sum.Removed, fmt.Sprintf("class session of course %d at %s", s.CourseID, models.FormatDateTime(s.StartsAt))) if err := tx.Delete(&models.ClassSession{}, s.ID).Error; err != nil { return fmt.Errorf("prune class session %d: %w", s.ID, err) } @@ -520,6 +521,15 @@ func nz(s *string) *string { // an insert when there was no row, an update when set changed a column, and // nothing otherwise. The comparison is on the row's own fields, before and // after set, as JSON. +// setInstant sets *dst to t unless it already holds the same instant, so a +// time read back from the database in another zone does not count as a +// change in upsert. +func setInstant(dst *time.Time, t time.Time) { + if !dst.Equal(t) { + *dst = t + } +} + func upsert[T any](tx *gorm.DB, row *T, where clause.Expression, set func(*T)) (created, changed bool, err error) { res := tx.Clauses(where).Limit(1).Find(row) if res.Error != nil { diff --git a/controllers/admin_test.go b/controllers/admin_test.go index df2011b..180dbd2 100644 --- a/controllers/admin_test.go +++ b/controllers/admin_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/jakub/sm-bm-plugin/models" @@ -11,7 +12,8 @@ import ( func TestTemporalHooksAnswerOnTheField(t *testing.T) { var hooks temporalHooks - bad := &models.ClassSession{StartsAt: "2026-10-03 08:00", EndsAt: "wtorek"} + at := func(h, m int) time.Time { return time.Date(2026, 10, 3, h, m, 0, 0, models.Location) } + bad := &models.ClassSession{StartsAt: at(8, 0), EndsAt: at(7, 0)} for name, fn := range map[string]func(context.Context, any) error{ "create": hooks.FormBeforeCreate, "update": hooks.FormBeforeUpdate, @@ -25,7 +27,7 @@ func TestTemporalHooksAnswerOnTheField(t *testing.T) { t.Errorf("%s: details %v name no ends_at", name, ve.Details) } } - good := &models.ClassSession{StartsAt: "2026-10-03 08:00", EndsAt: "2026-10-03 09:30"} + good := &models.ClassSession{StartsAt: at(8, 0), EndsAt: at(9, 30)} if err := hooks.FormBeforeCreate(context.Background(), good); err != nil { t.Errorf("valid session: %v", err) } diff --git a/controllers/api/calendar.go b/controllers/api/calendar.go index e4893c5..778df1e 100644 --- a/controllers/api/calendar.go +++ b/controllers/api/calendar.go @@ -75,7 +75,7 @@ func (h *Handlers) newFeedJSON(r *http.Request, f *models.CalendarFeed) calendar IncludeDeadlines: f.IncludeDeadlines, } if f.LastFetchedAt != nil { - s := string(models.NewDateTime(*f.LastFetchedAt)) + s := models.FormatDateTime(*f.LastFetchedAt) out.LastFetchedAt = &s } return out diff --git a/controllers/api/dashboard.go b/controllers/api/dashboard.go index 4a08f51..9682e5d 100644 --- a/controllers/api/dashboard.go +++ b/controllers/api/dashboard.go @@ -1,6 +1,7 @@ package api import ( + "git.golem15.com/golem15/summercms/modules/lagoon" "net/http" "git.golem15.com/jakub/sm-bm-plugin/models" @@ -46,7 +47,7 @@ func (h *Handlers) Dashboard(w http.ResponseWriter, r *http.Request) { } out.UpcomingDeadlines = newDeadlinesJSON(deadlines) - day := today(h.now()).Format(models.DateLayout) + day := lagoon.DateOf(today(h.now())).String() var err error if out.CurrentMeeting, err = meetingWhere(db, "starts_on <= ? AND ends_on >= ?", day, day); err != nil { writeOpaque500(w) diff --git a/controllers/api/json.go b/controllers/api/json.go index b683257..a5126e3 100644 --- a/controllers/api/json.go +++ b/controllers/api/json.go @@ -2,6 +2,15 @@ package api import "git.golem15.com/jakub/sm-bm-plugin/models" +// idOf is the value of an optional id, 0 when it is nil. The API serves only +// sessions and readings that have a course, so their course_id is set. +func idOf(id *uint) uint { + if id == nil { + return 0 + } + return *id +} + // courseRef is the short form of a course embedded in other records. type courseRef struct { ID uint `json:"id"` @@ -83,7 +92,7 @@ type meetingJSON struct { } func newMeetingJSON(m models.Meeting, sessions int64) meetingJSON { - return meetingJSON{ID: m.ID, Number: m.Number, StartsOn: string(m.StartsOn), EndsOn: string(m.EndsOn), Notes: m.Notes, SessionsCount: sessions} + return meetingJSON{ID: m.ID, Number: m.Number, StartsOn: m.StartsOn.String(), EndsOn: m.EndsOn.String(), Notes: m.Notes, SessionsCount: sessions} } // sessionJSON is one class session with its course. @@ -104,10 +113,10 @@ type sessionJSON struct { func newSessionJSON(s models.ClassSession) sessionJSON { out := sessionJSON{ ID: s.ID, - CourseID: s.CourseID, + CourseID: idOf(s.CourseID), MeetingID: s.MeetingID, - StartsAt: string(s.StartsAt), - EndsAt: string(s.EndsAt), + StartsAt: models.FormatDateTime(s.StartsAt), + EndsAt: models.FormatDateTime(s.EndsAt), Room: s.Room, Kind: s.Kind, Notes: s.Notes, @@ -146,7 +155,7 @@ func newDeadlinesJSON(rows []models.Deadline) []deadlineJSON { out = append(out, deadlineJSON{ ID: d.ID, Title: d.Title, - DueAt: string(d.DueAt), + DueAt: models.FormatDateTime(d.DueAt), Kind: d.Kind, Description: d.Description, CourseID: d.CourseID, @@ -212,7 +221,7 @@ func newMaterialListJSON(m models.Material, files int64) materialListJSON { FilesCount: files, } if m.PublishedAt != nil { - out.PublishedAt = string(*m.PublishedAt) + out.PublishedAt = models.FormatDateTime(*m.PublishedAt) } return out } diff --git a/controllers/api/params.go b/controllers/api/params.go index 3576338..43e1ed9 100644 --- a/controllers/api/params.go +++ b/controllers/api/params.go @@ -99,11 +99,11 @@ func dayStart(in map[string]any, name string) (time.Time, bool) { if s == "" { return time.Time{}, false } - t, err := models.ParseDate(s) + d, err := models.ParseDate(s) if err != nil { return time.Time{}, false } - return t, true + return d.Time(models.Location), true } // today returns midnight of now's calendar day in Europe/Warsaw. diff --git a/controllers/api/readings.go b/controllers/api/readings.go index 95d9499..a1b44b3 100644 --- a/controllers/api/readings.go +++ b/controllers/api/readings.go @@ -37,8 +37,8 @@ func listReadings(db *gorm.DB, userID, courseID uint) ([]readingJSON, error) { for _, row := range rows { item := readingJSON{ ID: row.ID, - CourseID: row.CourseID, - Course: newCourseRef(courses[row.CourseID]), + CourseID: idOf(row.CourseID), + Course: newCourseRef(courses[idOf(row.CourseID)]), Title: row.Title, Author: row.Author, Source: row.Source, @@ -47,7 +47,7 @@ func listReadings(db *gorm.DB, userID, courseID uint) ([]readingJSON, error) { SortOrder: row.SortOrder, } if row.ReadAt != nil { - s := string(models.NewDateTime(*row.ReadAt)) + s := models.FormatDateTime(*row.ReadAt) item.ReadAt = &s } out = append(out, item) @@ -59,9 +59,9 @@ func readingCourseIDs(rows []readingRow) []uint { seen := map[uint]bool{} var ids []uint for _, row := range rows { - if !seen[row.CourseID] { - seen[row.CourseID] = true - ids = append(ids, row.CourseID) + if id := idOf(row.CourseID); !seen[id] { + seen[id] = true + ids = append(ids, id) } } return ids @@ -121,7 +121,7 @@ func readingFor(w http.ResponseWriter, r *http.Request, db *gorm.DB) (uint, bool return 0, false } var reading models.Reading - res := db.Select("id").Where("id = ?", id).Limit(1).Find(&reading) + res := db.Select("id").Where("id = ? AND course_id IS NOT NULL", id).Limit(1).Find(&reading) if res.Error != nil { writeOpaque500(w) return 0, false @@ -160,7 +160,7 @@ ON CONFLICT (reading_id, user_id) DO NOTHING`, readingID, userID, now).Error writeOpaque500(w) return } - readAt := string(models.NewDateTime(progress.ReadAt)) + readAt := models.FormatDateTime(progress.ReadAt) writeData(w, progressJSON{ReadingID: readingID, ReadAt: &readAt}) } diff --git a/controllers/api/schedule.go b/controllers/api/schedule.go index 620fce5..93aab26 100644 --- a/controllers/api/schedule.go +++ b/controllers/api/schedule.go @@ -32,8 +32,11 @@ func bounds(in map[string]any, lower time.Time) (time.Time, *time.Time) { return lower, nil } +// preloadSession queries the class sessions students see, with their course +// and meeting. A session without a course is still pending on an unsaved +// course form and is left out. func preloadSession(q *gorm.DB) *gorm.DB { - return q.Preload("Course").Preload("Meeting") + return q.Preload("Course").Preload("Meeting").Where("jz_bm_class_sessions.course_id IS NOT NULL") } // Schedule answers GET /schedule?from=YYYY-MM-DD&to=YYYY-MM-DD: the class diff --git a/controllers/courses.go b/controllers/courses.go index ec0d69a..1ee8700 100644 --- a/controllers/courses.go +++ b/controllers/courses.go @@ -3,6 +3,9 @@ package controllers import ( + "context" + + "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/jakub/sm-bm-plugin/models" ) @@ -11,6 +14,10 @@ var ( _ pact.AdminController = coursesAdmin{} _ pact.AdminRecordSource = coursesAdmin{} _ pact.AdminPermissioned = coursesAdmin{} + + _ cabana.AdminRelationContractProvider = coursesAdmin{} + _ pact.RelationBeforeCreate = coursesAdmin{} + _ pact.RelationBeforeUpdate = coursesAdmin{} ) // coursesAdmin is the admin controller for courses (przedmioty). @@ -28,11 +35,41 @@ func (coursesAdmin) RequiredPermissions() []string { return []string{PermCourses} } -// TODO: requires SummerCMS change. The course form should embed relation -// managers for its readings and class sessions, but cabana relation managers -// only link and unlink belongsToMany rows through a pivot; hasMany children -// cannot be listed or edited from the parent form. Readings and class -// sessions have their own screens, filtered by course. +// AdminRelationContracts binds the course form's relation managers for its +// readings and class sessions. course_id is nullable, so both work on a +// course that is not saved yet. +func (coursesAdmin) AdminRelationContracts() []cabana.RelationContract { + return []cabana.RelationContract{ + { + Name: "readings", + Kind: cabana.RelationHasMany, + NewRelated: func() any { return &models.Reading{} }, + ForeignKey: "course_id", + Columns: map[string]string{ + "title": "title", "author": "author", "is_required": "is_required", "sort_order": "sort_order", + }, + }, + { + Name: "sessions", + Kind: cabana.RelationHasMany, + NewRelated: func() any { return &models.ClassSession{} }, + ForeignKey: "course_id", + Columns: map[string]string{ + "starts_at": "starts_at", "ends_at": "ends_at", "room": "room", "kind": "kind", + }, + }, + } +} + +// RelationBeforeCreate and RelationBeforeUpdate check that a class session +// ends after it starts, as the class sessions screen does. +func (coursesAdmin) RelationBeforeCreate(_ context.Context, _ string, _, child any) error { + return checkTemporal(child) +} + +func (coursesAdmin) RelationBeforeUpdate(_ context.Context, _ string, _, child any) error { + return checkTemporal(child) +} // CoursesController returns the jz.bm.courses admin controller. func CoursesController() pact.AdminController { return coursesAdmin{} } diff --git a/controllers/courses/config_relation.yaml b/controllers/courses/config_relation.yaml new file mode 100644 index 0000000..e860ed4 --- /dev/null +++ b/controllers/courses/config_relation.yaml @@ -0,0 +1,46 @@ +# Readings and class sessions are created, edited and deleted from the +# course form, also before the course is first saved (course_id is +# nullable). There is no link or unlink: a row without a course is only +# ever one pending on an unsaved form. +readings: + label: jz.bm::lang.courses.readings + view: + list: + columns: + title: + label: jz.bm::lang.common.title + searchable: true + author: + label: jz.bm::lang.readings.author + is_required: + label: jz.bm::lang.readings.is_required + sort_order: + label: jz.bm::lang.common.sort_order + toolbarButtons: create|update|delete + showSearch: true + manage: + form: ~/plugins/jz/bm/models/readings/fields_course.yaml + list: + columns: + title: + label: jz.bm::lang.common.title +sessions: + label: jz.bm::lang.courses.sessions + view: + list: + columns: + starts_at: + label: jz.bm::lang.sessions.starts_at + ends_at: + label: jz.bm::lang.sessions.ends_at + room: + label: jz.bm::lang.sessions.room + kind: + label: jz.bm::lang.sessions.kind + toolbarButtons: create|update|delete + manage: + form: ~/plugins/jz/bm/models/class_sessions/fields_course.yaml + list: + columns: + starts_at: + label: jz.bm::lang.sessions.starts_at diff --git a/controllers/hooks.go b/controllers/hooks.go index 59bb335..b26acb6 100644 --- a/controllers/hooks.go +++ b/controllers/hooks.go @@ -14,9 +14,10 @@ const ( PermMaterials = "jz.bm.access_materials" ) -// temporalHooks checks a model's Date and DateTime columns before every admin -// save and answers a bad value as a 422 on its field. lagoon.Validate has no -// date rules, so the model checks them itself (models.TemporalValidator). +// temporalHooks checks the order of a model's dates before every admin save +// and answers a bad value as a 422 on its field. lagoon.Validate cannot +// compare two fields, so the model checks them itself +// (models.TemporalValidator). type temporalHooks struct{} func (temporalHooks) FormBeforeCreate(_ context.Context, model any) error { diff --git a/controllers/materials.go b/controllers/materials.go index 111e672..69b8bc5 100644 --- a/controllers/materials.go +++ b/controllers/materials.go @@ -29,11 +29,8 @@ func (materialsAdmin) RequiredPermissions() []string { return []string{PermMaterials} } -// AdminFieldRelations binds the course picker and the tags. -// -// TODO: requires SummerCMS change. Materials carry their files as -// system_files attachments (field "files"), but cabana has no file upload -// form widget, so files are attached outside the admin form for now. +// AdminFieldRelations binds the course picker and the tags. The files field +// is bound by models.Material.AttachRelations. func (materialsAdmin) AdminFieldRelations() []cabana.FieldRelationContract { return []cabana.FieldRelationContract{ courseRelation("course_id"), diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index baadf2e..15dbefa 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -23,8 +23,6 @@ common: sort_order: Sort order created_at: Created none: — none — - date_comment: A date in the YYYY-MM-DD form, such as 2026-10-03. - datetime_comment: Date and time in Polish time, such as 2026-10-03 08:00. forms: wyklad: Lecture cwiczenia: Tutorial @@ -32,6 +30,8 @@ forms: seminarium: Seminar inne: Other courses: + readings: Readings + sessions: Class sessions title: Courses form: Course name: Name @@ -83,7 +83,8 @@ materials: body: Body (Markdown) body_comment: A note in Markdown. published_at: Published - published_at_comment: Leave empty for a draft that students do not see. Such as 2026-10-03 08:00. + files: Files + published_at_comment: Leave empty for a draft that students do not see. tags: Tags material_types: pdf: PDF diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 7b9cff1..e236482 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -23,8 +23,6 @@ common: sort_order: Kolejność created_at: Utworzono none: — brak — - date_comment: Data w formacie RRRR-MM-DD, np. 2026-10-03. - datetime_comment: Data i godzina czasu polskiego, np. 2026-10-03 08:00. forms: wyklad: Wykład cwiczenia: Ćwiczenia @@ -32,6 +30,8 @@ forms: seminarium: Seminarium inne: Inne courses: + readings: Lektury + sessions: Zajęcia title: Przedmioty form: Przedmiot name: Nazwa @@ -83,7 +83,8 @@ materials: body: Treść (Markdown) body_comment: Notatka w Markdownie. published_at: Opublikowano - published_at_comment: Puste pole to szkic, niewidoczny dla studentów. Np. 2026-10-03 08:00. + files: Pliki + published_at_comment: Puste pole to szkic, niewidoczny dla studentów. tags: Tagi material_types: pdf: PDF diff --git a/models/class_session.go b/models/class_session.go index 10dbf59..18e2790 100644 --- a/models/class_session.go +++ b/models/class_session.go @@ -11,12 +11,12 @@ import ( // ClassSession is one block of classes (zajęcia) of a course. type ClassSession struct { ID uint `gorm:"column:id;primaryKey"` - CourseID uint `gorm:"column:course_id"` + CourseID *uint `gorm:"column:course_id"` Course *Course `gorm:"foreignKey:CourseID"` MeetingID *uint `gorm:"column:meeting_id"` Meeting *Meeting `gorm:"foreignKey:MeetingID"` - StartsAt DateTime `gorm:"column:starts_at"` - EndsAt DateTime `gorm:"column:ends_at"` + StartsAt time.Time `gorm:"column:starts_at"` + EndsAt time.Time `gorm:"column:ends_at"` Room *string `gorm:"column:room"` Kind *string `gorm:"column:kind"` Notes *string `gorm:"column:notes"` @@ -45,12 +45,11 @@ func (ClassSession) Rules() map[string]string { } } -// TemporalErrors checks the timestamps, which lagoon.Validate cannot. +// TemporalErrors checks that the session ends after it starts, which +// lagoon.Validate cannot. The required rules cover missing times. func (s *ClassSession) TemporalErrors() map[string][]string { e := temporalErrors{} - e.dateTime("starts_at", s.StartsAt, true) - e.dateTime("ends_at", s.EndsAt, true) - if len(e) == 0 && !s.EndsAt.MustTime().After(s.StartsAt.MustTime()) { + if !s.StartsAt.IsZero() && !s.EndsAt.IsZero() && !s.EndsAt.After(s.StartsAt) { e.add("ends_at", "The ends at must be a date after starts at.") } return e.result() diff --git a/models/class_sessions/fields.yaml b/models/class_sessions/fields.yaml index 3497285..0870713 100644 --- a/models/class_sessions/fields.yaml +++ b/models/class_sessions/fields.yaml @@ -13,16 +13,20 @@ fields: emptyOption: jz.bm::lang.common.none starts_at: label: jz.bm::lang.sessions.starts_at - type: text + type: datepicker + mode: datetime + format: d.m.Y H:i + firstDay: 1 span: left required: true - comment: jz.bm::lang.common.datetime_comment ends_at: label: jz.bm::lang.sessions.ends_at - type: text + type: datepicker + mode: datetime + format: d.m.Y H:i + firstDay: 1 span: right required: true - comment: jz.bm::lang.common.datetime_comment room: label: jz.bm::lang.sessions.room type: text diff --git a/models/class_sessions/fields_course.yaml b/models/class_sessions/fields_course.yaml new file mode 100644 index 0000000..a8130a6 --- /dev/null +++ b/models/class_sessions/fields_course.yaml @@ -0,0 +1,36 @@ +# The class session form inside the course form's relation manager: the +# course is the parent, and a relation form has no relation pickers, so the +# meeting (zjazd) is set on the class sessions screen. +fields: + starts_at: + label: jz.bm::lang.sessions.starts_at + type: datepicker + mode: datetime + format: d.m.Y H:i + firstDay: 1 + span: left + required: true + ends_at: + label: jz.bm::lang.sessions.ends_at + type: datepicker + mode: datetime + format: d.m.Y H:i + firstDay: 1 + span: right + required: true + room: + label: jz.bm::lang.sessions.room + type: text + span: left + kind: + label: jz.bm::lang.sessions.kind + type: text + span: right + is_online: + label: jz.bm::lang.sessions.is_online + type: switch + span: left + notes: + label: jz.bm::lang.common.notes + type: textarea + size: small diff --git a/models/course.go b/models/course.go index d43847b..f2cecba 100644 --- a/models/course.go +++ b/models/course.go @@ -39,6 +39,11 @@ type Course struct { SortOrder int `gorm:"column:sort_order"` CreatedAt time.Time `gorm:"column:created_at"` UpdatedAt time.Time `gorm:"column:updated_at"` + + // Readings and Sessions are the hasMany relations the course form's + // relation managers edit. + Readings []Reading `gorm:"foreignKey:CourseID"` + Sessions []ClassSession `gorm:"foreignKey:CourseID"` } // TableName is the plugin table. diff --git a/models/courses/fields.yaml b/models/courses/fields.yaml index bc2c7a8..9ba648f 100644 --- a/models/courses/fields.yaml +++ b/models/courses/fields.yaml @@ -56,3 +56,9 @@ fields: label: jz.bm::lang.common.description type: textarea size: large + readings: + type: relation-manager + relation: readings + sessions: + type: relation-manager + relation: sessions diff --git a/models/deadline.go b/models/deadline.go index fa8f119..42ebdc9 100644 --- a/models/deadline.go +++ b/models/deadline.go @@ -16,7 +16,7 @@ var DeadlineKinds = []string{"zaliczenie", "egzamin", "praca", "organizacyjne", type Deadline struct { ID uint `gorm:"column:id;primaryKey"` Title string `gorm:"column:title"` - DueAt DateTime `gorm:"column:due_at"` + DueAt time.Time `gorm:"column:due_at"` CourseID *uint `gorm:"column:course_id"` Course *Course `gorm:"foreignKey:CourseID"` Kind string `gorm:"column:kind"` @@ -42,13 +42,6 @@ func (Deadline) Rules() map[string]string { } } -// TemporalErrors checks the due time, which lagoon.Validate cannot. -func (d *Deadline) TemporalErrors() map[string][]string { - e := temporalErrors{} - e.dateTime("due_at", d.DueAt, true) - return e.result() -} - // BeforeSave stores NULL for empty optional text. func (d *Deadline) BeforeSave(*gorm.DB) error { blank(&d.Description) diff --git a/models/deadlines/fields.yaml b/models/deadlines/fields.yaml index c817b9a..9914aab 100644 --- a/models/deadlines/fields.yaml +++ b/models/deadlines/fields.yaml @@ -6,10 +6,12 @@ fields: required: true due_at: label: jz.bm::lang.deadlines.due_at - type: text + type: datepicker + mode: datetime + format: d.m.Y H:i + firstDay: 1 span: right required: true - comment: jz.bm::lang.common.datetime_comment kind: label: jz.bm::lang.deadlines.kind type: dropdown diff --git a/models/material.go b/models/material.go index f2c0a7e..b9802c6 100644 --- a/models/material.go +++ b/models/material.go @@ -5,6 +5,7 @@ package models import ( "time" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" "gorm.io/gorm" ) @@ -19,17 +20,17 @@ const MaterialFilesField = "files" // tied to a course. A material without published_at is a draft and is not // served by the API. type Material struct { - ID uint `gorm:"column:id;primaryKey"` - CourseID *uint `gorm:"column:course_id"` - Course *Course `gorm:"foreignKey:CourseID"` - Title string `gorm:"column:title"` - Type string `gorm:"column:type"` - Body *string `gorm:"column:body"` - Source *string `gorm:"column:source"` - URL *string `gorm:"column:url"` - PublishedAt *DateTime `gorm:"column:published_at"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` + ID uint `gorm:"column:id;primaryKey"` + CourseID *uint `gorm:"column:course_id"` + Course *Course `gorm:"foreignKey:CourseID"` + Title string `gorm:"column:title"` + Type string `gorm:"column:type"` + Body *string `gorm:"column:body"` + Source *string `gorm:"column:source"` + URL *string `gorm:"column:url"` + PublishedAt *time.Time `gorm:"column:published_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` Tags []Tag `gorm:"many2many:jz_bm_material_tag;joinForeignKey:material_id;joinReferences:tag_id"` } @@ -55,21 +56,14 @@ func (Material) Rules() map[string]string { } } -// TemporalErrors checks published_at, which lagoon.Validate cannot. -func (m *Material) TemporalErrors() map[string][]string { - e := temporalErrors{} - if m.PublishedAt != nil { - e.dateTime("published_at", *m.PublishedAt, false) - } - return e.result() +// AttachRelations declares the attachMany "files". The files are public: +// the student app links them by their storage URL. +func (Material) AttachRelations() []attach.Relation { + return []attach.Relation{{Name: MaterialFilesField, Many: true, Public: true}} } -// BeforeSave stores NULL for empty optional text and an empty publication -// time. +// BeforeSave stores NULL for empty optional text. func (m *Material) BeforeSave(*gorm.DB) error { blank(&m.Body, &m.Source, &m.URL) - if m.PublishedAt != nil && *m.PublishedAt == "" { - m.PublishedAt = nil - } return nil } diff --git a/models/materials/fields.yaml b/models/materials/fields.yaml index 54e9ba8..3f0c2af 100644 --- a/models/materials/fields.yaml +++ b/models/materials/fields.yaml @@ -1,5 +1,3 @@ -# Attached files (system_files, field "files") have no form widget yet; see -# the TODO on materialsAdmin.AdminFieldRelations. fields: title: label: jz.bm::lang.common.title @@ -24,7 +22,10 @@ fields: emptyOption: jz.bm::lang.common.none published_at: label: jz.bm::lang.materials.published_at - type: text + type: datepicker + mode: datetime + format: d.m.Y H:i + firstDay: 1 span: right comment: jz.bm::lang.materials.published_at_comment source: @@ -39,6 +40,13 @@ fields: label: jz.bm::lang.materials.tags type: relation nameFrom: name + files: + label: jz.bm::lang.materials.files + type: fileupload + mode: file + maxFilesize: 50 + maxFiles: 20 + useCaption: true body: label: jz.bm::lang.materials.body type: textarea diff --git a/models/meeting.go b/models/meeting.go index 2cff79f..1af075d 100644 --- a/models/meeting.go +++ b/models/meeting.go @@ -5,18 +5,19 @@ package models import ( "time" + "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) // Meeting is one zjazd: a weekend of classes of the part-time programme. type Meeting struct { - ID uint `gorm:"column:id;primaryKey"` - Number int `gorm:"column:number"` - StartsOn Date `gorm:"column:starts_on"` - EndsOn Date `gorm:"column:ends_on"` - Notes *string `gorm:"column:notes"` - CreatedAt time.Time `gorm:"column:created_at"` - UpdatedAt time.Time `gorm:"column:updated_at"` + ID uint `gorm:"column:id;primaryKey"` + Number int `gorm:"column:number"` + StartsOn lagoon.Date `gorm:"column:starts_on"` + EndsOn lagoon.Date `gorm:"column:ends_on"` + Notes *string `gorm:"column:notes"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` } // TableName is the plugin table. @@ -34,12 +35,11 @@ func (Meeting) Rules() map[string]string { } } -// TemporalErrors checks the dates, which lagoon.Validate cannot. +// TemporalErrors checks the order of the dates, which lagoon.Validate +// cannot. The required rules cover missing dates. func (m *Meeting) TemporalErrors() map[string][]string { e := temporalErrors{} - e.date("starts_on", m.StartsOn, true) - e.date("ends_on", m.EndsOn, true) - if len(e) == 0 && m.EndsOn < m.StartsOn { + if !m.StartsOn.IsZero() && !m.EndsOn.IsZero() && m.EndsOn.Time(nil).Before(m.StartsOn.Time(nil)) { e.add("ends_on", "The ends on must be a date after or equal to starts on.") } return e.result() diff --git a/models/meetings/fields.yaml b/models/meetings/fields.yaml index 1e85ca9..aa9a39f 100644 --- a/models/meetings/fields.yaml +++ b/models/meetings/fields.yaml @@ -6,16 +6,20 @@ fields: required: true starts_on: label: jz.bm::lang.meetings.starts_on - type: text + type: datepicker + mode: date + format: d.m.Y + firstDay: 1 span: left required: true - comment: jz.bm::lang.common.date_comment ends_on: label: jz.bm::lang.meetings.ends_on - type: text + type: datepicker + mode: date + format: d.m.Y + firstDay: 1 span: right required: true - comment: jz.bm::lang.common.date_comment notes: label: jz.bm::lang.common.notes type: textarea diff --git a/models/nullable.go b/models/nullable.go index fd708c6..c4447d9 100644 --- a/models/nullable.go +++ b/models/nullable.go @@ -17,24 +17,6 @@ type temporalErrors map[string][]string func (e temporalErrors) add(field, msg string) { e[field] = append(e[field], msg) } -func (e temporalErrors) dateTime(field string, v DateTime, required bool) { - switch { - case v == "" && required: - e.add(field, "The "+strings.ReplaceAll(field, "_", " ")+" field is required.") - case v != "" && !v.Valid(): - e.add(field, "The "+strings.ReplaceAll(field, "_", " ")+" must be a date and time such as 2026-10-03 08:00.") - } -} - -func (e temporalErrors) date(field string, v Date, required bool) { - switch { - case v == "" && required: - e.add(field, "The "+strings.ReplaceAll(field, "_", " ")+" field is required.") - case v != "" && !v.Valid(): - e.add(field, "The "+strings.ReplaceAll(field, "_", " ")+" must be a date in the YYYY-MM-DD form.") - } -} - func (e temporalErrors) result() map[string][]string { if len(e) == 0 { return nil @@ -42,9 +24,9 @@ func (e temporalErrors) result() map[string][]string { return e } -// TemporalValidator is implemented by models with Date or DateTime columns. -// The admin controllers call it before a save, because lagoon.Validate has -// no date rules. +// TemporalValidator is implemented by models whose dates must be in order. +// The admin controllers call it before a save, because lagoon.Validate +// cannot compare two fields. type TemporalValidator interface { TemporalErrors() map[string][]string } diff --git a/models/reading.go b/models/reading.go index f00d216..af0f178 100644 --- a/models/reading.go +++ b/models/reading.go @@ -11,7 +11,7 @@ import ( // Reading is one lektura on a course's reading list. type Reading struct { ID uint `gorm:"column:id;primaryKey"` - CourseID uint `gorm:"column:course_id"` + CourseID *uint `gorm:"column:course_id"` Course *Course `gorm:"foreignKey:CourseID"` Title string `gorm:"column:title"` Author *string `gorm:"column:author"` diff --git a/models/readings/fields_course.yaml b/models/readings/fields_course.yaml new file mode 100644 index 0000000..3a09c23 --- /dev/null +++ b/models/readings/fields_course.yaml @@ -0,0 +1,29 @@ +# The reading form inside the course form's relation manager: the course is +# the parent, so there is no course picker. +fields: + title: + label: jz.bm::lang.common.title + type: text + span: left + required: true + author: + label: jz.bm::lang.readings.author + type: text + span: right + source: + label: jz.bm::lang.common.source + type: text + span: left + url: + label: jz.bm::lang.common.url + type: text + span: right + is_required: + label: jz.bm::lang.readings.is_required + type: switch + span: left + sort_order: + label: jz.bm::lang.common.sort_order + type: number + span: right + default: 0 diff --git a/models/temporal.go b/models/temporal.go index 8009bf8..518eed8 100644 --- a/models/temporal.go +++ b/models/temporal.go @@ -1,11 +1,12 @@ package models import ( - "database/sql/driver" "fmt" "strings" "time" _ "time/tzdata" // Europe/Warsaw must resolve on hosts without zoneinfo. + + "git.golem15.com/golem15/summercms/modules/lagoon" ) // Location is the time zone the programme runs in. Timestamps are stored in @@ -21,9 +22,6 @@ func mustLoadLocation(name string) *time.Location { return loc } -// DateLayout is the textual form of a Date. -const DateLayout = "2006-01-02" - // dateTimeLayouts are the forms ParseDateTime accepts. RFC 3339 carries its // own offset; the others are wall-clock times in Location. var dateTimeLayouts = []string{ @@ -34,7 +32,7 @@ var dateTimeLayouts = []string{ } // ParseDateTime reads an RFC 3339 timestamp, or a wall-clock time -// ("2026-10-03 08:00") in Location. +// ("2026-10-03 08:00") in Location. The seed file uses these forms. func ParseDateTime(s string) (time.Time, error) { s = strings.TrimSpace(s) if t, err := time.Parse(time.RFC3339, s); err == nil { @@ -49,138 +47,14 @@ func ParseDateTime(s string) (time.Time, error) { } // ParseDate reads a YYYY-MM-DD calendar date. -func ParseDate(s string) (time.Time, error) { - t, err := time.ParseInLocation(DateLayout, strings.TrimSpace(s), Location) +func ParseDate(s string) (lagoon.Date, error) { + d, err := lagoon.ParseDate(strings.TrimSpace(s)) if err != nil { - return time.Time{}, fmt.Errorf("jz.bm: %q is not a YYYY-MM-DD date", s) + return lagoon.Date{}, fmt.Errorf("jz.bm: %q is not a YYYY-MM-DD date", s) } - return t, nil + return d, nil } -// DateTime is a TIMESTAMPTZ column held as RFC 3339 text in Location +// FormatDateTime is the API form of a timestamp: RFC 3339 in Location // ("2026-10-03T08:00:00+02:00"). -// -// It is a string type rather than time.Time because the admin form has no -// date picker and lagoon.Fill cannot write text into a time.Time field, while -// cabana treats any struct-typed field as a relation. A string type is filled -// from the form's text input, and Value checks and normalises it. -// TODO: requires SummerCMS change (a datetime form field type and Fill -// support for time.Time) to use time.Time here. -type DateTime string - -// NewDateTime returns t as a DateTime in Location. -func NewDateTime(t time.Time) DateTime { - return DateTime(t.In(Location).Format(time.RFC3339)) -} - -// Time parses the value. -func (d DateTime) Time() (time.Time, error) { return ParseDateTime(string(d)) } - -// MustTime parses the value and returns the zero time when it does not parse. -// Values read from the database always parse. -func (d DateTime) MustTime() time.Time { - t, _ := d.Time() - return t -} - -// Valid reports whether the value parses. -func (d DateTime) Valid() bool { - _, err := d.Time() - return err == nil -} - -// Value stores the instant in UTC. An empty value is SQL NULL. -func (d DateTime) Value() (driver.Value, error) { - if strings.TrimSpace(string(d)) == "" { - return nil, nil - } - t, err := d.Time() - if err != nil { - return nil, err - } - return t.UTC(), nil -} - -// Scan reads a TIMESTAMPTZ (or its text) and normalises it to Location. -func (d *DateTime) Scan(src any) error { - switch v := src.(type) { - case nil: - *d = "" - case time.Time: - *d = NewDateTime(v) - case string: - return d.scanText(v) - case []byte: - return d.scanText(string(v)) - default: - return fmt.Errorf("jz.bm: cannot scan %T into DateTime", src) - } - return nil -} - -func (d *DateTime) scanText(s string) error { - if strings.TrimSpace(s) == "" { - *d = "" - return nil - } - t, err := ParseDateTime(s) - if err != nil { - // Postgres text output: "2026-10-03 06:00:00+00". - t, err = time.Parse("2006-01-02 15:04:05-07", s) - if err != nil { - return err - } - } - *d = NewDateTime(t) - return nil -} - -// Date is a DATE column held as YYYY-MM-DD text, for the same reason as -// DateTime. -type Date string - -// NewDate returns the calendar date of t in Location. -func NewDate(t time.Time) Date { return Date(t.In(Location).Format(DateLayout)) } - -// Time returns midnight of the date in Location. -func (d Date) Time() (time.Time, error) { return ParseDate(string(d)) } - -// Valid reports whether the value is a YYYY-MM-DD date. -func (d Date) Valid() bool { - _, err := d.Time() - return err == nil -} - -// Value stores the date as text; Postgres casts it to DATE. An empty value is -// SQL NULL. -func (d Date) Value() (driver.Value, error) { - if strings.TrimSpace(string(d)) == "" { - return nil, nil - } - t, err := d.Time() - if err != nil { - return nil, err - } - return t.Format(DateLayout), nil -} - -// Scan reads a DATE. The driver hands it over as midnight UTC, so the -// calendar fields are taken as they are, without a zone conversion. -func (d *Date) Scan(src any) error { - switch v := src.(type) { - case nil: - *d = "" - case time.Time: - *d = Date(v.Format(DateLayout)) - case string: - *d = Date(strings.TrimSpace(v)) - if len(*d) > len(DateLayout) { - *d = (*d)[:len(DateLayout)] - } - case []byte: - return d.Scan(string(v)) - default: - return fmt.Errorf("jz.bm: cannot scan %T into Date", src) - } - return nil -} +func FormatDateTime(t time.Time) string { return t.In(Location).Format(time.RFC3339) } diff --git a/models/temporal_test.go b/models/temporal_test.go index 1f057ea..e19a9ab 100644 --- a/models/temporal_test.go +++ b/models/temporal_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/jakub/sm-bm-plugin/models" ) @@ -37,85 +38,41 @@ func TestParseDateTimeForms(t *testing.T) { } } -func TestDateTimeRoundTrip(t *testing.T) { - d := models.DateTime("2026-10-03 08:00") - v, err := d.Value() - if err != nil { - t.Fatal(err) +func TestParseDateAndFormat(t *testing.T) { + d, err := models.ParseDate(" 2026-10-03 ") + if err != nil || d.String() != "2026-10-03" { + t.Errorf("ParseDate = %v, %v", d, err) } - stored, ok := v.(time.Time) - if !ok || stored.Location() != time.UTC || stored.Hour() != 6 { - t.Fatalf("Value = %#v, want 06:00 UTC", v) + if _, err := models.ParseDate("03.10.2026"); err == nil { + t.Error("a non-ISO date was accepted") } - var read models.DateTime - if err := read.Scan(stored); err != nil { - t.Fatal(err) - } - if read != "2026-10-03T08:00:00+02:00" { - t.Errorf("Scan = %q, want the Warsaw RFC 3339 form", read) - } - if err := read.Scan("2026-10-03 06:00:00+00"); err != nil || read != "2026-10-03T08:00:00+02:00" { - t.Errorf("Scan(postgres text) = %q, %v", read, err) - } - if err := read.Scan(nil); err != nil || read != "" { - t.Errorf("Scan(nil) = %q, %v", read, err) - } - if v, err := models.DateTime("").Value(); err != nil || v != nil { - t.Errorf("empty Value = %v, %v; want NULL", v, err) - } - if _, err := models.DateTime("soon").Value(); err == nil { - t.Error("an unparsable DateTime was stored") - } - if err := read.Scan(42); err == nil { - t.Error("Scan(int) succeeded") - } -} - -func TestDateRoundTrip(t *testing.T) { - var d models.Date - if err := d.Scan(time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)); err != nil || d != "2026-10-03" { - t.Errorf("Scan(time) = %q, %v", d, err) - } - if err := d.Scan([]byte("2026-10-04")); err != nil || d != "2026-10-04" { - t.Errorf("Scan(bytes) = %q, %v", d, err) - } - if v, err := models.Date("2026-10-03").Value(); err != nil || v != "2026-10-03" { - t.Errorf("Value = %v, %v", v, err) - } - if _, err := models.Date("03.10.2026").Value(); err == nil { - t.Error("a non-ISO date was stored") - } - if models.NewDate(time.Date(2026, 10, 2, 23, 30, 0, 0, time.UTC)) != "2026-10-03" { - t.Error("NewDate does not use the Warsaw calendar day") + got := models.FormatDateTime(time.Date(2026, 10, 3, 6, 0, 0, 0, time.UTC)) + if got != "2026-10-03T08:00:00+02:00" { + t.Errorf("FormatDateTime = %q, want the Warsaw RFC 3339 form", got) } } func TestTemporalErrors(t *testing.T) { - s := &models.ClassSession{StartsAt: "2026-10-03 10:00", EndsAt: "2026-10-03 09:00"} + at := func(h int) time.Time { return time.Date(2026, 10, 3, h, 0, 0, 0, models.Location) } + s := &models.ClassSession{StartsAt: at(10), EndsAt: at(9)} if errs := s.TemporalErrors(); len(errs["ends_at"]) != 1 { t.Errorf("end before start: %v", errs) } - s = &models.ClassSession{StartsAt: "bad"} - errs := s.TemporalErrors() - if len(errs["starts_at"]) != 1 || len(errs["ends_at"]) != 1 { - t.Errorf("bad and missing: %v", errs) + if errs := (&models.ClassSession{StartsAt: at(8), EndsAt: at(10)}).TemporalErrors(); errs != nil { + t.Errorf("valid session: %v", errs) } - m := &models.Meeting{StartsOn: "2026-10-04", EndsOn: "2026-10-03"} + // Missing times are the required rules' job, not an order error. + if errs := (&models.ClassSession{}).TemporalErrors(); errs != nil { + t.Errorf("empty session: %v", errs) + } + m := &models.Meeting{StartsOn: lagoon.NewDate(2026, 10, 4), EndsOn: lagoon.NewDate(2026, 10, 3)} if errs := m.TemporalErrors(); len(errs["ends_on"]) != 1 { t.Errorf("meeting order: %v", errs) } - ok := &models.Meeting{StartsOn: "2026-10-03", EndsOn: "2026-10-04"} + ok := &models.Meeting{StartsOn: lagoon.NewDate(2026, 10, 3), EndsOn: lagoon.NewDate(2026, 10, 4)} if errs := ok.TemporalErrors(); errs != nil { t.Errorf("valid meeting: %v", errs) } - draft := &models.Material{} - if errs := draft.TemporalErrors(); errs != nil { - t.Errorf("draft material: %v", errs) - } - bad := models.DateTime("yesterday") - if errs := (&models.Material{PublishedAt: &bad}).TemporalErrors(); len(errs["published_at"]) != 1 { - t.Errorf("bad published_at: %v", errs) - } } func TestBeforeSaveBlanksOptionalText(t *testing.T) { @@ -127,10 +84,4 @@ func TestBeforeSaveBlanksOptionalText(t *testing.T) { if c.Code != nil || c.Lecturer != nil { t.Errorf("blank text kept: %v %v", c.Code, c.Lecturer) } - pub := models.DateTime("") - m := &models.Material{PublishedAt: &pub} - _ = m.BeforeSave(nil) - if m.PublishedAt != nil { - t.Error("an empty published_at was not cleared to a draft") - } } diff --git a/plugin_test.go b/plugin_test.go index 723e1db..ee142e3 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -28,7 +28,7 @@ const ( func testConfig(t *testing.T) *compass.Config { t.Helper() dir := t.TempDir() - httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n" + httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 67108864\n" if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil { t.Fatal(err) } @@ -76,8 +76,8 @@ func TestPluginActivates(t *testing.T) { if n := len(p.Models()); n != 10 { t.Errorf("Models = %d, want 10", n) } - if n := len(p.Migrations()); n != 11 { - t.Errorf("Migrations = %d, want 11", n) + if n := len(p.Migrations()); n != 12 { + t.Errorf("Migrations = %d, want 12", n) } if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) { t.Error("bm:seed is not registered") diff --git a/postgres_test.go b/postgres_test.go index f1b1dec..a495c14 100644 --- a/postgres_test.go +++ b/postgres_test.go @@ -242,7 +242,7 @@ func TestSeedCommandIsIdempotent(t *testing.T) { if err := gdb.Preload("Course").Preload("Meeting").Where("room = ?", "101").Take(&session).Error; err != nil { t.Fatal(err) } - if session.StartsAt != "2030-10-05T08:00:00+02:00" || session.Course.Name != "Przedmiot przykładowy A" || session.Meeting.Number != 1 { + if !session.StartsAt.Equal(time.Date(2030, 10, 5, 6, 0, 0, 0, time.UTC)) || session.Course.Name != "Przedmiot przykładowy A" || session.Meeting.Number != 1 { t.Errorf("session = %+v", session) } var stored time.Time @@ -441,8 +441,8 @@ func TestAPIAgainstDatabase(t *testing.T) { // Materials: a published note with tags and files, a published link, a // draft and one scheduled for the future. - past := models.DateTime("2026-01-15 12:00") - future := models.DateTime("2099-01-01 12:00") + past := time.Date(2026, 1, 15, 11, 0, 0, 0, time.UTC) + future := time.Date(2099, 1, 1, 11, 0, 0, 0, time.UTC) note := models.Material{CourseID: &courseA.ID, Title: "Notatka", Type: "notatka", Body: ptr("# Nagłówek"), PublishedAt: &past} link := models.Material{CourseID: &courseAC.ID, Title: "Link", Type: "link", URL: ptr("https://example.test"), PublishedAt: &past} draft := models.Material{CourseID: &courseA.ID, Title: "Szkic", Type: "notatka"} diff --git a/registry.gen.go b/registry.gen.go index 631feff..557a90d 100644 --- a/registry.gen.go +++ b/registry.gen.go @@ -39,6 +39,7 @@ func generatedMigrations() []*gormigrate.Migration { updates.CreateMaterialTag(), updates.AddElectiveGroupToCourses(), updates.CreateCalendarFeeds(), + updates.MakeCourseIDNullable(), } } diff --git a/static_test.go b/static_test.go index cd130ee..1c2484e 100644 --- a/static_test.go +++ b/static_test.go @@ -208,7 +208,7 @@ func TestEmbeddedPublicHasIndex(t *testing.T) { // the rest does. func TestStaticThroughRouter(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte("body_limits:\n default_bytes: 1048576\n upload_bytes: 67108864\n"), 0o644); err != nil { t.Fatal(err) } cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{ diff --git a/updates/20261002234500_make_course_id_nullable.go b/updates/20261002234500_make_course_id_nullable.go new file mode 100644 index 0000000..30e2057 --- /dev/null +++ b/updates/20261002234500_make_course_id_nullable.go @@ -0,0 +1,27 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// MakeCourseIDNullable returns the 20261002234500_make_course_id_nullable +// gormigrate entry: class sessions and readings may have no course while +// the course form that creates them is not saved yet. The relation managers +// on the course form hold such rows in deferred_bindings, and the first save +// of the course sets course_id; deferred:purge deletes abandoned ones. +func MakeCourseIDNullable() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "20261002234500_make_course_id_nullable", + Migrate: func(tx *gorm.DB) error { + return execAll(tx, `ALTER TABLE jz_bm_class_sessions ALTER COLUMN course_id DROP NOT NULL; +ALTER TABLE jz_bm_readings ALTER COLUMN course_id DROP NOT NULL`) + }, + Rollback: func(tx *gorm.DB) error { + return execAll(tx, `DELETE FROM jz_bm_class_sessions WHERE course_id IS NULL; +DELETE FROM jz_bm_readings WHERE course_id IS NULL; +ALTER TABLE jz_bm_class_sessions ALTER COLUMN course_id SET NOT NULL; +ALTER TABLE jz_bm_readings ALTER COLUMN course_id SET NOT NULL`) + }, + } +}