Management API: record routes for courses, meetings, class sessions, readings and deadlines (Fillable + Rules + TemporalErrors, PATCH, filters)
This commit is contained in:
18
README.md
18
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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
375
controllers/api/manage_records.go
Normal file
375
controllers/api/manage_records.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
48
routes.go
48
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]+")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user