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