Adopt SummerCMS v0.1.1 admin features
Datepickers on native time.Time / lagoon.Date fields, file uploads on materials, readings and class sessions managed from the course form (course_id made nullable).
This commit is contained in:
@@ -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 `<backend.uri>/api/v1/jz/bm/<name>`), 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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{} }
|
||||
|
||||
46
controllers/courses/config_relation.yaml
Normal file
46
controllers/courses/config_relation.yaml
Normal file
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
36
models/class_sessions/fields_course.yaml
Normal file
36
models/class_sessions/fields_course.yaml
Normal file
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
29
models/readings/fields_course.yaml
Normal file
29
models/readings/fields_course.yaml
Normal file
@@ -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
|
||||
@@ -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) }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -39,6 +39,7 @@ func generatedMigrations() []*gormigrate.Migration {
|
||||
updates.CreateMaterialTag(),
|
||||
updates.AddElectiveGroupToCourses(),
|
||||
updates.CreateCalendarFeeds(),
|
||||
updates.MakeCourseIDNullable(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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{
|
||||
|
||||
27
updates/20261002234500_make_course_id_nullable.go
Normal file
27
updates/20261002234500_make_course_id_nullable.go
Normal file
@@ -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`)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user