feat: add the study data models and schema migrations
Courses, meetings, class sessions, deadlines, readings with per-user progress, materials with tags and file attachments. Timestamps are stored in UTC and held as Europe/Warsaw text so the admin text inputs can write them.
This commit is contained in:
63
models/class_session.go
Normal file
63
models/class_session.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
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"`
|
||||
Room *string `gorm:"column:room"`
|
||||
Kind *string `gorm:"column:kind"`
|
||||
Notes *string `gorm:"column:notes"`
|
||||
IsOnline bool `gorm:"column:is_online"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
// TableName is the plugin table.
|
||||
func (ClassSession) TableName() string { return "jz_bm_class_sessions" }
|
||||
|
||||
// Fillable lists the columns mass assignment may set. course_id and
|
||||
// meeting_id are set through the admin relation pickers.
|
||||
func (ClassSession) Fillable() []string {
|
||||
return []string{"course_id", "meeting_id", "starts_at", "ends_at", "room", "kind", "notes", "is_online"}
|
||||
}
|
||||
|
||||
// Rules are checked by the admin API on every save.
|
||||
func (ClassSession) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"starts_at": "required",
|
||||
"ends_at": "required",
|
||||
"room": "nullable|max:100",
|
||||
"kind": "nullable|max:100",
|
||||
"is_online": "boolean",
|
||||
}
|
||||
}
|
||||
|
||||
// TemporalErrors checks the timestamps, which lagoon.Validate cannot.
|
||||
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()) {
|
||||
e.add("ends_at", "The ends at must be a date after starts at.")
|
||||
}
|
||||
return e.result()
|
||||
}
|
||||
|
||||
// BeforeSave stores NULL for empty optional text.
|
||||
func (s *ClassSession) BeforeSave(*gorm.DB) error {
|
||||
blank(&s.Room, &s.Kind, &s.Notes)
|
||||
return nil
|
||||
}
|
||||
72
models/course.go
Normal file
72
models/course.go
Normal file
@@ -0,0 +1,72 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Course forms, the values of Course.Form.
|
||||
const (
|
||||
FormWyklad = "wyklad"
|
||||
FormCwiczenia = "cwiczenia"
|
||||
FormKonwersatorium = "konwersatorium"
|
||||
FormSeminarium = "seminarium"
|
||||
FormInne = "inne"
|
||||
)
|
||||
|
||||
// CourseForms lists the allowed Course.Form values.
|
||||
var CourseForms = []string{FormWyklad, FormCwiczenia, FormKonwersatorium, FormSeminarium, FormInne}
|
||||
|
||||
// Course is one przedmiot of the programme in one form (a lecture and its
|
||||
// tutorial are two courses).
|
||||
type Course struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Code *string `gorm:"column:code"`
|
||||
Name string `gorm:"column:name"`
|
||||
Form string `gorm:"column:form"`
|
||||
Lecturer *string `gorm:"column:lecturer"`
|
||||
Semester int `gorm:"column:semester"`
|
||||
ECTS *float64 `gorm:"column:ects"`
|
||||
Assessment *string `gorm:"column:assessment"`
|
||||
// ElectiveGroup marks alternatives ("do wyboru"): courses that share a
|
||||
// value are options of one choice, such as "specjalnosc".
|
||||
ElectiveGroup *string `gorm:"column:elective_group"`
|
||||
Description *string `gorm:"column:description"`
|
||||
SyllabusURL *string `gorm:"column:syllabus_url"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
// TableName is the plugin table.
|
||||
func (Course) TableName() string { return "jz_bm_courses" }
|
||||
|
||||
// Fillable lists the columns mass assignment may set.
|
||||
func (Course) Fillable() []string {
|
||||
return []string{"code", "name", "form", "lecturer", "semester", "ects", "assessment", "elective_group", "description", "syllabus_url", "sort_order"}
|
||||
}
|
||||
|
||||
// Rules are checked by the admin API on every save.
|
||||
func (Course) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"code": "nullable|max:50",
|
||||
"name": "required|max:255",
|
||||
"form": "required|in:wyklad,cwiczenia,konwersatorium,seminarium,inne",
|
||||
"lecturer": "nullable|max:255",
|
||||
"semester": "required|integer|between:1,4",
|
||||
"ects": "nullable|numeric|between:0,60",
|
||||
"assessment": "nullable|max:100",
|
||||
"elective_group": "nullable|max:100",
|
||||
"syllabus_url": "nullable|max:2048",
|
||||
"sort_order": "nullable|integer",
|
||||
}
|
||||
}
|
||||
|
||||
// BeforeSave stores NULL for empty optional text.
|
||||
func (c *Course) BeforeSave(*gorm.DB) error {
|
||||
blank(&c.Code, &c.Lecturer, &c.Assessment, &c.ElectiveGroup, &c.Description, &c.SyllabusURL)
|
||||
return nil
|
||||
}
|
||||
56
models/deadline.go
Normal file
56
models/deadline.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DeadlineKinds lists the allowed Deadline.Kind values.
|
||||
var DeadlineKinds = []string{"zaliczenie", "egzamin", "praca", "organizacyjne", "inne"}
|
||||
|
||||
// Deadline is one entry of the terminarz: an exam, a submission or an
|
||||
// administrative date, optionally tied to a course.
|
||||
type Deadline struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
DueAt DateTime `gorm:"column:due_at"`
|
||||
CourseID *uint `gorm:"column:course_id"`
|
||||
Course *Course `gorm:"foreignKey:CourseID"`
|
||||
Kind string `gorm:"column:kind"`
|
||||
Description *string `gorm:"column:description"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
// TableName is the plugin table.
|
||||
func (Deadline) TableName() string { return "jz_bm_deadlines" }
|
||||
|
||||
// Fillable lists the columns mass assignment may set.
|
||||
func (Deadline) Fillable() []string {
|
||||
return []string{"title", "due_at", "course_id", "kind", "description"}
|
||||
}
|
||||
|
||||
// Rules are checked by the admin API on every save.
|
||||
func (Deadline) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"title": "required|max:255",
|
||||
"due_at": "required",
|
||||
"kind": "required|in:zaliczenie,egzamin,praca,organizacyjne,inne",
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
return nil
|
||||
}
|
||||
75
models/material.go
Normal file
75
models/material.go
Normal file
@@ -0,0 +1,75 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MaterialTypes lists the allowed Material.Type values.
|
||||
var MaterialTypes = []string{"pdf", "notatka", "link", "inne"}
|
||||
|
||||
// MaterialFilesField is the system_files field of a material's attachments
|
||||
// (attachMany "files").
|
||||
const MaterialFilesField = "files"
|
||||
|
||||
// Material is a study material: a note, a link or uploaded files, optionally
|
||||
// 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"`
|
||||
|
||||
Tags []Tag `gorm:"many2many:jz_bm_material_tag;joinForeignKey:material_id;joinReferences:tag_id"`
|
||||
}
|
||||
|
||||
// TableName is the plugin table.
|
||||
func (Material) TableName() string { return "jz_bm_materials" }
|
||||
|
||||
// MorphName is the attachment_type of the material's system_files rows.
|
||||
func (Material) MorphName() string { return `Jz\Bm\Models\Material` }
|
||||
|
||||
// Fillable lists the columns mass assignment may set.
|
||||
func (Material) Fillable() []string {
|
||||
return []string{"course_id", "title", "type", "body", "source", "url", "published_at"}
|
||||
}
|
||||
|
||||
// Rules are checked by the admin API on every save.
|
||||
func (Material) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"title": "required|max:255",
|
||||
"type": "required|in:pdf,notatka,link,inne",
|
||||
"source": "nullable|max:500",
|
||||
"url": "nullable|max:2048",
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// BeforeSave stores NULL for empty optional text and an empty publication
|
||||
// time.
|
||||
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
|
||||
}
|
||||
12
models/material_tag.go
Normal file
12
models/material_tag.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
// MaterialTag is the pivot row between a material and a tag.
|
||||
type MaterialTag struct {
|
||||
MaterialID uint `gorm:"column:material_id;primaryKey"`
|
||||
TagID uint `gorm:"column:tag_id;primaryKey"`
|
||||
}
|
||||
|
||||
// TableName is the pivot table.
|
||||
func (MaterialTag) TableName() string { return "jz_bm_material_tag" }
|
||||
52
models/meeting.go
Normal file
52
models/meeting.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"`
|
||||
}
|
||||
|
||||
// TableName is the plugin table.
|
||||
func (Meeting) TableName() string { return "jz_bm_meetings" }
|
||||
|
||||
// Fillable lists the columns mass assignment may set.
|
||||
func (Meeting) Fillable() []string { return []string{"number", "starts_on", "ends_on", "notes"} }
|
||||
|
||||
// Rules are checked by the admin API on every save.
|
||||
func (Meeting) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"number": "required|integer|min:1|unique:jz_bm_meetings",
|
||||
"starts_on": "required",
|
||||
"ends_on": "required",
|
||||
}
|
||||
}
|
||||
|
||||
// TemporalErrors checks the dates, which lagoon.Validate cannot.
|
||||
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 {
|
||||
e.add("ends_on", "The ends on must be a date after or equal to starts on.")
|
||||
}
|
||||
return e.result()
|
||||
}
|
||||
|
||||
// BeforeSave stores NULL for empty optional text.
|
||||
func (m *Meeting) BeforeSave(*gorm.DB) error {
|
||||
blank(&m.Notes)
|
||||
return nil
|
||||
}
|
||||
50
models/nullable.go
Normal file
50
models/nullable.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package models
|
||||
|
||||
import "strings"
|
||||
|
||||
// blank clears each optional text column whose value is empty or only
|
||||
// whitespace, so an emptied admin text input stores NULL rather than "".
|
||||
func blank(fields ...**string) {
|
||||
for _, f := range fields {
|
||||
if *f != nil && strings.TrimSpace(**f) == "" {
|
||||
*f = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// temporalErrors is a field-to-messages map in the shape of lagoon.Validate.
|
||||
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
|
||||
}
|
||||
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.
|
||||
type TemporalValidator interface {
|
||||
TemporalErrors() map[string][]string
|
||||
}
|
||||
50
models/reading.go
Normal file
50
models/reading.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
Course *Course `gorm:"foreignKey:CourseID"`
|
||||
Title string `gorm:"column:title"`
|
||||
Author *string `gorm:"column:author"`
|
||||
Source *string `gorm:"column:source"`
|
||||
URL *string `gorm:"column:url"`
|
||||
IsRequired bool `gorm:"column:is_required"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
// TableName is the plugin table.
|
||||
func (Reading) TableName() string { return "jz_bm_readings" }
|
||||
|
||||
// Fillable lists the columns mass assignment may set.
|
||||
func (Reading) Fillable() []string {
|
||||
return []string{"course_id", "title", "author", "source", "url", "is_required", "sort_order"}
|
||||
}
|
||||
|
||||
// Rules are checked by the admin API on every save.
|
||||
func (Reading) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"title": "required|max:500",
|
||||
"author": "nullable|max:255",
|
||||
"source": "nullable|max:500",
|
||||
"url": "nullable|max:2048",
|
||||
"is_required": "boolean",
|
||||
"sort_order": "nullable|integer",
|
||||
}
|
||||
}
|
||||
|
||||
// BeforeSave stores NULL for empty optional text.
|
||||
func (r *Reading) BeforeSave(*gorm.DB) error {
|
||||
blank(&r.Author, &r.Source, &r.URL)
|
||||
return nil
|
||||
}
|
||||
17
models/reading_progress.go
Normal file
17
models/reading_progress.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// ReadingProgress records that a user (a golem15.user users row) has read a
|
||||
// reading. One row per reading and user.
|
||||
type ReadingProgress struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
ReadingID uint `gorm:"column:reading_id"`
|
||||
UserID uint `gorm:"column:user_id"`
|
||||
ReadAt time.Time `gorm:"column:read_at"`
|
||||
}
|
||||
|
||||
// TableName is the plugin table.
|
||||
func (ReadingProgress) TableName() string { return "jz_bm_reading_progress" }
|
||||
28
models/tag.go
Normal file
28
models/tag.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Tag labels materials.
|
||||
type Tag struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Name string `gorm:"column:name"`
|
||||
Slug string `gorm:"column:slug"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
}
|
||||
|
||||
// TableName is the plugin table.
|
||||
func (Tag) TableName() string { return "jz_bm_tags" }
|
||||
|
||||
// Fillable lists the columns mass assignment may set.
|
||||
func (Tag) Fillable() []string { return []string{"name", "slug"} }
|
||||
|
||||
// Rules are checked by the admin API on every save.
|
||||
func (Tag) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"name": "required|max:100",
|
||||
"slug": "required|max:100|unique:jz_bm_tags",
|
||||
}
|
||||
}
|
||||
186
models/temporal.go
Normal file
186
models/temporal.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata" // Europe/Warsaw must resolve on hosts without zoneinfo.
|
||||
)
|
||||
|
||||
// Location is the time zone the programme runs in. Timestamps are stored in
|
||||
// UTC (TIMESTAMPTZ) and shown in this zone; a date or time typed without an
|
||||
// offset is read in it.
|
||||
var Location = mustLoadLocation("Europe/Warsaw")
|
||||
|
||||
func mustLoadLocation(name string) *time.Location {
|
||||
loc, err := time.LoadLocation(name)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("jz.bm: load location %s: %v", name, err))
|
||||
}
|
||||
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{
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
|
||||
// ParseDateTime reads an RFC 3339 timestamp, or a wall-clock time
|
||||
// ("2026-10-03 08:00") in Location.
|
||||
func ParseDateTime(s string) (time.Time, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
for _, layout := range dateTimeLayouts {
|
||||
if t, err := time.ParseInLocation(layout, s, Location); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("jz.bm: %q is not a date and time", s)
|
||||
}
|
||||
|
||||
// ParseDate reads a YYYY-MM-DD calendar date.
|
||||
func ParseDate(s string) (time.Time, error) {
|
||||
t, err := time.ParseInLocation(DateLayout, strings.TrimSpace(s), Location)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("jz.bm: %q is not a YYYY-MM-DD date", s)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// DateTime is a TIMESTAMPTZ column held as RFC 3339 text 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
|
||||
}
|
||||
136
models/temporal_test.go
Normal file
136
models/temporal_test.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package models_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
)
|
||||
|
||||
func TestParseDateTimeForms(t *testing.T) {
|
||||
want := time.Date(2026, 10, 3, 6, 0, 0, 0, time.UTC)
|
||||
for _, in := range []string{
|
||||
"2026-10-03T08:00:00+02:00",
|
||||
"2026-10-03T06:00:00Z",
|
||||
"2026-10-03 08:00",
|
||||
"2026-10-03T08:00",
|
||||
" 2026-10-03 08:00:00 ",
|
||||
} {
|
||||
got, err := models.ParseDateTime(in)
|
||||
if err != nil {
|
||||
t.Errorf("ParseDateTime(%q): %v", in, err)
|
||||
continue
|
||||
}
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("ParseDateTime(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
// Winter time: a wall-clock time in December is UTC+1.
|
||||
got, err := models.ParseDateTime("2026-12-01 08:00")
|
||||
if err != nil || !got.Equal(time.Date(2026, 12, 1, 7, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("winter wall clock = %v, %v", got, err)
|
||||
}
|
||||
for _, bad := range []string{"", "tomorrow", "2026-13-01 08:00", "2026-10-03"} {
|
||||
if _, err := models.ParseDateTime(bad); err == nil {
|
||||
t.Errorf("ParseDateTime(%q) accepted", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDateTimeRoundTrip(t *testing.T) {
|
||||
d := models.DateTime("2026-10-03 08:00")
|
||||
v, err := d.Value()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stored, ok := v.(time.Time)
|
||||
if !ok || stored.Location() != time.UTC || stored.Hour() != 6 {
|
||||
t.Fatalf("Value = %#v, want 06:00 UTC", v)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporalErrors(t *testing.T) {
|
||||
s := &models.ClassSession{StartsAt: "2026-10-03 10:00", EndsAt: "2026-10-03 09:00"}
|
||||
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)
|
||||
}
|
||||
m := &models.Meeting{StartsOn: "2026-10-04", EndsOn: "2026-10-03"}
|
||||
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"}
|
||||
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) {
|
||||
empty, space := "", " "
|
||||
c := &models.Course{Code: &empty, Lecturer: &space}
|
||||
if err := c.BeforeSave(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user