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:
@@ -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
122
controllers/api/api.go
Normal 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
118
controllers/api/courses.go
Normal 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,
|
||||
})
|
||||
}
|
||||
88
controllers/api/dashboard.go
Normal file
88
controllers/api/dashboard.go
Normal 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
|
||||
}
|
||||
144
controllers/api/handlers_test.go
Normal file
144
controllers/api/handlers_test.go
Normal 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
242
controllers/api/json.go
Normal 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"`
|
||||
}
|
||||
183
controllers/api/materials.go
Normal file
183
controllers/api/materials.go
Normal 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
113
controllers/api/params.go
Normal 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
187
controllers/api/readings.go
Normal 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
137
controllers/api/schedule.go
Normal 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
|
||||
}
|
||||
62
routes.go
62
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user