diff --git a/config/config.yaml b/config/config.yaml index 0967ef4..a0c3d51 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -1 +1,7 @@ -{} +# Defaults for jz.bm, merged under the plugin ID: the application reads them +# as jz.bm. and can override them in its own config. + +# Default page size of GET /_bm/api/v1/materials (clamped to 1..100). +materials_per_page: 20 +# Number of class sessions and deadlines on GET /_bm/api/v1/dashboard. +dashboard_limit: 5 diff --git a/controllers/api/api.go b/controllers/api/api.go new file mode 100644 index 0000000..2f0ef8e --- /dev/null +++ b/controllers/api/api.go @@ -0,0 +1,122 @@ +// Package api holds the student-facing JSON handlers of the jz.bm plugin, +// served under /_bm/api/v1 behind the golem15.user jwt.auth guard. +// +// Every response is built field by field and wrapped in {"data": ...}; a +// paginated list adds {"meta": ...}. Timestamps are RFC 3339 in +// Europe/Warsaw, dates are YYYY-MM-DD. +package api + +import ( + "net/http" + "sort" + "time" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) + +// Handlers serves the API. The plugin builds it in Routes; tests build it +// with their own database and clock. +type Handlers struct { + // DB returns the application database, or nil while none is published. + // It is resolved per request: the binary boots plugins before serve + // publishes the database. + DB func() *gorm.DB + // Translator returns the application translator for validation + // messages; nil gives English. + Translator func() *phrasebook.Translator + // Now is the clock; nil means time.Now. + Now func() time.Time + // PerPage is the default page size of /materials. + PerPage int + // DashboardLimit is the number of sessions and deadlines on /dashboard. + DashboardLimit int +} + +func (h *Handlers) now() time.Time { + if h.Now != nil { + return h.Now() + } + return time.Now() +} + +func (h *Handlers) perPage() int { + if h.PerPage > 0 { + return min(h.PerPage, maxPerPage) + } + return defaultPerPage +} + +func (h *Handlers) dashboardLimit() int { + if h.DashboardLimit > 0 { + return h.DashboardLimit + } + return 5 +} + +func (h *Handlers) translator() *phrasebook.Translator { + if h.Translator == nil { + return nil + } + return h.Translator() +} + +const ( + defaultPerPage = 20 + maxPerPage = 100 +) + +// caller returns the signed-in user's ID. It answers 401 itself and returns +// false when there is none (the jwt.auth guard normally answers first). +func (h *Handlers) caller(w http.ResponseWriter, r *http.Request) (uint, bool) { + user, ok := bouncer.User(r.Context()) + if !ok || user == nil || user.ID == 0 { + wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthenticated."}) + return 0, false + } + return user.ID, true +} + +// db returns the database bound to the request context. It answers 503 +// itself and returns false while no database is published. +func (h *Handlers) db(w http.ResponseWriter, r *http.Request) (*gorm.DB, bool) { + var db *gorm.DB + if h.DB != nil { + db = h.DB() + } + if db == nil { + wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": true, "message": "Database unavailable."}) + return nil, false + } + return db.WithContext(r.Context()), true +} + +func writeData(w http.ResponseWriter, v any) { + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": v}) +} + +func writeNotFound(w http.ResponseWriter) { + wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Not found."}) +} + +// writeValidation answers 422 with Laravel's validation body: the first +// message and the errors object from lagoon.ValidateRequest. +func writeValidation(w http.ResponseWriter, errs map[string][]string) { + keys := make([]string, 0, len(errs)) + for k := range errs { + keys = append(keys, k) + } + sort.Strings(keys) + message := "The given data was invalid." + if len(keys) > 0 && len(errs[keys[0]]) > 0 { + message = errs[keys[0]][0] + } + wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": message, "errors": errs}) +} + +func writeOpaque500(w http.ResponseWriter) { wire.WriteOpaque500(w) } + +// writeJSONPage writes a lagoon.Page as it is: {data, meta}. +func writeJSONPage(w http.ResponseWriter, page any) { wire.WriteJSON(w, http.StatusOK, page) } diff --git a/controllers/api/courses.go b/controllers/api/courses.go new file mode 100644 index 0000000..c2b6b54 --- /dev/null +++ b/controllers/api/courses.go @@ -0,0 +1,118 @@ +package api + +import ( + "net/http" + "time" + + "git.golem15.com/golem15/summercms/modules/surf" + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" +) + +// courseRow is a course with its counters, as the counting query returns it. +type courseRow struct { + models.Course `gorm:"embedded"` + SessionsCount int64 `gorm:"column:sessions_count"` + ReadingsCount int64 `gorm:"column:readings_count"` + ReadingsRead int64 `gorm:"column:readings_read"` + MaterialsCount int64 `gorm:"column:materials_count"` +} + +func (c courseRow) counts() courseCounts { + return courseCounts{Sessions: c.SessionsCount, Readings: c.ReadingsCount, ReadingsRead: c.ReadingsRead, Materials: c.MaterialsCount} +} + +// coursesWithCounts selects courses with their session, reading and +// published material counts and the caller's read readings. +func coursesWithCounts(db *gorm.DB, userID uint, now time.Time) *gorm.DB { + return db.Table("jz_bm_courses AS c").Select(`c.*, +(SELECT COUNT(*) FROM jz_bm_class_sessions s WHERE s.course_id = c.id) AS sessions_count, +(SELECT COUNT(*) FROM jz_bm_readings r WHERE r.course_id = c.id) AS readings_count, +(SELECT COUNT(*) FROM jz_bm_reading_progress p JOIN jz_bm_readings r ON r.id = p.reading_id + WHERE r.course_id = c.id AND p.user_id = ?) AS readings_read, +(SELECT COUNT(*) FROM jz_bm_materials m WHERE m.course_id = c.id + AND m.published_at IS NOT NULL AND m.published_at <= ?) AS materials_count`, userID, now.UTC()) +} + +// Courses answers GET /courses?semester=N: every course in programme order +// with its counters. +func (h *Handlers) Courses(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + in := query(r, "semester") + if !h.validate(w, r, in, coursesRules) { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + q := coursesWithCounts(db, userID, h.now()) + if semester := intParam(in, "semester", 0); semester != 0 { + q = q.Where("c.semester = ?", semester) + } + var rows []courseRow + if err := q.Order("c.semester, c.sort_order, c.name, c.id").Scan(&rows).Error; err != nil { + writeOpaque500(w) + return + } + out := make([]courseListJSON, 0, len(rows)) + for _, row := range rows { + out = append(out, courseListJSON{courseJSON: newCourseJSON(row.Course), Counts: row.counts()}) + } + writeData(w, out) +} + +// Course answers GET /courses/{id}: the course with its counters, all its +// class sessions, its readings with the caller's read state and its +// published materials. +func (h *Handlers) Course(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + id, ok := surf.IntParam(r, "id") + if !ok { + writeNotFound(w) + return + } + now := h.now() + var rows []courseRow + if err := coursesWithCounts(db, userID, now).Where("c.id = ?", id).Limit(1).Scan(&rows).Error; err != nil { + writeOpaque500(w) + return + } + if len(rows) == 0 { + writeNotFound(w) + return + } + row := rows[0] + var sessions []models.ClassSession + if err := preloadSession(db).Where("course_id = ?", row.ID).Order("starts_at, id").Find(&sessions).Error; err != nil { + writeOpaque500(w) + return + } + readings, err := listReadings(db, userID, row.ID) + if err != nil { + writeOpaque500(w) + return + } + materials, err := publishedMaterialsOfCourse(db, now, row.ID) + if err != nil { + writeOpaque500(w) + return + } + writeData(w, courseDetailJSON{ + courseJSON: newCourseJSON(row.Course), + Counts: row.counts(), + Sessions: newSessionsJSON(sessions), + Readings: readings, + Materials: materials, + }) +} diff --git a/controllers/api/dashboard.go b/controllers/api/dashboard.go new file mode 100644 index 0000000..4a08f51 --- /dev/null +++ b/controllers/api/dashboard.go @@ -0,0 +1,88 @@ +package api + +import ( + "net/http" + + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" +) + +// dashboardJSON is the answer of /dashboard. +type dashboardJSON struct { + NextSessions []sessionJSON `json:"next_sessions"` + UpcomingDeadlines []deadlineJSON `json:"upcoming_deadlines"` + CurrentMeeting *meetingJSON `json:"current_meeting"` + NextMeeting *meetingJSON `json:"next_meeting"` + Readings readingsSummary `json:"readings"` +} + +// Dashboard answers GET /dashboard: the next class sessions (those not yet +// over), the deadlines not yet due, the meeting under way today and the next +// one, and the caller's reading progress. +func (h *Handlers) Dashboard(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + now := h.now().UTC() + limit := h.dashboardLimit() + out := dashboardJSON{} + + var sessions []models.ClassSession + if err := preloadSession(db).Where("ends_at > ?", now).Order("starts_at, id").Limit(limit).Find(&sessions).Error; err != nil { + writeOpaque500(w) + return + } + out.NextSessions = newSessionsJSON(sessions) + + var deadlines []models.Deadline + if err := db.Preload("Course").Where("due_at >= ?", now).Order("due_at, id").Limit(limit).Find(&deadlines).Error; err != nil { + writeOpaque500(w) + return + } + out.UpcomingDeadlines = newDeadlinesJSON(deadlines) + + day := today(h.now()).Format(models.DateLayout) + var err error + if out.CurrentMeeting, err = meetingWhere(db, "starts_on <= ? AND ends_on >= ?", day, day); err != nil { + writeOpaque500(w) + return + } + if out.NextMeeting, err = meetingWhere(db, "starts_on > ?", day); err != nil { + writeOpaque500(w) + return + } + + err = db.Raw(`SELECT + COUNT(*) AS total, + COUNT(p.id) AS read, + COUNT(*) FILTER (WHERE r.is_required) AS required_total, + COUNT(p.id) FILTER (WHERE r.is_required) AS required_read +FROM jz_bm_readings r +LEFT JOIN jz_bm_reading_progress p ON p.reading_id = r.id AND p.user_id = ?`, userID).Scan(&out.Readings).Error + if err != nil { + writeOpaque500(w) + return + } + writeData(w, out) +} + +// meetingWhere returns the first meeting by start date that matches the +// condition, with its session count, or nil. +func meetingWhere(db *gorm.DB, cond string, args ...any) (*meetingJSON, error) { + var m models.Meeting + res := db.Where(cond, args...).Order("starts_on, number").Limit(1).Find(&m) + if res.Error != nil || res.RowsAffected == 0 { + return nil, res.Error + } + var sessions int64 + if err := db.Model(&models.ClassSession{}).Where("meeting_id = ?", m.ID).Count(&sessions).Error; err != nil { + return nil, err + } + out := newMeetingJSON(m, sessions) + return &out, nil +} diff --git a/controllers/api/handlers_test.go b/controllers/api/handlers_test.go new file mode 100644 index 0000000..4e69ed4 --- /dev/null +++ b/controllers/api/handlers_test.go @@ -0,0 +1,144 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/jakub/sm-bm-plugin/controllers/api" + "gorm.io/gorm" +) + +// noDB is a Handlers whose database is never published, so a request that +// gets past authentication and validation answers 503. +func noDB() *api.Handlers { + return &api.Handlers{DB: func() *gorm.DB { return nil }} +} + +func serve(t *testing.T, h http.HandlerFunc, method, target string, user *bouncer.Principal, pathValues map[string]string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest(method, target, nil) + if user != nil { + req = req.WithContext(bouncer.WithUser(req.Context(), user)) + } + for k, v := range pathValues { + req.SetPathValue(k, v) + } + rec := httptest.NewRecorder() + h(rec, req) + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("%s %s: body is not JSON: %v\n%s", method, target, err, rec.Body.String()) + } + return rec.Code, body +} + +func handlers(h *api.Handlers) map[string]http.HandlerFunc { + return map[string]http.HandlerFunc{ + "dashboard": h.Dashboard, + "courses": h.Courses, + "course": h.Course, + "schedule": h.Schedule, + "meetings": h.Meetings, + "deadlines": h.Deadlines, + "readings": h.Readings, + "mark": h.MarkRead, + "unmark": h.UnmarkRead, + "materials": h.Materials, + "material": h.Material, + } +} + +func TestEveryHandlerNeedsACaller(t *testing.T) { + for name, fn := range handlers(noDB()) { + code, body := serve(t, fn, http.MethodGet, "/", nil, map[string]string{"id": "1"}) + if code != http.StatusUnauthorized || body["error"] != true { + t.Errorf("%s without a principal = %d %v, want 401", name, code, body) + } + } +} + +func TestEveryHandlerNeedsADatabase(t *testing.T) { + user := &bouncer.Principal{ID: 7} + for name, fn := range handlers(noDB()) { + code, body := serve(t, fn, http.MethodGet, "/", user, map[string]string{"id": "1"}) + if code != http.StatusServiceUnavailable { + t.Errorf("%s without a database = %d %v, want 503", name, code, body) + } + } + // A nil DB func is the same as an unpublished database. + code, _ := serve(t, (&api.Handlers{}).Meetings, http.MethodGet, "/", user, nil) + if code != http.StatusServiceUnavailable { + t.Errorf("nil DB func = %d, want 503", code) + } +} + +func TestQueryValidation(t *testing.T) { + h := noDB() + user := &bouncer.Principal{ID: 7} + cases := []struct { + name string + fn http.HandlerFunc + target string + field string + }{ + {"schedule from not a date", h.Schedule, "/?from=jutro", "from"}, + {"schedule from impossible date", h.Schedule, "/?from=2026-02-30", "from"}, + {"schedule from not ISO", h.Schedule, "/?from=03.10.2026", "from"}, + {"schedule to before from", h.Schedule, "/?from=2026-10-10&to=2026-10-01", "to"}, + {"deadlines to not a date", h.Deadlines, "/?to=10/01/2026", "to"}, + {"readings course not a number", h.Readings, "/?course=abc", "course"}, + {"readings course zero", h.Readings, "/?course=0", "course"}, + {"courses semester out of range", h.Courses, "/?semester=9", "semester"}, + {"materials type unknown", h.Materials, "/?type=video", "type"}, + {"materials page zero", h.Materials, "/?page=0", "page"}, + {"materials per_page too large", h.Materials, "/?per_page=1000", "per_page"}, + {"materials course negative", h.Materials, "/?course=-1", "course"}, + {"materials tag too long", h.Materials, "/?tag=" + longTag(), "tag"}, + } + for _, tc := range cases { + code, body := serve(t, tc.fn, http.MethodGet, tc.target, user, nil) + if code != http.StatusUnprocessableEntity { + t.Errorf("%s: status %d %v, want 422", tc.name, code, body) + continue + } + errs, _ := body["errors"].(map[string]any) + if msgs, _ := errs[tc.field].([]any); len(msgs) == 0 { + t.Errorf("%s: errors %v have no %s message", tc.name, errs, tc.field) + } + if msg, _ := body["message"].(string); msg == "" { + t.Errorf("%s: no message in %v", tc.name, body) + } + } +} + +func longTag() string { + b := make([]byte, 101) + for i := range b { + b[i] = 'a' + } + return string(b) +} + +func TestValidQueriesReachTheDatabase(t *testing.T) { + h := noDB() + user := &bouncer.Principal{ID: 7} + for _, tc := range []struct { + fn http.HandlerFunc + target string + }{ + {h.Schedule, "/?from=2026-10-01&to=2026-10-31"}, + {h.Schedule, "/?to=2026-10-31"}, + {h.Schedule, "/?from=2026-10-01&to=2026-10-01"}, + {h.Deadlines, "/"}, + {h.Readings, "/?course=3"}, + {h.Courses, "/?semester=2"}, + {h.Materials, "/?course=1&tag=geopolityka&type=pdf&page=2&per_page=10"}, + } { + if code, body := serve(t, tc.fn, http.MethodGet, tc.target, user, nil); code != http.StatusServiceUnavailable { + t.Errorf("%s = %d %v, want to pass validation (503 without a database)", tc.target, code, body) + } + } +} diff --git a/controllers/api/json.go b/controllers/api/json.go new file mode 100644 index 0000000..b683257 --- /dev/null +++ b/controllers/api/json.go @@ -0,0 +1,242 @@ +package api + +import "git.golem15.com/jakub/sm-bm-plugin/models" + +// courseRef is the short form of a course embedded in other records. +type courseRef struct { + ID uint `json:"id"` + Code *string `json:"code"` + Name string `json:"name"` + Form string `json:"form"` + Semester int `json:"semester"` +} + +func newCourseRef(c *models.Course) *courseRef { + if c == nil { + return nil + } + return &courseRef{ID: c.ID, Code: c.Code, Name: c.Name, Form: c.Form, Semester: c.Semester} +} + +// courseJSON is a course's own fields. +type courseJSON struct { + ID uint `json:"id"` + 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"` +} + +func newCourseJSON(c models.Course) courseJSON { + return courseJSON{ + ID: c.ID, + Code: c.Code, + Name: c.Name, + Form: c.Form, + Lecturer: c.Lecturer, + Semester: c.Semester, + ECTS: c.ECTS, + Assessment: c.Assessment, + ElectiveGroup: c.ElectiveGroup, + Description: c.Description, + SyllabusURL: c.SyllabusURL, + SortOrder: c.SortOrder, + } +} + +// courseCounts are the per-course counters of /courses and /courses/{id}. +type courseCounts struct { + Sessions int64 `json:"sessions"` + Readings int64 `json:"readings"` + ReadingsRead int64 `json:"readings_read"` + Materials int64 `json:"materials"` +} + +type courseListJSON struct { + courseJSON + Counts courseCounts `json:"counts"` +} + +type courseDetailJSON struct { + courseJSON + Counts courseCounts `json:"counts"` + Sessions []sessionJSON `json:"sessions"` + Readings []readingJSON `json:"readings"` + Materials []materialListJSON `json:"materials"` +} + +// meetingJSON is one zjazd. +type meetingJSON struct { + ID uint `json:"id"` + Number int `json:"number"` + StartsOn string `json:"starts_on"` + EndsOn string `json:"ends_on"` + Notes *string `json:"notes"` + SessionsCount int64 `json:"sessions_count"` +} + +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} +} + +// sessionJSON is one class session with its course. +type sessionJSON struct { + ID uint `json:"id"` + CourseID uint `json:"course_id"` + MeetingID *uint `json:"meeting_id"` + MeetingNumber *int `json:"meeting_number"` + StartsAt string `json:"starts_at"` + EndsAt string `json:"ends_at"` + Room *string `json:"room"` + Kind *string `json:"kind"` + Notes *string `json:"notes"` + IsOnline bool `json:"is_online"` + Course *courseRef `json:"course"` +} + +func newSessionJSON(s models.ClassSession) sessionJSON { + out := sessionJSON{ + ID: s.ID, + CourseID: s.CourseID, + MeetingID: s.MeetingID, + StartsAt: string(s.StartsAt), + EndsAt: string(s.EndsAt), + Room: s.Room, + Kind: s.Kind, + Notes: s.Notes, + IsOnline: s.IsOnline, + Course: newCourseRef(s.Course), + } + if s.Meeting != nil { + n := s.Meeting.Number + out.MeetingNumber = &n + } + return out +} + +func newSessionsJSON(rows []models.ClassSession) []sessionJSON { + out := make([]sessionJSON, 0, len(rows)) + for _, s := range rows { + out = append(out, newSessionJSON(s)) + } + return out +} + +// deadlineJSON is one terminarz entry. +type deadlineJSON struct { + ID uint `json:"id"` + Title string `json:"title"` + DueAt string `json:"due_at"` + Kind string `json:"kind"` + Description *string `json:"description"` + CourseID *uint `json:"course_id"` + Course *courseRef `json:"course"` +} + +func newDeadlinesJSON(rows []models.Deadline) []deadlineJSON { + out := make([]deadlineJSON, 0, len(rows)) + for _, d := range rows { + out = append(out, deadlineJSON{ + ID: d.ID, + Title: d.Title, + DueAt: string(d.DueAt), + Kind: d.Kind, + Description: d.Description, + CourseID: d.CourseID, + Course: newCourseRef(d.Course), + }) + } + return out +} + +// readingJSON is one reading with the caller's read state. +type readingJSON struct { + ID uint `json:"id"` + CourseID uint `json:"course_id"` + Course *courseRef `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"` + ReadAt *string `json:"read_at"` +} + +// tagJSON is one material tag. +type tagJSON struct { + ID uint `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` +} + +func newTagsJSON(tags []models.Tag) []tagJSON { + out := make([]tagJSON, 0, len(tags)) + for _, t := range tags { + out = append(out, tagJSON{ID: t.ID, Name: t.Name, Slug: t.Slug}) + } + return out +} + +// materialListJSON is a published material in a list. +type materialListJSON struct { + ID uint `json:"id"` + CourseID *uint `json:"course_id"` + Course *courseRef `json:"course"` + Title string `json:"title"` + Type string `json:"type"` + Source *string `json:"source"` + URL *string `json:"url"` + PublishedAt string `json:"published_at"` + Tags []tagJSON `json:"tags"` + FilesCount int64 `json:"files_count"` +} + +func newMaterialListJSON(m models.Material, files int64) materialListJSON { + out := materialListJSON{ + ID: m.ID, + CourseID: m.CourseID, + Course: newCourseRef(m.Course), + Title: m.Title, + Type: m.Type, + Source: m.Source, + URL: m.URL, + Tags: newTagsJSON(m.Tags), + FilesCount: files, + } + if m.PublishedAt != nil { + out.PublishedAt = string(*m.PublishedAt) + } + return out +} + +// materialDetailJSON is a published material with its body and files. +type materialDetailJSON struct { + materialListJSON + Body *string `json:"body"` + Files []fileJSON `json:"files"` +} + +// fileJSON is one attached file. +type fileJSON struct { + ID uint `json:"id"` + URL string `json:"url"` + Name string `json:"name"` + Size int64 `json:"size"` + Mime string `json:"mime"` +} + +// readingsSummary is the reading progress block of /dashboard. +type readingsSummary struct { + Total int64 `json:"total"` + Read int64 `json:"read"` + RequiredTotal int64 `json:"required_total"` + RequiredRead int64 `json:"required_read"` +} diff --git a/controllers/api/materials.go b/controllers/api/materials.go new file mode 100644 index 0000000..722a12a --- /dev/null +++ b/controllers/api/materials.go @@ -0,0 +1,183 @@ +package api + +import ( + "net/http" + "strconv" + "strings" + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/surf" + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" +) + +// published scopes a material query to what students may see: a +// publication time that has passed. A NULL published_at is a draft. +func published(q *gorm.DB, now time.Time) *gorm.DB { + return q.Where("jz_bm_materials.published_at IS NOT NULL AND jz_bm_materials.published_at <= ?", now.UTC()) +} + +// materialFilter holds the validated filters of /materials. +type materialFilter struct { + CourseID uint + Tag string + Type string +} + +func (f materialFilter) apply(q *gorm.DB) *gorm.DB { + if f.CourseID != 0 { + q = q.Where("jz_bm_materials.course_id = ?", f.CourseID) + } + if f.Type != "" { + q = q.Where("jz_bm_materials.type = ?", f.Type) + } + if f.Tag != "" { + q = q.Where(`EXISTS (SELECT 1 FROM jz_bm_material_tag mt JOIN jz_bm_tags t ON t.id = mt.tag_id +WHERE mt.material_id = jz_bm_materials.id AND t.slug = ?)`, f.Tag) + } + return q +} + +func preloadMaterial(q *gorm.DB) *gorm.DB { + return q.Preload("Course").Preload("Tags", func(db *gorm.DB) *gorm.DB { return db.Order("jz_bm_tags.name") }) +} + +// listMaterials returns one page of published materials, newest first. +func listMaterials(db *gorm.DB, now time.Time, f materialFilter, page, perPage int) ([]materialListJSON, int64, error) { + q := f.apply(published(db.Model(&models.Material{}), now)) + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, err + } + var rows []models.Material + err := preloadMaterial(f.apply(published(db, now))). + Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC"). + Offset((page - 1) * perPage).Limit(perPage). + Find(&rows).Error + if err != nil { + return nil, 0, err + } + out, err := materialsJSON(db, rows) + return out, total, err +} + +// materialsJSON builds list rows with their file counts. +func materialsJSON(db *gorm.DB, rows []models.Material) ([]materialListJSON, error) { + ids := make([]string, 0, len(rows)) + for _, m := range rows { + ids = append(ids, strconv.FormatUint(uint64(m.ID), 10)) + } + counts := map[string]int64{} + if len(ids) > 0 { + var found []struct { + AttachmentID string + Count int64 + } + err := db.Model(&attach.File{}). + Select("attachment_id, COUNT(*) AS count"). + Where("attachment_type = ? AND field = ? AND is_public AND attachment_id IN ?", models.Material{}.MorphName(), models.MaterialFilesField, ids). + Group("attachment_id"). + Scan(&found).Error + if err != nil { + return nil, err + } + for _, row := range found { + counts[row.AttachmentID] = row.Count + } + } + out := make([]materialListJSON, 0, len(rows)) + for _, m := range rows { + out = append(out, newMaterialListJSON(m, counts[strconv.FormatUint(uint64(m.ID), 10)])) + } + return out, nil +} + +// Materials answers GET /materials?course=&tag=&type=&page=&per_page=: a +// page of published materials in the {data, meta} shape of lagoon.Paginate. +func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) { + if _, ok := h.caller(w, r); !ok { + return + } + in := query(r, "course", "tag", "type", "page", "per_page") + if !h.validate(w, r, in, materialsRules) { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + filter := materialFilter{CourseID: uintParam(in, "course"), Tag: strings.TrimSpace(str(in, "tag")), Type: str(in, "type")} + page := min(intParam(in, "page", 1), 100000) + perPage := intParam(in, "per_page", h.perPage()) + rows, total, err := listMaterials(db, h.now(), filter, page, perPage) + if err != nil { + writeOpaque500(w) + return + } + writeJSONPage(w, lagoon.Paginate(rows, page, perPage, total)) +} + +// Material answers GET /materials/{id}: one published material with its +// body, tags and files. A draft or a future publication is a 404. +func (h *Handlers) Material(w http.ResponseWriter, r *http.Request) { + if _, ok := h.caller(w, r); !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + id, ok := surf.IntParam(r, "id") + if !ok { + writeNotFound(w) + return + } + var m models.Material + res := preloadMaterial(published(db, h.now())).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m) + if res.Error != nil { + writeOpaque500(w) + return + } + if res.RowsAffected == 0 { + writeNotFound(w) + return + } + var files []attach.File + err := db.Where("attachment_type = ? AND attachment_id = ? AND field = ?", m.MorphName(), strconv.FormatUint(uint64(m.ID), 10), models.MaterialFilesField). + Order("sort_order, id"). + Find(&files).Error + if err != nil { + writeOpaque500(w) + return + } + out := materialDetailJSON{ + materialListJSON: newMaterialListJSON(m, int64(len(files))), + Body: m.Body, + Files: make([]fileJSON, 0, len(files)), + } + for i := range files { + f := &files[i] + if !f.Public() { + continue + } + out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType}) + } + out.FilesCount = int64(len(out.Files)) + writeData(w, out) +} + +// publishedMaterialsOfCourse lists a course's published materials, newest +// first, for /courses/{id}. +func publishedMaterialsOfCourse(db *gorm.DB, now time.Time, courseID uint) ([]materialListJSON, error) { + var rows []models.Material + err := preloadMaterial(published(db, now)). + Where("jz_bm_materials.course_id = ?", courseID). + Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC"). + Find(&rows).Error + if err != nil { + return nil, err + } + return materialsJSON(db, rows) +} diff --git a/controllers/api/params.go b/controllers/api/params.go new file mode 100644 index 0000000..3576338 --- /dev/null +++ b/controllers/api/params.go @@ -0,0 +1,113 @@ +package api + +import ( + "net/http" + "strconv" + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/jakub/sm-bm-plugin/models" +) + +// Rule tables of the query parameters, in the order the errors are listed. +// lagoon.ParseRules panics on a bad rule, so a mistake here stops start-up. +var ( + dateRule = `nullable|regex:/^\d{4}-\d{2}-\d{2}$/|date` + + // rangeRules check from and to alone; rangeRulesFrom also orders them + // when both are given. + rangeRules = []lagoon.RequestRule{ + {Field: "from", Rules: lagoon.ParseRules(dateRule)}, + {Field: "to", Rules: lagoon.ParseRules(dateRule)}, + } + rangeRulesFrom = []lagoon.RequestRule{ + {Field: "from", Rules: lagoon.ParseRules(dateRule)}, + {Field: "to", Rules: lagoon.ParseRules(dateRule + "|after_or_equal:from")}, + } + courseFilterRules = []lagoon.RequestRule{ + {Field: "course", Rules: lagoon.ParseRules("nullable|integer|min:1")}, + } + coursesRules = []lagoon.RequestRule{ + {Field: "semester", Rules: lagoon.ParseRules("nullable|integer|between:1,4")}, + } + materialsRules = []lagoon.RequestRule{ + {Field: "course", Rules: lagoon.ParseRules("nullable|integer|min:1")}, + {Field: "tag", Rules: lagoon.ParseRules("nullable|string|max:100")}, + {Field: "type", Rules: append(lagoon.ParseRules("nullable"), lagoon.In(models.MaterialTypes...))}, + {Field: "page", Rules: lagoon.ParseRules("nullable|integer|min:1")}, + {Field: "per_page", Rules: lagoon.ParseRules("nullable|integer|between:1,100")}, + } +) + +// query collects the named query parameters that are present into the input +// map lagoon.ValidateRequest takes. A repeated parameter keeps its first +// value. +func query(r *http.Request, names ...string) map[string]any { + q := r.URL.Query() + in := make(map[string]any, len(names)) + for _, name := range names { + if vals, ok := q[name]; ok && len(vals) > 0 { + in[name] = vals[0] + } + } + return in +} + +// validate checks in against rules and answers 422 itself when it fails. +func (h *Handlers) validate(w http.ResponseWriter, r *http.Request, in map[string]any, rules []lagoon.RequestRule) bool { + errs, err := lagoon.ValidateRequest(r.Context(), nil, in, rules, h.translator()) + if err != nil { + writeOpaque500(w) + return false + } + if len(errs) > 0 { + writeValidation(w, errs) + return false + } + return true +} + +// str returns a validated string parameter, or "" when it is absent. +func str(in map[string]any, name string) string { + s, _ := in[name].(string) + return s +} + +// uintParam returns a validated positive integer parameter, or 0 when it is +// absent. +func uintParam(in map[string]any, name string) uint { + n, err := strconv.ParseUint(str(in, name), 10, 64) + if err != nil { + return 0 + } + return uint(n) +} + +// intParam returns a validated integer parameter, or def when it is absent. +func intParam(in map[string]any, name string, def int) int { + n, err := strconv.Atoi(str(in, name)) + if err != nil { + return def + } + return n +} + +// dayStart returns midnight in Europe/Warsaw of a validated YYYY-MM-DD +// parameter, and false when it is absent. +func dayStart(in map[string]any, name string) (time.Time, bool) { + s := str(in, name) + if s == "" { + return time.Time{}, false + } + t, err := models.ParseDate(s) + if err != nil { + return time.Time{}, false + } + return t, true +} + +// today returns midnight of now's calendar day in Europe/Warsaw. +func today(now time.Time) time.Time { + y, m, d := now.In(models.Location).Date() + return time.Date(y, m, d, 0, 0, 0, 0, models.Location) +} diff --git a/controllers/api/readings.go b/controllers/api/readings.go new file mode 100644 index 0000000..95d9499 --- /dev/null +++ b/controllers/api/readings.go @@ -0,0 +1,187 @@ +package api + +import ( + "net/http" + "time" + + "git.golem15.com/golem15/summercms/modules/surf" + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" +) + +// readingRow is a reading joined with the caller's progress row. +type readingRow struct { + models.Reading `gorm:"embedded"` + ReadAt *time.Time `gorm:"column:read_at"` +} + +// listReadings returns the readings, optionally of one course, in course and +// reading order, each with the caller's read_at. +func listReadings(db *gorm.DB, userID, courseID uint) ([]readingJSON, error) { + q := db.Table("jz_bm_readings AS r"). + Select("r.*, p.read_at"). + Joins("JOIN jz_bm_courses c ON c.id = r.course_id"). + Joins("LEFT JOIN jz_bm_reading_progress p ON p.reading_id = r.id AND p.user_id = ?", userID) + if courseID != 0 { + q = q.Where("r.course_id = ?", courseID) + } + var rows []readingRow + if err := q.Order("c.semester, c.sort_order, c.name, r.course_id, r.sort_order, r.id").Scan(&rows).Error; err != nil { + return nil, err + } + courses, err := coursesByID(db, readingCourseIDs(rows)) + if err != nil { + return nil, err + } + out := make([]readingJSON, 0, len(rows)) + for _, row := range rows { + item := readingJSON{ + ID: row.ID, + CourseID: row.CourseID, + Course: newCourseRef(courses[row.CourseID]), + Title: row.Title, + Author: row.Author, + Source: row.Source, + URL: row.URL, + IsRequired: row.IsRequired, + SortOrder: row.SortOrder, + } + if row.ReadAt != nil { + s := string(models.NewDateTime(*row.ReadAt)) + item.ReadAt = &s + } + out = append(out, item) + } + return out, nil +} + +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) + } + } + return ids +} + +// coursesByID loads the given courses keyed by ID. +func coursesByID(db *gorm.DB, ids []uint) (map[uint]*models.Course, error) { + out := map[uint]*models.Course{} + if len(ids) == 0 { + return out, nil + } + var rows []models.Course + if err := db.Where("id IN ?", ids).Find(&rows).Error; err != nil { + return nil, err + } + for i := range rows { + out[rows[i].ID] = &rows[i] + } + return out, nil +} + +// Readings answers GET /readings?course=ID: the reading lists with the +// caller's read state. An unknown course gives an empty list. +func (h *Handlers) Readings(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + in := query(r, "course") + if !h.validate(w, r, in, courseFilterRules) { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + out, err := listReadings(db, userID, uintParam(in, "course")) + if err != nil { + writeOpaque500(w) + return + } + writeData(w, out) +} + +// progressJSON is the answer of the progress routes. +type progressJSON struct { + ReadingID uint `json:"reading_id"` + ReadAt *string `json:"read_at"` +} + +// readingFor resolves {id} to an existing reading and answers 404 itself +// when there is none. +func readingFor(w http.ResponseWriter, r *http.Request, db *gorm.DB) (uint, bool) { + id, ok := surf.IntParam(r, "id") + if !ok { + writeNotFound(w) + return 0, false + } + var reading models.Reading + res := db.Select("id").Where("id = ?", id).Limit(1).Find(&reading) + if res.Error != nil { + writeOpaque500(w) + return 0, false + } + if res.RowsAffected == 0 { + writeNotFound(w) + return 0, false + } + return reading.ID, true +} + +// MarkRead answers PUT /readings/{id}/progress: it marks the reading read +// by the caller. Marking it again keeps the first read_at. +func (h *Handlers) MarkRead(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + readingID, ok := readingFor(w, r, db) + if !ok { + return + } + now := h.now().UTC() + err := db.Exec(`INSERT INTO jz_bm_reading_progress (reading_id, user_id, read_at) VALUES (?, ?, ?) +ON CONFLICT (reading_id, user_id) DO NOTHING`, readingID, userID, now).Error + if err != nil { + writeOpaque500(w) + return + } + var progress models.ReadingProgress + if err := db.Where("reading_id = ? AND user_id = ?", readingID, userID).Take(&progress).Error; err != nil { + writeOpaque500(w) + return + } + readAt := string(models.NewDateTime(progress.ReadAt)) + writeData(w, progressJSON{ReadingID: readingID, ReadAt: &readAt}) +} + +// UnmarkRead answers DELETE /readings/{id}/progress: it clears the caller's +// read mark. Clearing an unread reading is not an error. +func (h *Handlers) UnmarkRead(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + readingID, ok := readingFor(w, r, db) + if !ok { + return + } + if err := db.Where("reading_id = ? AND user_id = ?", readingID, userID).Delete(&models.ReadingProgress{}).Error; err != nil { + writeOpaque500(w) + return + } + writeData(w, progressJSON{ReadingID: readingID}) +} diff --git a/controllers/api/schedule.go b/controllers/api/schedule.go new file mode 100644 index 0000000..620fce5 --- /dev/null +++ b/controllers/api/schedule.go @@ -0,0 +1,137 @@ +package api + +import ( + "net/http" + "time" + + "git.golem15.com/jakub/sm-bm-plugin/models" + "gorm.io/gorm" +) + +// validRange validates ?from=&to= and returns the decoded input. +func (h *Handlers) validRange(w http.ResponseWriter, r *http.Request) (map[string]any, bool) { + in := query(r, "from", "to") + rules := rangeRules + if str(in, "from") != "" { + rules = rangeRulesFrom + } + return in, h.validate(w, r, in, rules) +} + +// bounds turns validated from and to dates into a half-open instant range: +// from midnight of from to midnight after to, in Europe/Warsaw. Without +// from the lower bound is lower; without to there is no upper bound. +func bounds(in map[string]any, lower time.Time) (time.Time, *time.Time) { + if from, ok := dayStart(in, "from"); ok { + lower = from + } + if to, ok := dayStart(in, "to"); ok { + upper := to.AddDate(0, 0, 1) + return lower, &upper + } + return lower, nil +} + +func preloadSession(q *gorm.DB) *gorm.DB { + return q.Preload("Course").Preload("Meeting") +} + +// Schedule answers GET /schedule?from=YYYY-MM-DD&to=YYYY-MM-DD: the class +// sessions starting in the range, by start time. from defaults to today. +func (h *Handlers) Schedule(w http.ResponseWriter, r *http.Request) { + if _, ok := h.caller(w, r); !ok { + return + } + in, ok := h.validRange(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + lower, upper := bounds(in, today(h.now())) + q := preloadSession(db).Where("starts_at >= ?", lower.UTC()) + if upper != nil { + q = q.Where("starts_at < ?", upper.UTC()) + } + var rows []models.ClassSession + if err := q.Order("starts_at, id").Find(&rows).Error; err != nil { + writeOpaque500(w) + return + } + writeData(w, newSessionsJSON(rows)) +} + +// Meetings answers GET /meetings: every zjazd by number, with its session +// count. +func (h *Handlers) Meetings(w http.ResponseWriter, r *http.Request) { + if _, ok := h.caller(w, r); !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + var rows []models.Meeting + if err := db.Order("number, id").Find(&rows).Error; err != nil { + writeOpaque500(w) + return + } + counts, err := countBy(db, "jz_bm_class_sessions", "meeting_id") + if err != nil { + writeOpaque500(w) + return + } + out := make([]meetingJSON, 0, len(rows)) + for _, m := range rows { + out = append(out, newMeetingJSON(m, counts[m.ID])) + } + writeData(w, out) +} + +// Deadlines answers GET /deadlines?from=&to=. Without from it lists the +// deadlines not yet due; from and to are calendar days in Europe/Warsaw. +func (h *Handlers) Deadlines(w http.ResponseWriter, r *http.Request) { + if _, ok := h.caller(w, r); !ok { + return + } + in, ok := h.validRange(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + lower, upper := bounds(in, h.now()) + q := db.Preload("Course").Where("due_at >= ?", lower.UTC()) + if upper != nil { + q = q.Where("due_at < ?", upper.UTC()) + } + var rows []models.Deadline + if err := q.Order("due_at, id").Find(&rows).Error; err != nil { + writeOpaque500(w) + return + } + writeData(w, newDeadlinesJSON(rows)) +} + +// countBy counts the rows of table per value of a nullable foreign key +// column. table and column are constants of this package, never input. +func countBy(db *gorm.DB, table, column string) (map[uint]int64, error) { + var rows []struct { + Key uint + Count int64 + } + err := db.Table(table). + Select(column + " AS key, COUNT(*) AS count"). + Where(column + " IS NOT NULL"). + Group(column). + Scan(&rows).Error + out := make(map[uint]int64, len(rows)) + for _, row := range rows { + out[row.Key] = row.Count + } + return out, err +} diff --git a/routes.go b/routes.go index 251a11c..e4bb245 100644 --- a/routes.go +++ b/routes.go @@ -1,7 +1,65 @@ package bm -import "git.golem15.com/golem15/summercms/modules/pact" +import ( + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/phrasebook" + "git.golem15.com/golem15/summercms/modules/surf" + "git.golem15.com/jakub/sm-bm-plugin/controllers/api" + "gorm.io/gorm" +) var _ pact.HasRoutes = (*Plugin)(nil) -func (p *Plugin) Routes(pact.Router) error { return nil } +// APIPrefix is the path prefix of the student API. +const APIPrefix = "/_bm/api/v1" + +// Routes declares the student API. Every route needs a golem15.user JWT +// (jwt.auth); students only read, except for their own reading progress. +func (p *Plugin) Routes(r pact.Router) error { + h := p.handlers() + r.Group(APIPrefix, surf.Use("jwt.auth", "locale.from-principal"), func(g pact.Router) { + g.Get("/dashboard", h.Dashboard) + g.Get("/courses", h.Courses) + g.Get("/courses/{id}", h.Course) + g.Where("id", "[0-9]+") + g.Get("/schedule", h.Schedule) + g.Get("/meetings", h.Meetings) + g.Get("/deadlines", h.Deadlines) + g.Get("/readings", h.Readings) + g.Put("/readings/{id}/progress", h.MarkRead) + g.Where("id", "[0-9]+") + g.Delete("/readings/{id}/progress", h.UnmarkRead) + g.Where("id", "[0-9]+") + g.Get("/materials", h.Materials) + g.Get("/materials/{id}", h.Material) + g.Where("id", "[0-9]+") + }) + return nil +} + +// handlers builds the API handlers on the application. The database is +// looked up per request, because the binary boots plugins before serve +// publishes it. +func (p *Plugin) handlers() *api.Handlers { + h := &api.Handlers{ + DB: func() *gorm.DB { + if p.app == nil { + return nil + } + db, _ := p.app.Lookup[*gorm.DB]() + return db + }, + Translator: func() *phrasebook.Translator { + if p.app == nil { + return nil + } + tr, _ := p.app.Lookup[*phrasebook.Translator]() + return tr + }, + } + if p.app != nil && p.app.Config != nil { + h.PerPage = p.app.Config.Int("jz.bm.materials_per_page") + h.DashboardLimit = p.app.Config.Int("jz.bm.dashboard_limit") + } + return h +}