feat: add the student API under /_bm/api/v1

Dashboard, courses with counters, schedule, meetings, deadlines,
readings with per-user progress (mark and unmark) and paginated
published materials with tags and files, behind golem15.user's
jwt.auth. Responses are built field by field with Warsaw RFC 3339
timestamps; bad query parameters answer 422 in Laravel's shape.
This commit is contained in:
Jakub Zych
2026-10-02 14:26:34 +02:00
parent 577fa14d3d
commit 98cabfd51d
11 changed files with 1401 additions and 3 deletions

View File

@@ -1 +1,7 @@
{}
# Defaults for jz.bm, merged under the plugin ID: the application reads them
# as jz.bm.<key> 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

122
controllers/api/api.go Normal file
View File

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

118
controllers/api/courses.go Normal file
View File

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

View File

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

View File

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

242
controllers/api/json.go Normal file
View File

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

View File

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

113
controllers/api/params.go Normal file
View File

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

187
controllers/api/readings.go Normal file
View File

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

137
controllers/api/schedule.go Normal file
View File

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

View File

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