From 2536bcb95948b03fd9ecb024fc9fe6361b876f92 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 17 Oct 2026 18:05:39 +0200 Subject: [PATCH] Management API: record routes for courses, meetings, class sessions, readings and deadlines (Fillable + Rules + TemporalErrors, PATCH, filters) --- README.md | 18 +- controllers/api/manage.go | 19 -- controllers/api/manage_records.go | 375 ++++++++++++++++++++++++++++++ management_token_test.go | 156 +++++++++++++ plugin_test.go | 24 +- routes.go | 48 +++- 6 files changed, 618 insertions(+), 22 deletions(-) create mode 100644 controllers/api/manage_records.go diff --git a/README.md b/README.md index be1c071..1902b04 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/controllers/api/manage.go b/controllers/api/manage.go index 458ff50..08d6eee 100644 --- a/controllers/api/manage.go +++ b/controllers/api/manage.go @@ -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) { diff --git a/controllers/api/manage_records.go b/controllers/api/manage_records.go new file mode 100644 index 0000000..bb6d902 --- /dev/null +++ b/controllers/api/manage_records.go @@ -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 +} diff --git a/management_token_test.go b/management_token_test.go index 43935b6..bacf86f 100644 --- a/management_token_test.go +++ b/management_token_test.go @@ -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) + } + } +} diff --git a/plugin_test.go b/plugin_test.go index a94699b..17bb48a 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -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 diff --git a/routes.go b/routes.go index 189f89e..387fa5f 100644 --- a/routes.go +++ b/routes.go @@ -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]+") + } +}