diff --git a/README.md b/README.md index 3101b95..78cf786 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Every route except the calendar feed itself needs `Authorization: Bearer , …], "meta": {"current_page": 1, "last_page": 1, "per_page": 20, "total": 2}} ``` -Newest publication first. The detail adds the Markdown body and the public files; `url` starts with `storage.uploads.public_path_prefix`: +Newest publication first. `meeting` keeps the materials assigned to that zjazd (meeting id); like every filter it applies to published materials only. The detail adds the Markdown body and the public files; `url` starts with `storage.uploads.public_path_prefix`: ```json {"data": {…material…, "body": "# …", @@ -217,7 +217,7 @@ The record tables (`controllers/api/manage_records.go`, mounted by `mountRecords | `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 | +| `readings` | `Reading` | courses | `course_id`, `meeting_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. diff --git a/controllers/api/handlers_test.go b/controllers/api/handlers_test.go index 086397b..8f754b6 100644 --- a/controllers/api/handlers_test.go +++ b/controllers/api/handlers_test.go @@ -148,6 +148,7 @@ func TestQueryValidation(t *testing.T) { {"materials page zero", h.Materials, "/?page=0", "page"}, {"materials per_page too large", h.Materials, "/?per_page=1000", "per_page"}, {"materials course negative", h.Materials, "/?course=-1", "course"}, + {"materials meeting zero", h.Materials, "/?meeting=0", "meeting"}, {"materials tag too long", h.Materials, "/?tag=" + longTag(), "tag"}, } for _, tc := range cases { @@ -188,6 +189,7 @@ func TestValidQueriesReachTheDatabase(t *testing.T) { {h.Readings, "/?course=3"}, {h.Courses, "/?semester=2"}, {h.Materials, "/?course=1&tag=geopolityka&type=pdf&page=2&per_page=10"}, + {h.Materials, "/?meeting=4&per_page=100"}, } { if code, body := serve(t, tc.fn, http.MethodGet, tc.target, user, nil); code != http.StatusServiceUnavailable { t.Errorf("%s = %d %v, want to pass validation (503 without a database)", tc.target, code, body) diff --git a/controllers/api/json.go b/controllers/api/json.go index 79a143f..c0e6136 100644 --- a/controllers/api/json.go +++ b/controllers/api/json.go @@ -204,6 +204,7 @@ type materialListJSON struct { ID uint `json:"id"` CourseID *uint `json:"course_id"` Course *courseRef `json:"course"` + MeetingID *uint `json:"meeting_id"` Title string `json:"title"` Type string `json:"type"` Source *string `json:"source"` @@ -218,6 +219,7 @@ func newMaterialListJSON(m models.Material, files int64) materialListJSON { ID: m.ID, CourseID: m.CourseID, Course: newCourseRef(m.Course), + MeetingID: m.MeetingID, Title: m.Title, Type: m.Type, Source: m.Source, diff --git a/controllers/api/json_test.go b/controllers/api/json_test.go index 5aee156..e2d3a82 100644 --- a/controllers/api/json_test.go +++ b/controllers/api/json_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "strings" "testing" + + "git.golem15.com/jakub/sm-bm-plugin/models" ) // TestReadingJSONCarriesMeetingID checks that meeting_id is always present @@ -26,3 +28,24 @@ func TestReadingJSONCarriesMeetingID(t *testing.T) { } } } + +// TestMaterialJSONCarriesMeetingID checks that meeting_id is always present +// in a material, as a number or as null. +func TestMaterialJSONCarriesMeetingID(t *testing.T) { + id := uint(4) + for _, tc := range []struct { + meeting *uint + want string + }{ + {&id, `"meeting_id":4`}, + {nil, `"meeting_id":null`}, + } { + b, err := json.Marshal(newMaterialListJSON(models.Material{ID: 1, Title: "Materiał", Type: "pdf", MeetingID: tc.meeting}, 0)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), tc.want) { + t.Errorf("material JSON %s does not contain %s", b, tc.want) + } + } +} diff --git a/controllers/api/materials.go b/controllers/api/materials.go index 722a12a..dfcd15b 100644 --- a/controllers/api/materials.go +++ b/controllers/api/materials.go @@ -21,15 +21,19 @@ func published(q *gorm.DB, now time.Time) *gorm.DB { // materialFilter holds the validated filters of /materials. type materialFilter struct { - CourseID uint - Tag string - Type string + 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) } @@ -94,13 +98,13 @@ func materialsJSON(db *gorm.DB, rows []models.Material) ([]materialListJSON, err return out, nil } -// Materials answers GET /materials?course=&tag=&type=&page=&per_page=: a -// page of published materials in the {data, meta} shape of lagoon.Paginate. +// Materials answers GET /materials?course=&meeting=&tag=&type=&page=&per_page=: +// a page of published materials in the {data, meta} shape of lagoon.Paginate. func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) { if _, ok := h.caller(w, r); !ok { return } - in := query(r, "course", "tag", "type", "page", "per_page") + in := query(r, "course", "meeting", "tag", "type", "page", "per_page") if !h.validate(w, r, in, materialsRules) { return } @@ -108,7 +112,7 @@ func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) { if !ok { return } - filter := materialFilter{CourseID: uintParam(in, "course"), Tag: strings.TrimSpace(str(in, "tag")), Type: str(in, "type")} + filter := materialFilter{CourseID: uintParam(in, "course"), MeetingID: uintParam(in, "meeting"), Tag: strings.TrimSpace(str(in, "tag")), Type: str(in, "type")} page := min(intParam(in, "page", 1), 100000) perPage := intParam(in, "per_page", h.perPage()) rows, total, err := listMaterials(db, h.now(), filter, page, perPage) diff --git a/controllers/api/params.go b/controllers/api/params.go index 43e1ed9..34c95a2 100644 --- a/controllers/api/params.go +++ b/controllers/api/params.go @@ -32,6 +32,7 @@ var ( } materialsRules = []lagoon.RequestRule{ {Field: "course", Rules: lagoon.ParseRules("nullable|integer|min:1")}, + {Field: "meeting", Rules: lagoon.ParseRules("nullable|integer|min:1")}, {Field: "tag", Rules: lagoon.ParseRules("nullable|string|max:100")}, {Field: "type", Rules: append(lagoon.ParseRules("nullable"), lagoon.In(models.MaterialTypes...))}, {Field: "page", Rules: lagoon.ParseRules("nullable|integer|min:1")}, diff --git a/controllers/materials.go b/controllers/materials.go index 69b8bc5..d995bab 100644 --- a/controllers/materials.go +++ b/controllers/materials.go @@ -29,11 +29,12 @@ func (materialsAdmin) RequiredPermissions() []string { return []string{PermMaterials} } -// AdminFieldRelations binds the course picker and the tags. The files field +// AdminFieldRelations binds the course and meeting pickers and the tags. The files field // is bound by models.Material.AttachRelations. func (materialsAdmin) AdminFieldRelations() []cabana.FieldRelationContract { return []cabana.FieldRelationContract{ courseRelation("course_id"), + meetingRelation(), { Field: "tags", Kind: "belongsToMany", diff --git a/models/material.go b/models/material.go index b9802c6..162eb04 100644 --- a/models/material.go +++ b/models/material.go @@ -23,6 +23,8 @@ type Material struct { ID uint `gorm:"column:id;primaryKey"` CourseID *uint `gorm:"column:course_id"` Course *Course `gorm:"foreignKey:CourseID"` + MeetingID *uint `gorm:"column:meeting_id"` + Meeting *Meeting `gorm:"foreignKey:MeetingID"` Title string `gorm:"column:title"` Type string `gorm:"column:type"` Body *string `gorm:"column:body"` @@ -43,7 +45,7 @@ func (Material) MorphName() string { return `Jz\Bm\Models\Material` } // Fillable lists the columns mass assignment may set. func (Material) Fillable() []string { - return []string{"course_id", "title", "type", "body", "source", "url", "published_at"} + return []string{"course_id", "meeting_id", "title", "type", "body", "source", "url", "published_at"} } // Rules are checked by the admin API on every save. diff --git a/models/materials/fields.yaml b/models/materials/fields.yaml index 3f0c2af..3e45a89 100644 --- a/models/materials/fields.yaml +++ b/models/materials/fields.yaml @@ -36,6 +36,13 @@ fields: label: jz.bm::lang.common.url type: text span: right + meeting: + label: jz.bm::lang.common.meeting + type: relation + nameFrom: number + span: left + emptyOption: jz.bm::lang.common.none + comment: jz.bm::lang.common.meeting_comment tags: label: jz.bm::lang.materials.tags type: relation