Datepickers on native time.Time / lagoon.Date fields, file uploads on materials, readings and class sessions managed from the course form (course_id made nullable).
188 lines
4.8 KiB
Go
188 lines
4.8 KiB
Go
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: idOf(row.CourseID),
|
|
Course: newCourseRef(courses[idOf(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 := models.FormatDateTime(*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 id := idOf(row.CourseID); !seen[id] {
|
|
seen[id] = true
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
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 = ? AND course_id IS NOT NULL", 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 := models.FormatDateTime(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})
|
|
}
|