A material may be private: only its author and the users it is shared
with see it. Every student read (list, detail, course detail and
counters, the checklist linked material, the token twins) goes through
one visibility fragment. Existing materials stay public with no author.
GET/PUT /_bm/api/v1/materials/{id}/access let the author, a material
manager, toggle the flag and pick the users; the first change of an
author-less material makes the caller its author.
203 lines
6.3 KiB
Go
203 lines
6.3 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
|
|
MeetingID 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.MeetingID != 0 {
|
|
q = q.Where("jz_bm_materials.meeting_id = ?", f.MeetingID)
|
|
}
|
|
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 the published materials userID may
|
|
// see, newest first.
|
|
func listMaterials(db *gorm.DB, now time.Time, userID uint, f materialFilter, page, perPage int) ([]materialListJSON, int64, error) {
|
|
q := f.apply(visibleMaterials(db.Model(&models.Material{}), now, userID))
|
|
var total int64
|
|
if err := q.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var rows []models.Material
|
|
err := preloadMaterial(f.apply(visibleMaterials(db, now, userID))).
|
|
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=&meeting=&tag=&type=&page=&per_page=:
|
|
// a page of the published materials the caller may see, in the {data, meta}
|
|
// shape of lagoon.Paginate.
|
|
func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
|
|
userID, ok := h.caller(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
in := query(r, "course", "meeting", "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"), MeetingID: uintParam(in, "meeting"), 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(), userID, 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 the caller
|
|
// may see, with its body, tags and files. A draft, a future publication or
|
|
// another user's private material is a 404. can_manage tells the author (a
|
|
// material manager) that the access panel applies. An MCP call gets the body
|
|
// only with include_body (see materialView).
|
|
func (h *Handlers) Material(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
|
|
}
|
|
var m models.Material
|
|
res := preloadMaterial(visibleMaterials(db, h.now(), userID)).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))
|
|
if m.AuthorUserID == nil || *m.AuthorUserID == userID {
|
|
manager, err := h.managesMaterials(r.Context(), db, userID)
|
|
if err != nil {
|
|
writeOpaque500(w)
|
|
return
|
|
}
|
|
out.CanManage = canManageMaterial(m, userID, manager)
|
|
}
|
|
writeData(w, materialView(out, viaMCP(r), includeBody(r)))
|
|
}
|
|
|
|
// publishedMaterialsOfCourse lists a course's published materials that
|
|
// userID may see, newest first, for /courses/{id}.
|
|
func publishedMaterialsOfCourse(db *gorm.DB, now time.Time, userID uint, courseID uint) ([]materialListJSON, error) {
|
|
var rows []models.Material
|
|
err := preloadMaterial(visibleMaterials(db, now, userID)).
|
|
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)
|
|
}
|