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.
184 lines
5.5 KiB
Go
184 lines
5.5 KiB
Go
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)
|
|
}
|