feat: add the bm:seed command
Validates a whole JSON file (course keys, meeting numbers, values, dates) and upserts meetings, courses, class sessions, deadlines and readings in one transaction, matched on their natural identity. Prints a created, updated and unchanged summary; --dry-run only validates. Ships a small example fixture with made-up data.
This commit is contained in:
449
classes/seed.go
Normal file
449
classes/seed.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SeedFile is the JSON document bm:seed reads. Every top-level array is
|
||||
// optional. Courses carry a seed-local key that class sessions, deadlines
|
||||
// and readings use to point at them; meetings are referenced by number.
|
||||
type SeedFile struct {
|
||||
Meetings []SeedMeeting `json:"meetings"`
|
||||
Courses []SeedCourse `json:"courses"`
|
||||
ClassSessions []SeedSession `json:"class_sessions"`
|
||||
Deadlines []SeedDeadline `json:"deadlines"`
|
||||
Readings []SeedReading `json:"readings"`
|
||||
}
|
||||
|
||||
// SeedMeeting is one meeting; its identity is Number.
|
||||
type SeedMeeting struct {
|
||||
Number int `json:"number"`
|
||||
StartsOn string `json:"starts_on"`
|
||||
EndsOn string `json:"ends_on"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
// SeedCourse is one course; its identity is Name, Form and Semester. Key is
|
||||
// only a reference inside the file.
|
||||
type SeedCourse struct {
|
||||
Key string `json:"key"`
|
||||
Code *string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Form string `json:"form"`
|
||||
Lecturer *string `json:"lecturer"`
|
||||
Semester int `json:"semester"`
|
||||
ECTS *float64 `json:"ects"`
|
||||
Assessment *string `json:"assessment"`
|
||||
ElectiveGroup *string `json:"elective_group"`
|
||||
Description *string `json:"description"`
|
||||
SyllabusURL *string `json:"syllabus_url"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// SeedSession is one class session; its identity is the course and StartsAt.
|
||||
// Course is a course key, Meeting an optional meeting number.
|
||||
type SeedSession struct {
|
||||
Course string `json:"course"`
|
||||
Meeting *int `json:"meeting"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
Room *string `json:"room"`
|
||||
Kind *string `json:"kind"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
Notes *string `json:"notes"`
|
||||
}
|
||||
|
||||
// SeedDeadline is one deadline; its identity is Title and DueAt. Course is
|
||||
// an optional course key.
|
||||
type SeedDeadline struct {
|
||||
Title string `json:"title"`
|
||||
DueAt string `json:"due_at"`
|
||||
Course *string `json:"course"`
|
||||
Kind string `json:"kind"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
// SeedReading is one reading; its identity is the course and Title.
|
||||
type SeedReading struct {
|
||||
Course string `json:"course"`
|
||||
Title string `json:"title"`
|
||||
Author *string `json:"author"`
|
||||
Source *string `json:"source"`
|
||||
URL *string `json:"url"`
|
||||
IsRequired bool `json:"is_required"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// SeedCounts counts the rows of one kind a seed created, updated or left
|
||||
// unchanged.
|
||||
type SeedCounts struct {
|
||||
Created int
|
||||
Updated int
|
||||
Unchanged int
|
||||
}
|
||||
|
||||
func (c *SeedCounts) record(created, changed bool) {
|
||||
switch {
|
||||
case created:
|
||||
c.Created++
|
||||
case changed:
|
||||
c.Updated++
|
||||
default:
|
||||
c.Unchanged++
|
||||
}
|
||||
}
|
||||
|
||||
// SeedSummary is the outcome of a seed, per kind.
|
||||
type SeedSummary struct {
|
||||
Meetings SeedCounts
|
||||
Courses SeedCounts
|
||||
ClassSessions SeedCounts
|
||||
Deadlines SeedCounts
|
||||
Readings SeedCounts
|
||||
}
|
||||
|
||||
// Rows returns the summary as table rows: kind, created, updated, unchanged.
|
||||
func (s SeedSummary) Rows() [][]string {
|
||||
row := func(name string, c SeedCounts) []string {
|
||||
return []string{name, fmt.Sprint(c.Created), fmt.Sprint(c.Updated), fmt.Sprint(c.Unchanged)}
|
||||
}
|
||||
return [][]string{
|
||||
row("meetings", s.Meetings),
|
||||
row("courses", s.Courses),
|
||||
row("class_sessions", s.ClassSessions),
|
||||
row("deadlines", s.Deadlines),
|
||||
row("readings", s.Readings),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseSeed decodes a seed document. Unknown fields are an error, so a typo
|
||||
// in a key never drops data silently.
|
||||
func ParseSeed(r io.Reader) (*SeedFile, error) {
|
||||
body, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(body))
|
||||
dec.DisallowUnknownFields()
|
||||
var f SeedFile
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return nil, fmt.Errorf("bm:seed: %w", err)
|
||||
}
|
||||
if dec.More() {
|
||||
return nil, errors.New("bm:seed: trailing data after the JSON document")
|
||||
}
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// Validate checks the whole document before anything is written: required
|
||||
// fields, allowed values, dates and times, duplicate identities and that
|
||||
// every course key and meeting number a record points at exists in the
|
||||
// file. Each error names the record.
|
||||
func (f *SeedFile) Validate() error {
|
||||
var errs []error
|
||||
add := func(format string, args ...any) { errs = append(errs, fmt.Errorf(format, args...)) }
|
||||
|
||||
meetings := map[int]bool{}
|
||||
for i, m := range f.Meetings {
|
||||
at := fmt.Sprintf("meetings[%d] (number %d)", i, m.Number)
|
||||
if m.Number < 1 {
|
||||
add("%s: number must be a positive integer", at)
|
||||
}
|
||||
if meetings[m.Number] {
|
||||
add("%s: duplicate meeting number", at)
|
||||
}
|
||||
meetings[m.Number] = true
|
||||
start, err1 := models.ParseDate(m.StartsOn)
|
||||
end, err2 := models.ParseDate(m.EndsOn)
|
||||
if err1 != nil {
|
||||
add("%s: starts_on: %v", at, err1)
|
||||
}
|
||||
if err2 != nil {
|
||||
add("%s: ends_on: %v", at, err2)
|
||||
}
|
||||
if err1 == nil && err2 == nil && end.Before(start) {
|
||||
add("%s: ends_on is before starts_on", at)
|
||||
}
|
||||
}
|
||||
|
||||
keys := map[string]bool{}
|
||||
identities := map[string]bool{}
|
||||
for i, c := range f.Courses {
|
||||
at := fmt.Sprintf("courses[%d] (key %q)", i, c.Key)
|
||||
if strings.TrimSpace(c.Key) == "" {
|
||||
add("%s: key is required", at)
|
||||
} else if keys[c.Key] {
|
||||
add("%s: duplicate course key", at)
|
||||
}
|
||||
keys[c.Key] = true
|
||||
if strings.TrimSpace(c.Name) == "" {
|
||||
add("%s: name is required", at)
|
||||
}
|
||||
if !slices.Contains(models.CourseForms, c.Form) {
|
||||
add("%s: form %q is not one of %s", at, c.Form, strings.Join(models.CourseForms, ", "))
|
||||
}
|
||||
if c.Semester < 1 || c.Semester > 4 {
|
||||
add("%s: semester must be between 1 and 4", at)
|
||||
}
|
||||
id := fmt.Sprintf("%s\x00%s\x00%d", c.Name, c.Form, c.Semester)
|
||||
if identities[id] {
|
||||
add("%s: another course has the same name, form and semester", at)
|
||||
}
|
||||
identities[id] = true
|
||||
}
|
||||
|
||||
sessions := map[string]bool{}
|
||||
for i, s := range f.ClassSessions {
|
||||
at := fmt.Sprintf("class_sessions[%d] (course %q, starts_at %s)", i, s.Course, s.StartsAt)
|
||||
if !keys[s.Course] {
|
||||
add("%s: unknown course key %q", at, s.Course)
|
||||
}
|
||||
if s.Meeting != nil && !meetings[*s.Meeting] {
|
||||
add("%s: unknown meeting number %d", at, *s.Meeting)
|
||||
}
|
||||
start, err1 := models.ParseDateTime(s.StartsAt)
|
||||
end, err2 := models.ParseDateTime(s.EndsAt)
|
||||
if err1 != nil {
|
||||
add("%s: starts_at: %v", at, err1)
|
||||
}
|
||||
if err2 != nil {
|
||||
add("%s: ends_at: %v", at, err2)
|
||||
}
|
||||
if err1 == nil && err2 == nil {
|
||||
if !end.After(start) {
|
||||
add("%s: ends_at is not after starts_at", at)
|
||||
}
|
||||
id := s.Course + "\x00" + start.UTC().String()
|
||||
if sessions[id] {
|
||||
add("%s: duplicate session of the course at this time", at)
|
||||
}
|
||||
sessions[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
deadlines := map[string]bool{}
|
||||
for i, d := range f.Deadlines {
|
||||
at := fmt.Sprintf("deadlines[%d] (%q)", i, d.Title)
|
||||
if strings.TrimSpace(d.Title) == "" {
|
||||
add("%s: title is required", at)
|
||||
}
|
||||
if d.Course != nil && !keys[*d.Course] {
|
||||
add("%s: unknown course key %q", at, *d.Course)
|
||||
}
|
||||
if !slices.Contains(models.DeadlineKinds, d.Kind) {
|
||||
add("%s: kind %q is not one of %s", at, d.Kind, strings.Join(models.DeadlineKinds, ", "))
|
||||
}
|
||||
due, err := models.ParseDateTime(d.DueAt)
|
||||
if err != nil {
|
||||
add("%s: due_at: %v", at, err)
|
||||
continue
|
||||
}
|
||||
id := d.Title + "\x00" + due.UTC().String()
|
||||
if deadlines[id] {
|
||||
add("%s: duplicate deadline (same title and due_at)", at)
|
||||
}
|
||||
deadlines[id] = true
|
||||
}
|
||||
|
||||
readings := map[string]bool{}
|
||||
for i, r := range f.Readings {
|
||||
at := fmt.Sprintf("readings[%d] (course %q, %q)", i, r.Course, r.Title)
|
||||
if !keys[r.Course] {
|
||||
add("%s: unknown course key %q", at, r.Course)
|
||||
}
|
||||
if strings.TrimSpace(r.Title) == "" {
|
||||
add("%s: title is required", at)
|
||||
}
|
||||
id := r.Course + "\x00" + r.Title
|
||||
if readings[id] {
|
||||
add("%s: duplicate reading of the course", at)
|
||||
}
|
||||
readings[id] = true
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Seed validates f and upserts it in one transaction: meetings by number,
|
||||
// courses by name, form and semester, class sessions by course and start
|
||||
// time, deadlines by title and due time, readings by course and title. Rows
|
||||
// absent from the file are left alone. On any error nothing is written.
|
||||
func Seed(ctx context.Context, db *gorm.DB, f *SeedFile) (SeedSummary, error) {
|
||||
var sum SeedSummary
|
||||
if err := f.Validate(); err != nil {
|
||||
return sum, err
|
||||
}
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
meetingIDs := map[int]uint{}
|
||||
for _, in := range f.Meetings {
|
||||
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.Notes = nz(in.Notes)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("meeting %d: %w", in.Number, err)
|
||||
}
|
||||
sum.Meetings.record(created, changed)
|
||||
meetingIDs[in.Number] = m.ID
|
||||
}
|
||||
|
||||
courseIDs := map[string]uint{}
|
||||
for _, in := range f.Courses {
|
||||
c := models.Course{}
|
||||
where := clause.And(
|
||||
clause.Eq{Column: "name", Value: in.Name},
|
||||
clause.Eq{Column: "form", Value: in.Form},
|
||||
clause.Eq{Column: "semester", Value: in.Semester},
|
||||
)
|
||||
created, changed, err := upsert(tx, &c, where, func(c *models.Course) {
|
||||
c.Code = nz(in.Code)
|
||||
c.Name = in.Name
|
||||
c.Form = in.Form
|
||||
c.Lecturer = nz(in.Lecturer)
|
||||
c.Semester = in.Semester
|
||||
c.ECTS = in.ECTS
|
||||
c.Assessment = nz(in.Assessment)
|
||||
c.ElectiveGroup = nz(in.ElectiveGroup)
|
||||
c.Description = nz(in.Description)
|
||||
c.SyllabusURL = nz(in.SyllabusURL)
|
||||
c.SortOrder = in.SortOrder
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("course %q: %w", in.Key, err)
|
||||
}
|
||||
sum.Courses.record(created, changed)
|
||||
courseIDs[in.Key] = c.ID
|
||||
}
|
||||
|
||||
for _, in := range f.ClassSessions {
|
||||
start, _ := models.ParseDateTime(in.StartsAt)
|
||||
s := models.ClassSession{}
|
||||
where := clause.And(
|
||||
clause.Eq{Column: "course_id", Value: courseIDs[in.Course]},
|
||||
clause.Eq{Column: "starts_at", Value: start.UTC()},
|
||||
)
|
||||
created, changed, err := upsert(tx, &s, where, func(s *models.ClassSession) {
|
||||
s.CourseID = courseIDs[in.Course]
|
||||
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)
|
||||
s.Room = nz(in.Room)
|
||||
s.Kind = nz(in.Kind)
|
||||
s.IsOnline = in.IsOnline
|
||||
s.Notes = nz(in.Notes)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("class session %s %s: %w", in.Course, in.StartsAt, err)
|
||||
}
|
||||
sum.ClassSessions.record(created, changed)
|
||||
}
|
||||
|
||||
for _, in := range f.Deadlines {
|
||||
due, _ := models.ParseDateTime(in.DueAt)
|
||||
d := models.Deadline{}
|
||||
where := clause.And(
|
||||
clause.Eq{Column: "title", Value: in.Title},
|
||||
clause.Eq{Column: "due_at", Value: due.UTC()},
|
||||
)
|
||||
created, changed, err := upsert(tx, &d, where, func(d *models.Deadline) {
|
||||
d.Title = in.Title
|
||||
d.DueAt = models.NewDateTime(due)
|
||||
d.CourseID = nil
|
||||
if in.Course != nil {
|
||||
id := courseIDs[*in.Course]
|
||||
d.CourseID = &id
|
||||
}
|
||||
d.Kind = in.Kind
|
||||
d.Description = nz(in.Description)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("deadline %q: %w", in.Title, err)
|
||||
}
|
||||
sum.Deadlines.record(created, changed)
|
||||
}
|
||||
|
||||
for _, in := range f.Readings {
|
||||
r := models.Reading{}
|
||||
where := clause.And(
|
||||
clause.Eq{Column: "course_id", Value: courseIDs[in.Course]},
|
||||
clause.Eq{Column: "title", Value: in.Title},
|
||||
)
|
||||
created, changed, err := upsert(tx, &r, where, func(r *models.Reading) {
|
||||
r.CourseID = courseIDs[in.Course]
|
||||
r.Title = in.Title
|
||||
r.Author = nz(in.Author)
|
||||
r.Source = nz(in.Source)
|
||||
r.URL = nz(in.URL)
|
||||
r.IsRequired = in.IsRequired
|
||||
r.SortOrder = in.SortOrder
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading %q of %s: %w", in.Title, in.Course, err)
|
||||
}
|
||||
sum.Readings.record(created, changed)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return SeedSummary{}, err
|
||||
}
|
||||
return sum, nil
|
||||
}
|
||||
|
||||
// nz returns nil for a missing or blank optional text, as the models'
|
||||
// BeforeSave hooks store it, so an unchanged row compares equal.
|
||||
func nz(s *string) *string {
|
||||
if s == nil || strings.TrimSpace(*s) == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// upsert loads the row matching where into row, applies set and writes it:
|
||||
// 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.
|
||||
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 {
|
||||
return false, false, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
set(row)
|
||||
return true, false, tx.Create(row).Error
|
||||
}
|
||||
before, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
set(row)
|
||||
after, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
if bytes.Equal(before, after) {
|
||||
return false, false, nil
|
||||
}
|
||||
return false, true, tx.Save(row).Error
|
||||
}
|
||||
117
classes/seed_test.go
Normal file
117
classes/seed_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package classes_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
)
|
||||
|
||||
func parse(t *testing.T, doc string) *classes.SeedFile {
|
||||
t.Helper()
|
||||
f, err := classes.ParseSeed(strings.NewReader(doc))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseSeed: %v", err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestExampleFixtureIsValid(t *testing.T) {
|
||||
fh, err := os.Open("../testdata/seed.example.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer fh.Close()
|
||||
f, err := classes.ParseSeed(fh)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := f.Validate(); err != nil {
|
||||
t.Fatalf("Validate: %v", err)
|
||||
}
|
||||
if len(f.Meetings) != 2 || len(f.Courses) != 3 || len(f.ClassSessions) != 3 || len(f.Deadlines) != 2 || len(f.Readings) != 2 {
|
||||
t.Errorf("fixture counts = %d %d %d %d %d", len(f.Meetings), len(f.Courses), len(f.ClassSessions), len(f.Deadlines), len(f.Readings))
|
||||
}
|
||||
if f.Courses[2].ElectiveGroup == nil || *f.Courses[2].ElectiveGroup != "przyklad-do-wyboru" {
|
||||
t.Errorf("elective_group = %v", f.Courses[2].ElectiveGroup)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyDocumentIsValid(t *testing.T) {
|
||||
if err := parse(t, `{}`).Validate(); err != nil {
|
||||
t.Errorf("{}: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRejectsUnknownFieldsAndTrailingData(t *testing.T) {
|
||||
for _, doc := range []string{
|
||||
`{"courses": [{"key": "a", "nmae": "typo"}]}`,
|
||||
`{"lectures": []}`,
|
||||
`{} {}`,
|
||||
`[]`,
|
||||
`{"meetings": [{"number": "one"}]}`,
|
||||
} {
|
||||
if _, err := classes.ParseSeed(strings.NewReader(doc)); err == nil {
|
||||
t.Errorf("ParseSeed(%s) accepted", doc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNamesTheRecord(t *testing.T) {
|
||||
cases := []struct {
|
||||
doc string
|
||||
want []string
|
||||
}{
|
||||
{`{"class_sessions": [{"course": "ghost", "starts_at": "2030-10-05T08:00:00+02:00", "ends_at": "2030-10-05T09:00:00+02:00"}]}`,
|
||||
[]string{`class_sessions[0]`, `unknown course key "ghost"`}},
|
||||
{`{"courses": [{"key": "a", "name": "A", "form": "wyklad", "semester": 1}],
|
||||
"class_sessions": [{"course": "a", "meeting": 9, "starts_at": "2030-10-05T08:00:00+02:00", "ends_at": "2030-10-05T09:00:00+02:00"}]}`,
|
||||
[]string{`unknown meeting number 9`}},
|
||||
{`{"deadlines": [{"title": "X", "due_at": "2030-10-05T08:00:00+02:00", "course": "nope", "kind": "inne"}]}`,
|
||||
[]string{`deadlines[0] ("X")`, `unknown course key "nope"`}},
|
||||
{`{"readings": [{"course": "nope", "title": "Book"}]}`,
|
||||
[]string{`readings[0]`, `unknown course key "nope"`}},
|
||||
{`{"courses": [{"key": "a", "name": "A", "form": "lecture", "semester": 7}]}`,
|
||||
[]string{`form "lecture"`, `semester must be between 1 and 4`}},
|
||||
{`{"courses": [{"key": "a", "name": "A", "form": "wyklad", "semester": 1}, {"key": "a", "name": "A", "form": "wyklad", "semester": 1}]}`,
|
||||
[]string{`duplicate course key`, `same name, form and semester`}},
|
||||
{`{"meetings": [{"number": 1, "starts_on": "2030-10-06", "ends_on": "2030-10-05"}, {"number": 1, "starts_on": "05.10.2030", "ends_on": "2030-10-05"}]}`,
|
||||
[]string{`ends_on is before starts_on`, `duplicate meeting number`, `starts_on`}},
|
||||
{`{"courses": [{"key": "a", "name": "A", "form": "wyklad", "semester": 1}],
|
||||
"class_sessions": [{"course": "a", "starts_at": "2030-10-05 10:00", "ends_at": "2030-10-05 09:00"}]}`,
|
||||
[]string{`ends_at is not after starts_at`}},
|
||||
{`{"deadlines": [{"title": "", "due_at": "soon", "kind": "deadline"}]}`,
|
||||
[]string{`title is required`, `due_at`, `kind "deadline"`}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
err := parse(t, tc.doc).Validate()
|
||||
if err == nil {
|
||||
t.Errorf("Validate(%s) passed", tc.doc)
|
||||
continue
|
||||
}
|
||||
for _, want := range tc.want {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("Validate(%s) = %q, want it to mention %q", tc.doc, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedValidatesBeforeTouchingTheDatabase(t *testing.T) {
|
||||
f := parse(t, `{"readings": [{"course": "ghost", "title": "Book"}]}`)
|
||||
// A nil database would panic if Seed reached it.
|
||||
if _, err := classes.Seed(t.Context(), nil, f); err == nil || !strings.Contains(err.Error(), "ghost") {
|
||||
t.Errorf("Seed = %v, want the unknown course error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryRows(t *testing.T) {
|
||||
var s classes.SeedSummary
|
||||
s.Courses.Created = 2
|
||||
s.Readings.Unchanged = 1
|
||||
rows := s.Rows()
|
||||
if len(rows) != 5 || rows[1][0] != "courses" || rows[1][1] != "2" || rows[4][3] != "1" {
|
||||
t.Errorf("Rows = %v", rows)
|
||||
}
|
||||
}
|
||||
58
console/seed.go
Normal file
58
console/seed.go
Normal file
@@ -0,0 +1,58 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/classes"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SeedCommand returns the bm:seed console command. withDB runs the
|
||||
// command's work with the application's database; the plugin supplies it.
|
||||
func SeedCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "bm:seed",
|
||||
Description: "Upsert meetings, courses, class sessions, deadlines and readings from a JSON file",
|
||||
Args: []bonfire.Arg{{Name: "file", Description: "Path of the seed JSON file", Required: true}},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "dry-run", Description: "Validate the file without writing", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
path, _ := in.Argument("file")
|
||||
if path == "" {
|
||||
return fmt.Errorf("bm:seed: a file is required")
|
||||
}
|
||||
fh, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bm:seed: %w", err)
|
||||
}
|
||||
defer fh.Close()
|
||||
file, err := classes.ParseSeed(fh)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := file.Validate(); err != nil {
|
||||
return fmt.Errorf("bm:seed: %s is not valid, nothing was written:\n%w", path, err)
|
||||
}
|
||||
if dry, _ := in.Flag("dry-run"); dry == "true" {
|
||||
out.Success(fmt.Sprintf("%s is valid: %d meetings, %d courses, %d class sessions, %d deadlines, %d readings", path,
|
||||
len(file.Meetings), len(file.Courses), len(file.ClassSessions), len(file.Deadlines), len(file.Readings)))
|
||||
return nil
|
||||
}
|
||||
return withDB(ctx, func(db *gorm.DB) error {
|
||||
sum, err := classes.Seed(ctx, db, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bm:seed: nothing was written: %w", err)
|
||||
}
|
||||
out.Table([]string{"kind", "created", "updated", "unchanged"}, sum.Rows())
|
||||
out.Success("bm:seed: " + path + " applied")
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
69
console/seed_test.go
Normal file
69
console/seed_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package console_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/console"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestSeedCommandShape(t *testing.T) {
|
||||
cmd := console.SeedCommand(nil)
|
||||
if cmd.Name != "bm:seed" || cmd.Description == "" {
|
||||
t.Errorf("command = %q %q", cmd.Name, cmd.Description)
|
||||
}
|
||||
if len(cmd.Args) != 1 || cmd.Args[0].Name != "file" || !cmd.Args[0].Required {
|
||||
t.Errorf("Args = %+v, want one required file argument", cmd.Args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedCommandChecksTheFileBeforeTheDatabase(t *testing.T) {
|
||||
called := false
|
||||
withDB := func(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||
called = true
|
||||
return errors.New("no database in this test")
|
||||
}
|
||||
cmds := []bonfire.Command{console.SeedCommand(withDB)}
|
||||
dir := t.TempDir()
|
||||
invalid := filepath.Join(dir, "invalid.json")
|
||||
if err := os.WriteFile(invalid, []byte(`{"readings": [{"course": "ghost", "title": "X"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
broken := filepath.Join(dir, "broken.json")
|
||||
if err := os.WriteFile(broken, []byte(`{"courses": [`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
for _, args := range [][]string{nil, {filepath.Join(dir, "missing.json")}, {broken}, {invalid}} {
|
||||
if err := bonfire.Call(context.Background(), cmds, "bm:seed", args, &out); err == nil {
|
||||
t.Errorf("bm:seed %v succeeded", args)
|
||||
}
|
||||
}
|
||||
err := bonfire.Call(context.Background(), cmds, "bm:seed", []string{invalid}, &out)
|
||||
if err == nil || !strings.Contains(err.Error(), "nothing was written") || !strings.Contains(err.Error(), "ghost") {
|
||||
t.Errorf("invalid file error = %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Error("bm:seed reached the database with an invalid file")
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := bonfire.Call(context.Background(), cmds, "bm:seed", []string{"../testdata/seed.example.json", "--dry-run"}, &out); err != nil {
|
||||
t.Fatalf("dry run: %v", err)
|
||||
}
|
||||
if called || !strings.Contains(out.String(), "3 courses") {
|
||||
t.Errorf("dry run called=%v output %q", called, out.String())
|
||||
}
|
||||
|
||||
err = bonfire.Call(context.Background(), cmds, "bm:seed", []string{"../testdata/seed.example.json"}, &out)
|
||||
if !called || err == nil {
|
||||
t.Errorf("valid file: called=%v err=%v, want the withDB error", called, err)
|
||||
}
|
||||
}
|
||||
26
plugin.go
26
plugin.go
@@ -1,15 +1,19 @@
|
||||
package bm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/console"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/controllers"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -76,8 +80,11 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
|
||||
return generatedAdminControllers()
|
||||
}
|
||||
|
||||
// Commands returns the generated commands.
|
||||
func (p *Plugin) Commands() []bonfire.Command { return generatedCommands() }
|
||||
// Commands returns the generated commands plus bm:seed, which needs the
|
||||
// database and so is built here with the plugin's withDB.
|
||||
func (p *Plugin) Commands() []bonfire.Command {
|
||||
return append(generatedCommands(), console.SeedCommand(p.withDB))
|
||||
}
|
||||
|
||||
// AdminFS is the admin YAML the controllers read.
|
||||
func (p *Plugin) AdminFS() fs.FS { return adminFS }
|
||||
@@ -114,6 +121,21 @@ func (p *Plugin) Navigation() []pact.NavigationItem {
|
||||
}}
|
||||
}
|
||||
|
||||
// withDB runs fn with the application's database: the one the serve command
|
||||
// published, or, when a console command runs, one opened from the config for
|
||||
// the duration of fn.
|
||||
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
|
||||
return fn(gdb)
|
||||
}
|
||||
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
return fn(gdb)
|
||||
}
|
||||
|
||||
func init() {
|
||||
party.Register(&Plugin{})
|
||||
}
|
||||
|
||||
171
plugin_test.go
Normal file
171
plugin_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package bm_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
_ "git.golem15.com/golem15/sm-user-plugin"
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
bm "git.golem15.com/jakub/sm-bm-plugin"
|
||||
)
|
||||
|
||||
const (
|
||||
testUserSecret = "jz-bm-test-only-user-jwt-secret-32bytes"
|
||||
testAdminSecret = "jz-bm-test-only-admin-jwt-secret-32bytes"
|
||||
)
|
||||
|
||||
// testConfig is the application config the tests boot with: the HTTP
|
||||
// limits, the golem15.user JWT secret and the admin secret.
|
||||
func testConfig(t *testing.T) *compass.Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
httpYAML := "body_limits:\n default_bytes: 1048576\n upload_bytes: 10485760\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "http.yaml"), []byte(httpYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := compass.Open(compass.Options{
|
||||
Dir: dir,
|
||||
Environ: []string{
|
||||
"SUMMER_ENV=testing",
|
||||
"SUMMER_GOLEM15__USER__JWT__SECRET=" + testUserSecret,
|
||||
"SUMMER_ADMIN__JWT__SECRET=" + testAdminSecret,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// activate boots golem15.user and jz.bm the way the generated main does.
|
||||
func activate(t *testing.T, app *backpack.App) []party.Plugin {
|
||||
t.Helper()
|
||||
plugins, err := party.Activate(app, []string{"golem15.user", "jz.bm"})
|
||||
if err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
return plugins
|
||||
}
|
||||
|
||||
func bmPlugin(t *testing.T, plugins []party.Plugin) *bm.Plugin {
|
||||
t.Helper()
|
||||
for _, p := range plugins {
|
||||
if b, ok := p.(*bm.Plugin); ok {
|
||||
return b
|
||||
}
|
||||
}
|
||||
t.Fatal("jz.bm was not activated")
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestPluginActivates(t *testing.T) {
|
||||
app := backpack.New(testConfig(t))
|
||||
p := bmPlugin(t, activate(t, app))
|
||||
if p.ID() != "jz.bm" || !slices.Equal(p.Requires(), []string{"golem15.user"}) {
|
||||
t.Errorf("ID %q Requires %v", p.ID(), p.Requires())
|
||||
}
|
||||
if n := len(p.Models()); n != 9 {
|
||||
t.Errorf("Models = %d, want 9", n)
|
||||
}
|
||||
if n := len(p.Migrations()); n != 10 {
|
||||
t.Errorf("Migrations = %d, want 10", n)
|
||||
}
|
||||
if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) {
|
||||
t.Error("bm:seed is not registered")
|
||||
}
|
||||
if got := app.Config.Int("jz.bm.materials_per_page"); got != 20 {
|
||||
t.Errorf("jz.bm.materials_per_page = %d, want the default 20", got)
|
||||
}
|
||||
if got := app.Config.Int("jz.bm.dashboard_limit"); got != 5 {
|
||||
t.Errorf("jz.bm.dashboard_limit = %d, want the default 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutes(t *testing.T) {
|
||||
app := backpack.New(testConfig(t))
|
||||
plugins := activate(t, app)
|
||||
router, err := surf.BuildRouter(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildRouter: %v", err)
|
||||
}
|
||||
want := []string{
|
||||
"GET /_bm/api/v1/dashboard",
|
||||
"GET /_bm/api/v1/courses",
|
||||
"GET /_bm/api/v1/courses/{id}",
|
||||
"GET /_bm/api/v1/schedule",
|
||||
"GET /_bm/api/v1/meetings",
|
||||
"GET /_bm/api/v1/deadlines",
|
||||
"GET /_bm/api/v1/readings",
|
||||
"PUT /_bm/api/v1/readings/{id}/progress",
|
||||
"DELETE /_bm/api/v1/readings/{id}/progress",
|
||||
"GET /_bm/api/v1/materials",
|
||||
"GET /_bm/api/v1/materials/{id}",
|
||||
}
|
||||
var got []string
|
||||
for _, rt := range router.Routes() {
|
||||
if rt.PluginID != "jz.bm" {
|
||||
continue
|
||||
}
|
||||
got = append(got, rt.Method+" "+rt.Pattern)
|
||||
if !slices.Contains(rt.Middleware, "jwt.auth") {
|
||||
t.Errorf("%s %s is not behind jwt.auth: %v", rt.Method, rt.Pattern, rt.Middleware)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Errorf("routes =\n%v\nwant\n%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminCompiles(t *testing.T) {
|
||||
app := backpack.New(testConfig(t))
|
||||
plugins := activate(t, app)
|
||||
p := bmPlugin(t, plugins)
|
||||
routes, err := cabana.Activate(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("cabana.Activate: %v", err)
|
||||
}
|
||||
if routes == nil {
|
||||
t.Fatal("no admin routes")
|
||||
}
|
||||
declared := map[string]bool{}
|
||||
for _, perm := range p.Permissions() {
|
||||
declared[perm.Code] = true
|
||||
}
|
||||
for _, ctl := range p.AdminControllers() {
|
||||
perms := ctl.(pact.AdminPermissioned).RequiredPermissions()
|
||||
for _, code := range perms {
|
||||
if !declared[code] {
|
||||
t.Errorf("%s requires undeclared %s", ctl.ID(), code)
|
||||
}
|
||||
}
|
||||
if rec := ctl.(pact.AdminRecordSource).NewRecord(); rec == nil {
|
||||
t.Errorf("%s has no record model", ctl.ID())
|
||||
}
|
||||
form, err := cabana.CompileForm("jz.bm", ctl, p.AdminFS())
|
||||
if err != nil {
|
||||
t.Errorf("%s form: %v", ctl.ID(), err)
|
||||
} else if len(form.Fields) == 0 {
|
||||
t.Errorf("%s form has no fields", ctl.ID())
|
||||
}
|
||||
if _, err := cabana.CompileList("jz.bm", ctl, p.AdminFS()); err != nil {
|
||||
t.Errorf("%s list: %v", ctl.ID(), err)
|
||||
}
|
||||
editor := &bouncer.Principal{ID: 1, Backend: true, PermissionGrants: map[string]bool{"jz.bm.*": true}}
|
||||
if !cabana.Allows(editor, perms) {
|
||||
t.Errorf("jz.bm.* does not open %s", ctl.ID())
|
||||
}
|
||||
}
|
||||
nav := p.Navigation()
|
||||
if len(nav) != 1 || len(nav[0].SideMenu) != 7 {
|
||||
t.Errorf("navigation = %+v, want one group with 7 side items", nav)
|
||||
}
|
||||
}
|
||||
24
testdata/seed.example.json
vendored
Normal file
24
testdata/seed.example.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"meetings": [
|
||||
{"number": 1, "starts_on": "2030-10-05", "ends_on": "2030-10-06", "notes": "przykład"},
|
||||
{"number": 2, "starts_on": "2030-10-19", "ends_on": "2030-10-20"}
|
||||
],
|
||||
"courses": [
|
||||
{"key": "a-w", "code": null, "name": "Przedmiot przykładowy A", "form": "wyklad", "lecturer": "dr Jan Przykładowy", "semester": 1, "ects": 4, "assessment": "egzamin", "elective_group": null, "description": null, "syllabus_url": null, "sort_order": 1},
|
||||
{"key": "a-c", "name": "Przedmiot przykładowy A", "form": "cwiczenia", "semester": 1, "ects": 2.5, "assessment": "zaliczenie na ocenę", "sort_order": 2},
|
||||
{"key": "b-k", "name": "Przedmiot przykładowy B", "form": "konwersatorium", "semester": 1, "elective_group": "przyklad-do-wyboru", "sort_order": 3}
|
||||
],
|
||||
"class_sessions": [
|
||||
{"course": "a-w", "meeting": 1, "starts_at": "2030-10-05T08:00:00+02:00", "ends_at": "2030-10-05T09:30:00+02:00", "room": "101", "kind": null, "is_online": false, "notes": "dr Jan Przykładowy"},
|
||||
{"course": "a-c", "meeting": 1, "starts_at": "2030-10-05T09:45:00+02:00", "ends_at": "2030-10-05T11:15:00+02:00", "room": "102"},
|
||||
{"course": "b-k", "meeting": 2, "starts_at": "2030-10-19T08:00:00+02:00", "ends_at": "2030-10-19T09:30:00+02:00", "is_online": true}
|
||||
],
|
||||
"deadlines": [
|
||||
{"title": "Termin przykładowy", "due_at": "2030-10-07T23:59:00+02:00", "course": null, "kind": "organizacyjne", "description": null},
|
||||
{"title": "Esej przykładowy", "due_at": "2030-11-30T23:59:00+01:00", "course": "a-c", "kind": "praca"}
|
||||
],
|
||||
"readings": [
|
||||
{"course": "a-w", "title": "Lektura przykładowa 1", "author": "A. Autor", "source": null, "url": null, "is_required": true, "sort_order": 1},
|
||||
{"course": "a-w", "title": "Lektura przykładowa 2", "is_required": false, "sort_order": 2}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user