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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user