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:
Jakub Zych
2026-10-03 06:03:34 +02:00
parent ac2e2ddd7b
commit 71b848a376
39 changed files with 475 additions and 373 deletions

View File

@@ -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()

View File

@@ -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

View 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

View File

@@ -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.

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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
}

View File

@@ -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"`

View 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

View File

@@ -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) }

View File

@@ -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")
}
}