Management API: record routes for courses, meetings, class sessions, readings and deadlines (Fillable + Rules + TemporalErrors, PATCH, filters)

This commit is contained in:
Jakub Zych
2026-10-17 18:05:39 +02:00
parent 996b4d1542
commit 2536bcb959
6 changed files with 618 additions and 22 deletions

View File

@@ -192,7 +192,6 @@ The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with tim
| Method | Route | Scope, permission | Answer |
|--------|-------|-------------------|--------|
| GET | `/api/v1/bm/me` | read | The token owner's `user_id` and the token (`id`, `name`, `scopes`, `expires_at`). |
| GET | `/api/v1/bm/courses` | read, courses | Every course (`courseJSON`), for the `course_id` of a material. |
| GET | `/api/v1/bm/materials` | read, materials | Every material, drafts included, newest first. |
| GET | `/api/v1/bm/materials/{id}` | read, materials | One material, draft or not, with its public `files`. 404 when unknown. |
| POST | `/api/v1/bm/materials` | write, materials | Upsert on `title` + `course_id`: 201 `{"data", "created": true}` for a new material, 200 `{"data", "created": false}` for an updated one. |
@@ -200,8 +199,25 @@ The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with tim
| DELETE | `/api/v1/bm/materials/{id}` | write, materials | 204, with the material's files (rows, then blobs); 404 when unknown. |
| POST | `/api/v1/bm/materials/{id}/files` | write, materials | Multipart body, parts named `file`: each is stored as a public file of the material, after the ones it has. 201 with the material as GET shows it; 404 when unknown; 422 on `file` when there is no file part, a type is not allowed, a file is over 50 MB or the material would hold more than 20 files (nothing is stored then); 413 over the body cap (`body.limit`, 51 MB, so one large file per request). |
| DELETE | `/api/v1/bm/materials/{id}/files/{file}` | write, materials | 204: the file row, then its blob and thumbnails; 404 when the material or a file of it is unknown. |
| GET | `/api/v1/bm/{records}` | read, see below | Every row of the table, filtered by `?column=value` on the columns listed below (`null` matches NULL). |
| GET | `/api/v1/bm/{records}/{id}` | read | One row; 404 when unknown. |
| POST | `/api/v1/bm/{records}` | write | 201 with the new row. |
| PATCH | `/api/v1/bm/{records}/{id}` | write | Only the sent columns change; 200 with the row. |
| DELETE | `/api/v1/bm/{records}/{id}` | write | 204 (class sessions, readings, deadlines only; courses and meetings are deleted in the admin, because rows hang on them). |
| POST | `/api/v1/bm/seed?dry_run=&prune=` | write, courses + schedule + materials | `bm:seed` over HTTP: the body is the seed document, the answer the summary table. |
The record tables (`controllers/api/manage_records.go`, mounted by `mountRecords` in `routes.go`):
| `{records}` | Model | Permission | List filters | Order |
|-------------|-------|------------|--------------|-------|
| `courses` | `Course` | courses | `semester`, `form`, `code` | semester, sort_order |
| `meetings` | `Meeting` | schedule | | number |
| `class-sessions` | `ClassSession` | schedule | `course_id`, `meeting_id` | starts_at |
| `readings` | `Reading` | courses | `course_id`, `is_required` | course_id, sort_order |
| `deadlines` | `Deadline` | schedule | `course_id`, `kind` | due_at |
A row is answered as an object of its columns (`id`, `created_at` and `updated_at` included); times are RFC 3339 in Europe/Warsaw, dates `YYYY-MM-DD`. A POST or PATCH body is an object of columns: only the model's `Fillable()` ones (another key is a 422 that lists them), with times as RFC 3339 or `2006-01-02 15:04` in Europe/Warsaw, dates as `YYYY-MM-DD`, `""` or `null` clearing an optional column, and a number accepted for a text column. The model's `Rules()` (the admin's) run on the row as it would be saved, so a PATCH of one column still meets the required ones, and `unique` skips the row itself; `TemporalErrors()` checks the order of times; `course_id` and `meeting_id` must exist. A seed run (`bm:seed`, `POST /seed`) overwrites the columns it carries on the rows it matches, so changes made here are lost when the same rows are seeded again.
The material body (POST and PUT), unknown keys are a 422:
```json

View File

@@ -162,25 +162,6 @@ func newManagedMaterialJSON(m models.Material) managedMaterialJSON {
return out
}
// ManagedCourses answers GET /courses: every course, for the course_id of
// a material.
func (h *Handlers) ManagedCourses(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
var rows []models.Course
if err := db.Order("semester, sort_order, name, id").Find(&rows).Error; err != nil {
writeOpaque500(w)
return
}
out := make([]courseJSON, 0, len(rows))
for _, c := range rows {
out = append(out, newCourseJSON(c))
}
writeData(w, out)
}
// ManagedMaterials answers GET /materials: every material, drafts
// included, newest first.
func (h *Handlers) ManagedMaterials(w http.ResponseWriter, r *http.Request) {

View File

@@ -0,0 +1,375 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"reflect"
"slices"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// The record routes of the management API: courses, meetings, class
// sessions, readings and deadlines, one by one, so a script changes a row
// without a seed run. A body is a JSON object keyed by column names; only the
// model's Fillable() columns are accepted (anything else is a 422), the
// model's Rules() are checked as in the admin, against the row as it would
// be saved, and so is TemporalErrors() where a model has it. A record is
// answered as an object of its columns, times as RFC 3339 in Europe/Warsaw
// and dates as YYYY-MM-DD.
// recordModel is what a managed model provides.
type recordModel interface {
TableName() string
Fillable() []string
Rules() map[string]string
}
type temporalChecker interface {
TemporalErrors() map[string][]string
}
// Record describes one managed table.
type Record[T any, PT interface {
*T
recordModel
}] struct {
// Filters are the columns a list may be filtered on (?column=value).
Filters []string
// Order is the ORDER BY of a list.
Order string
// References maps a foreign key column to the table it must exist in.
References map[string]string
}
func (rec Record[T, PT]) table() string { return PT(new(T)).TableName() }
// List answers GET /{records}: every row, filtered by the query parameters
// named in Filters.
func (rec Record[T, PT]) List(h *Handlers) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
q := db.Model(new(T))
for _, col := range rec.Filters {
v := r.URL.Query().Get(col)
switch {
case v == "":
continue
case v == "null":
q = q.Where(col + " IS NULL")
default:
q = q.Where(col+" = ?", v)
}
}
var rows []T
if err := q.Order(rec.Order).Find(&rows).Error; err != nil {
writeOpaque500(w)
return
}
out := make([]map[string]any, 0, len(rows))
for i := range rows {
out = append(out, recordJSON(&rows[i]))
}
writeData(w, out)
}
}
// Show answers GET /{records}/{id}.
func (rec Record[T, PT]) Show(h *Handlers) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
row, ok := rec.find(w, r, db)
if !ok {
return
}
writeData(w, recordJSON(row))
}
}
// Create answers POST /{records}: 201 with the new row.
func (rec Record[T, PT]) Create(h *Handlers) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
rec.save(w, r, h, db, new(T), true)
}
}
// Update answers PATCH /{records}/{id}: the sent columns change, the rest
// stay.
func (rec Record[T, PT]) Update(h *Handlers) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
row, ok := rec.find(w, r, db)
if !ok {
return
}
rec.save(w, r, h, db, row, false)
}
}
// Delete answers DELETE /{records}/{id}: 204. Dependent rows follow the
// foreign keys (reading progress goes with its reading).
func (rec Record[T, PT]) Delete(h *Handlers) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
db, ok := h.db(w, r)
if !ok {
return
}
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return
}
res := db.Where("id = ?", id).Delete(new(T))
if res.Error != nil {
writeOpaque500(w)
return
}
if res.RowsAffected == 0 {
writeNotFound(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func (rec Record[T, PT]) find(w http.ResponseWriter, r *http.Request, db *gorm.DB) (*T, bool) {
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return nil, false
}
row := new(T)
res := db.Where("id = ?", id).Limit(1).Find(row)
if res.Error != nil {
writeOpaque500(w)
return nil, false
}
if res.RowsAffected == 0 {
writeNotFound(w)
return nil, false
}
return row, true
}
// save decodes the body onto row, validates the result and writes it.
func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handlers, db *gorm.DB, row *T, created bool) {
raw, ok := body(w, r)
if !ok {
return
}
var in map[string]any
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&in); err != nil || in == nil {
writeInvalid(w, "The body must be a JSON object of columns.")
return
}
model := PT(row)
fillable := model.Fillable()
errs := map[string][]string{}
for key := range in {
if !slices.Contains(fillable, key) {
errs[key] = []string{"Unknown or read-only field; allowed: " + strings.Join(fillable, ", ") + "."}
}
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
values, fieldErrs := normalizeValues(row, in)
for k, v := range fieldErrs {
errs[k] = v
}
if len(errs) == 0 {
var typeErr *lagoon.FillTypeError
if err := lagoon.Fill(row, fillable, values, true); errors.As(err, &typeErr) {
errs[typeErr.Key] = []string{"The " + typeErr.Key + " has the wrong type."}
} else if err != nil {
writeOpaque500(w)
return
}
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
// The rules run on the row as it would be saved, so a PATCH of one
// column still meets the required ones.
check := recordJSON(row)
verrs, err := lagoon.Validate(r.Context(), db, row, model.Rules(), check, h.translator())
if err != nil {
writeOpaque500(w)
return
}
for k, v := range verrs {
errs[k] = v
}
if tc, ok := any(row).(temporalChecker); ok {
for k, v := range tc.TemporalErrors() {
errs[k] = append(errs[k], v...)
}
}
for col, table := range rec.References {
id, ok := check[col].(uint)
if !ok || id == 0 {
continue
}
var n int64
if err := db.Table(table).Where("id = ?", id).Count(&n).Error; err != nil {
writeOpaque500(w)
return
}
if n == 0 {
errs[col] = append(errs[col], "The selected "+col+" is invalid.")
}
}
if len(errs) > 0 {
writeValidation(w, errs)
return
}
err = lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
return tx.Save(row).Error
})
if err != nil {
writeOpaque500(w)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
wire.WriteJSON(w, status, map[string]any{"data": recordJSON(row)})
}
var (
timeType = reflect.TypeOf(time.Time{})
dateType = reflect.TypeOf(lagoon.Date{})
)
// columns maps the column names of a model to their struct field indexes.
func columns(t reflect.Type) map[string]int {
out := map[string]int{}
for i := 0; i < t.NumField(); i++ {
for _, part := range strings.Split(t.Field(i).Tag.Get("gorm"), ";") {
if name, ok := strings.CutPrefix(part, "column:"); ok {
out[name] = i
}
}
}
return out
}
// normalizeValues turns the decoded JSON into what lagoon.Fill takes:
// numbers as int64 or float64, times parsed in Europe/Warsaw (RFC 3339 or
// "2006-01-02 15:04"), dates as YYYY-MM-DD, and "" as null for optional
// text.
func normalizeValues(row any, in map[string]any) (map[string]any, map[string][]string) {
t := reflect.TypeOf(row).Elem()
cols := columns(t)
out := make(map[string]any, len(in))
errs := map[string][]string{}
for key, v := range in {
ft := t.Field(cols[key]).Type
base := ft
if base.Kind() == reflect.Pointer {
base = base.Elem()
}
switch val := v.(type) {
case json.Number:
if base.Kind() == reflect.String {
// A room or code sent as a number ("room": 107).
out[key] = val.String()
continue
}
if i, err := val.Int64(); err == nil {
out[key] = i
} else if f, err := val.Float64(); err == nil {
out[key] = f
} else {
errs[key] = []string{"The " + key + " is not a number."}
}
continue
case string:
switch base {
case timeType:
tm, err := models.ParseDateTime(val)
if err != nil {
errs[key] = []string{"The " + key + " is not a valid date and time."}
continue
}
out[key] = tm
continue
case dateType:
d, err := models.ParseDate(val)
if err != nil {
errs[key] = []string{"The " + key + " is not a valid date (YYYY-MM-DD)."}
continue
}
out[key] = d
continue
}
if val == "" && ft.Kind() == reflect.Pointer {
out[key] = nil
continue
}
}
out[key] = v
}
return out, errs
}
// recordJSON is a row as an object of its columns.
func recordJSON(row any) map[string]any {
v := reflect.ValueOf(row).Elem()
cols := columns(v.Type())
out := make(map[string]any, len(cols))
for name, i := range cols {
f := v.Field(i)
if f.Kind() == reflect.Pointer {
if f.IsNil() {
out[name] = nil
continue
}
f = f.Elem()
}
switch x := f.Interface().(type) {
case time.Time:
if x.IsZero() {
out[name] = nil
} else {
out[name] = models.FormatDateTime(x)
}
case lagoon.Date:
if x.IsZero() {
out[name] = nil
} else {
out[name] = x.String()
}
default:
out[name] = x
}
}
return out
}

View File

@@ -346,3 +346,159 @@ RETURNING id`).Scan(&groupID).Error; err != nil {
t.Errorf("a blob is left in the bucket: %v %v", obj, err)
}
}
// TestManagementRecords drives the record routes: create, partial update,
// validation (unknown column, rules, temporal order, a missing reference),
// list filters and delete, for courses, meetings, sessions, readings and
// deadlines.
func TestManagementRecords(t *testing.T) {
app, plugins, db := migrated(t)
var userID uint
if err := db.Raw(`
INSERT INTO users (email, password, must_change_password)
VALUES ('bm-records-token@example.test', '', false)
RETURNING id`).Scan(&userID).Error; err != nil {
t.Fatal(err)
}
var groupID uint
if err := db.Raw(`
INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
VALUES ('BM records', 'bm-records', '{"jz.bm.access_courses":1,"jz.bm.access_schedule":1}', NOW(), NOW())
RETURNING id`).Scan(&groupID).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, userID, groupID).Error; err != nil {
t.Fatal(err)
}
secret, token, err := userclasses.MintAPIToken("usr_", userID, "BM records", []string{"read", "write"}, nil, nil)
if err != nil {
t.Fatal(err)
}
if err := db.Create(token).Error; err != nil {
t.Fatal(err)
}
handler, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatal(err)
}
send := func(method, path string, payload any) (int, map[string]any) {
t.Helper()
var rd io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
rd = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, "/api/v1/bm"+path, rd)
req.Header.Set("Authorization", "Bearer "+secret)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
var out map[string]any
if rec.Body.Len() > 0 {
_ = json.Unmarshal(rec.Body.Bytes(), &out)
}
return rec.Code, out
}
data := func(out map[string]any) map[string]any {
t.Helper()
d, _ := out["data"].(map[string]any)
if d == nil {
t.Fatalf("no data object in %v", out)
}
return d
}
id := func(d map[string]any) string { return strconv.Itoa(int(d["id"].(float64))) }
errorsOf := func(out map[string]any) map[string]any {
e, _ := out["errors"].(map[string]any)
return e
}
code, out := send(http.MethodPost, "/courses", map[string]any{"name": "Teoria", "form": "wyklad", "semester": 1, "code": "26-X"})
if code != http.StatusCreated {
t.Fatalf("create course = %d %v", code, out)
}
course := data(out)
code, out = send(http.MethodPatch, "/courses/"+id(course), map[string]any{"description": "Plan wykładów: 1. Wprowadzenie."})
if code != http.StatusOK || data(out)["description"] != "Plan wykładów: 1. Wprowadzenie." || data(out)["name"] != "Teoria" {
t.Fatalf("patch course = %d %v", code, out)
}
if code, out = send(http.MethodPatch, "/courses/"+id(course), map[string]any{"owner": 1}); code != http.StatusUnprocessableEntity || errorsOf(out)["owner"] == nil {
t.Errorf("course patch with an unknown column = %d %v", code, out)
}
if code, out = send(http.MethodPatch, "/courses/"+id(course), map[string]any{"form": "warsztat", "semester": 9}); code != http.StatusUnprocessableEntity || errorsOf(out)["form"] == nil || errorsOf(out)["semester"] == nil {
t.Errorf("course patch breaking the rules = %d %v", code, out)
}
if code, _ = send(http.MethodDelete, "/courses/"+id(course), nil); code != http.StatusMethodNotAllowed && code != http.StatusNotFound {
t.Errorf("course delete = %d, want no route", code)
}
code, out = send(http.MethodPost, "/meetings", map[string]any{"number": 1, "starts_on": "2026-10-03", "ends_on": "2026-10-04"})
if code != http.StatusCreated || data(out)["starts_on"] != "2026-10-03" {
t.Fatalf("create meeting = %d %v", code, out)
}
meeting := data(out)
if code, out = send(http.MethodPost, "/meetings", map[string]any{"number": 1, "starts_on": "2026-10-17", "ends_on": "2026-10-18"}); code != http.StatusUnprocessableEntity || errorsOf(out)["number"] == nil {
t.Errorf("duplicate meeting number = %d %v", code, out)
}
if code, out = send(http.MethodPatch, "/meetings/"+id(meeting), map[string]any{"notes": "online"}); code != http.StatusOK || data(out)["number"] != float64(1) {
t.Errorf("patch own meeting (unique on itself) = %d %v", code, out)
}
session := map[string]any{"course_id": course["id"], "meeting_id": meeting["id"], "starts_at": "2026-10-03 09:00", "ends_at": "2026-10-03 10:30", "room": "107"}
code, out = send(http.MethodPost, "/class-sessions", session)
if code != http.StatusCreated || data(out)["starts_at"] != "2026-10-03T09:00:00+02:00" {
t.Fatalf("create session = %d %v", code, out)
}
sess := data(out)
if code, out = send(http.MethodPatch, "/class-sessions/"+id(sess), map[string]any{"ends_at": "2026-10-03 08:00"}); code != http.StatusUnprocessableEntity || errorsOf(out)["ends_at"] == nil {
t.Errorf("session ending before it starts = %d %v", code, out)
}
if code, out = send(http.MethodPatch, "/class-sessions/"+id(sess), map[string]any{"room": "", "lecturer": "dr X", "is_online": true}); code != http.StatusOK || data(out)["room"] != nil || data(out)["is_online"] != true {
t.Errorf("patch session = %d %v", code, out)
}
if code, out = send(http.MethodPatch, "/class-sessions/"+id(sess), map[string]any{"room": 102}); code != http.StatusOK || data(out)["room"] != "102" {
t.Errorf("room sent as a number = %d %v", code, out)
}
if code, out = send(http.MethodPost, "/class-sessions", map[string]any{"course_id": 999999, "starts_at": "2026-10-03 09:00", "ends_at": "2026-10-03 10:00"}); code != http.StatusUnprocessableEntity || errorsOf(out)["course_id"] == nil {
t.Errorf("session of an unknown course = %d %v", code, out)
}
code, out = send(http.MethodPost, "/readings", map[string]any{"course_id": course["id"], "title": "Kuhn, Struktura rewolucji naukowych", "is_required": true})
if code != http.StatusCreated {
t.Fatalf("create reading = %d %v", code, out)
}
reading := data(out)
if code, out = send(http.MethodPost, "/readings", map[string]any{"course_id": course["id"]}); code != http.StatusUnprocessableEntity || errorsOf(out)["title"] == nil {
t.Errorf("reading without a title = %d %v", code, out)
}
if code, out = send(http.MethodGet, "/readings?course_id="+id(course), nil); code != http.StatusOK || len(out["data"].([]any)) != 1 {
t.Errorf("readings of the course = %d %v", code, out)
}
if code, out = send(http.MethodGet, "/readings?course_id=999999", nil); code != http.StatusOK || len(out["data"].([]any)) != 0 {
t.Errorf("readings of another course = %d %v", code, out)
}
code, out = send(http.MethodPost, "/deadlines", map[string]any{"title": "Egzamin", "due_at": "2027-01-30T12:00:00+01:00", "kind": "egzamin", "course_id": course["id"]})
if code != http.StatusCreated {
t.Fatalf("create deadline = %d %v", code, out)
}
deadline := data(out)
if code, out = send(http.MethodPatch, "/deadlines/"+id(deadline), map[string]any{"due_at": "jutro"}); code != http.StatusUnprocessableEntity || errorsOf(out)["due_at"] == nil {
t.Errorf("deadline with a bad time = %d %v", code, out)
}
if code, out = send(http.MethodPatch, "/deadlines/"+id(deadline), map[string]any{"course_id": nil}); code != http.StatusOK || data(out)["course_id"] != nil {
t.Errorf("deadline detached from the course = %d %v", code, out)
}
for _, path := range []string{"/readings/" + id(reading), "/deadlines/" + id(deadline), "/class-sessions/" + id(sess)} {
if code, _ := send(http.MethodDelete, path, nil); code != http.StatusNoContent {
t.Errorf("delete %s = %d", path, code)
}
if code, _ := send(http.MethodGet, path, nil); code != http.StatusNotFound {
t.Errorf("get %s after delete = %d", path, code)
}
}
}

