Files
sm-bm-plugin/controllers/api/materials.go
Jakub Zych 237069c827 feat: revision-check material writes
- add jz_bm_materials.revision (default 1), read-only for GORM
- bump it only on content changes (models.ReviseMaterial)
- PUT /materials/{id} locks the row and answers 409 to a stale revision
- management answers carry revision and updated_at, the student detail
  carries revision
2026-11-08 23:33:34 +01:00

271 lines
8.7 KiB
Go

package api
import (
"net/http"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/beachcomber"
"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/classes"
"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. With ids nil the page is newest first. With ids non-nil (search
// candidates in the engine's rank order) both the count and the page are
// also limited to ids and the page follows their order; the ids never widen
// what visibleMaterials and the filter allow. An empty ids is an empty page.
func listMaterials(db *gorm.DB, now time.Time, userID uint, f materialFilter, ids []uint, page, perPage int) ([]materialListJSON, []models.Material, int64, error) {
if ids != nil && len(ids) == 0 {
return []materialListJSON{}, nil, 0, nil
}
q := f.apply(visibleMaterials(db.Model(&models.Material{}), now, userID))
if ids != nil {
q = q.Where("jz_bm_materials.id IN ?", ids)
}
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, nil, 0, err
}
rowsQ := preloadMaterial(f.apply(visibleMaterials(db, now, userID)))
if ids != nil {
rowsQ = rowsQ.Where("jz_bm_materials.id IN ?", ids).Order(rankOrder(ids))
} else {
rowsQ = rowsQ.Order("jz_bm_materials.published_at DESC, jz_bm_materials.id DESC")
}
var rows []models.Material
err := rowsQ.Offset((page - 1) * perPage).Limit(perPage).Find(&rows).Error
if err != nil {
return nil, nil, 0, err
}
out, err := materialsJSON(db, rows)
return out, rows, 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?q=&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. With q (2 to 100 characters) the engine answers
// candidates, which SQL re-gates, counts and pages in rank order, each row
// with at most one snippet; when the engine cannot answer the request is a
// 503. Without q the list is newest first and never calls the engine.
func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
in := query(r, "q", "course", "meeting", "tag", "type", "page", "per_page")
if !h.validate(w, r, in, materialsRules) {
return
}
filter := materialFilter{CourseID: uintParam(in, "course"), MeetingID: uintParam(in, "meeting"), Tag: strings.TrimSpace(str(in, "tag")), Type: str(in, "type")}
var cands *classes.MaterialCandidates
if q := searchQuery(in); q != "" {
c, err := h.MaterialIndex.Search(r.Context(), classes.MaterialSearch{Q: q, CourseID: filter.CourseID, MeetingID: filter.MeetingID, Type: filter.Type, Tag: filter.Tag})
if err != nil {
writeSearchUnavailable(w)
return
}
cands = &c
}
db, ok := h.db(w, r)
if !ok {
return
}
page := min(intParam(in, "page", 1), 100000)
perPage := intParam(in, "per_page", h.perPage())
var ids []uint
if cands != nil {
ids = cands.IDs
if ids == nil {
ids = []uint{}
}
}
out, rows, total, err := listMaterials(db, h.now(), userID, filter, ids, page, perPage)
if err != nil {
writeOpaque500(w)
return
}
if cands != nil {
files, err := snippetFiles(db, materialIDs(rows), cands.Hits)
if err != nil {
writeOpaque500(w)
return
}
attachSnippets(out, rows, cands.Hits, files, viaMCP(r))
}
writeJSONPage(w, lagoon.Paginate(out, page, perPage, total))
}
// attachSnippets sets the snippet of each list row from its Hit and, for a
// file snippet, the row's indexed files. rows are
// the rows SQL returned (they passed the re-gate), in the order of out; the
// Hit of an id SQL did not return is never read.
func attachSnippets(out []materialListJSON, rows []models.Material, hits map[uint]beachcomber.Hit, files map[uint][]models.IndexedFile, mcp bool) {
for i := range out {
if i >= len(rows) || rows[i].ID != out[i].ID {
continue
}
hit, ok := hits[rows[i].ID]
if !ok {
continue
}
out[i].Snippet = snippetValue(pickSnippet(hit, rows[i], files[rows[i].ID]), mcp)
}
}
// materialIDs are the ids of rows, in order.
func materialIDs(rows []models.Material) []uint {
ids := make([]uint, 0, len(rows))
for _, m := range rows {
ids = append(ids, m.ID)
}
return ids
}
// 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)),
Revision: m.Revision,
}
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)
}