View File

@@ -118,7 +118,6 @@ func TestRoutes(t *testing.T) {
"POST /_bm/api/v1/calendar-feed/rotate",
"DELETE /_bm/api/v1/calendar-feed",
"GET /api/v1/bm/me",
"GET /api/v1/bm/courses",
"GET /api/v1/bm/materials",
"GET /api/v1/bm/materials/{id}",
"POST /api/v1/bm/materials",
@@ -126,6 +125,29 @@ func TestRoutes(t *testing.T) {
"DELETE /api/v1/bm/materials/{id}",
"POST /api/v1/bm/materials/{id}/files",
"DELETE /api/v1/bm/materials/{id}/files/{file}",
"GET /api/v1/bm/courses",
"GET /api/v1/bm/courses/{id}",
"POST /api/v1/bm/courses",
"PATCH /api/v1/bm/courses/{id}",
"GET /api/v1/bm/meetings",
"GET /api/v1/bm/meetings/{id}",
"POST /api/v1/bm/meetings",
"PATCH /api/v1/bm/meetings/{id}",
"GET /api/v1/bm/class-sessions",
"GET /api/v1/bm/class-sessions/{id}",
"POST /api/v1/bm/class-sessions",
"PATCH /api/v1/bm/class-sessions/{id}",
"DELETE /api/v1/bm/class-sessions/{id}",
"GET /api/v1/bm/readings",
"GET /api/v1/bm/readings/{id}",
"POST /api/v1/bm/readings",
"PATCH /api/v1/bm/readings/{id}",
"DELETE /api/v1/bm/readings/{id}",
"GET /api/v1/bm/deadlines",
"GET /api/v1/bm/deadlines/{id}",
"POST /api/v1/bm/deadlines",
"PATCH /api/v1/bm/deadlines/{id}",
"DELETE /api/v1/bm/deadlines/{id}",
"POST /api/v1/bm/seed",
}
var got []string

View File

@@ -14,6 +14,7 @@ import (
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/controllers"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gocloud.dev/blob"
"gorm.io/gorm"
)
@@ -115,7 +116,6 @@ func (p *Plugin) Routes(r pact.Router) error {
// user.permission checks the owner's current group permissions.
r.Group(ManagePrefix, surf.Use(bucketManage, "user.api_token"), func(g pact.Router) {
g.Get("/me", h.TokenInfo, "user.scope:read")
g.Get("/courses", h.ManagedCourses, "user.scope:read", "user.permission:"+controllers.PermCourses)
g.Get("/materials", h.ManagedMaterials, "user.scope:read", "user.permission:"+controllers.PermMaterials)
g.Get("/materials/{id}", h.ManagedMaterial, "user.scope:read", "user.permission:"+controllers.PermMaterials)
g.Where("id", "[0-9]+")
@@ -130,6 +130,24 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Delete("/materials/{id}/files/{file}", h.DetachMaterialFile, "user.scope:write", "user.permission:"+controllers.PermMaterials)
g.Where("id", "[0-9]+")
g.Where("file", "[0-9]+")
mountRecords(g, h, "/courses", controllers.PermCourses, api.Record[models.Course, *models.Course]{
Filters: []string{"semester", "form", "code"}, Order: "semester, sort_order, id",
}, false)
mountRecords(g, h, "/meetings", controllers.PermSchedule, api.Record[models.Meeting, *models.Meeting]{
Order: "number",
}, false)
mountRecords(g, h, "/class-sessions", controllers.PermSchedule, api.Record[models.ClassSession, *models.ClassSession]{
Filters: []string{"course_id", "meeting_id"}, Order: "starts_at, id",
References: map[string]string{"course_id": "jz_bm_courses", "meeting_id": "jz_bm_meetings"},
}, true)
mountRecords(g, h, "/readings", controllers.PermCourses, api.Record[models.Reading, *models.Reading]{
Filters: []string{"course_id", "is_required"}, Order: "course_id, sort_order, id",
References: map[string]string{"course_id": "jz_bm_courses"},
}, true)
mountRecords(g, h, "/deadlines", controllers.PermSchedule, api.Record[models.Deadline, *models.Deadline]{
Filters: []string{"course_id", "kind"}, Order: "due_at, id",
References: map[string]string{"course_id": "jz_bm_courses"},
}, true)
g.Post("/seed", h.ApplySeed, "user.scope:write",
"user.permission:"+controllers.PermCourses,
"user.permission:"+controllers.PermSchedule,
@@ -218,3 +236,31 @@ func (p *Plugin) handlers() *api.Handlers {
}
return h
}
// recordRoutes is what Record provides to mountRecords.
type recordRoutes interface {
List(*api.Handlers) http.HandlerFunc
Show(*api.Handlers) http.HandlerFunc
Create(*api.Handlers) http.HandlerFunc
Update(*api.Handlers) http.HandlerFunc
Delete(*api.Handlers) http.HandlerFunc
}
// mountRecords mounts the record routes of one table on the management API:
// GET list and one, POST, PATCH, and DELETE when deletable (courses and
// meetings are deleted only in the admin, because rows of other tables hang
// on them).
func mountRecords(g pact.Router, h *api.Handlers, path, perm string, rec recordRoutes, deletable bool) {
read := []string{"user.scope:read", "user.permission:" + perm}
write := []string{"user.scope:write", "user.permission:" + perm}
g.Get(path, rec.List(h), read...)
g.Get(path+"/{id}", rec.Show(h), read...)
g.Where("id", "[0-9]+")
g.Post(path, rec.Create(h), write...)
g.Patch(path+"/{id}", rec.Update(h), write...)
g.Where("id", "[0-9]+")
if deletable {
g.Delete(path+"/{id}", rec.Delete(h), write...)
g.Where("id", "[0-9]+")
}
}