feat: materials by zjazd in the student API and admin
The material admin form gets the "Na zjazd" picker, every student material JSON carries meeting_id, and GET /materials accepts meeting= inside the published-only scope, so the Pulpit hero can list the zjazd's materials next to its readings.
This commit is contained in:
14
README.md
14
README.md
@@ -45,7 +45,7 @@ Every route except the calendar feed itself needs `Authorization: Bearer <golem1
|
|||||||
| DELETE | `/_bm/api/v1/readings/{id}/progress` | | Clears the mark (idempotent) |
|
| DELETE | `/_bm/api/v1/readings/{id}/progress` | | Clears the mark (idempotent) |
|
||||||
| PUT | `/_bm/api/v1/readings/{id}/owned` | | Marks the reading owned (idempotent) |
|
| PUT | `/_bm/api/v1/readings/{id}/owned` | | Marks the reading owned (idempotent) |
|
||||||
| DELETE | `/_bm/api/v1/readings/{id}/owned` | | Clears the owned mark (idempotent) |
|
| DELETE | `/_bm/api/v1/readings/{id}/owned` | | Clears the owned mark (idempotent) |
|
||||||
| GET | `/_bm/api/v1/materials` | `course`, `tag` (slug), `type`, `page`, `per_page` (1–100) | One page of published materials |
|
| GET | `/_bm/api/v1/materials` | `course`, `meeting`, `tag` (slug), `type`, `page`, `per_page` (1–100) | One page of published materials |
|
||||||
| GET | `/_bm/api/v1/materials/{id}` | | One published material with body, tags and files |
|
| GET | `/_bm/api/v1/materials/{id}` | | One published material with body, tags and files |
|
||||||
| GET | `/_bm/api/v1/calendar-feed` | | The caller's calendar feed, or `null` |
|
| GET | `/_bm/api/v1/calendar-feed` | | The caller's calendar feed, or `null` |
|
||||||
| PUT | `/_bm/api/v1/calendar-feed` | | Creates the feed when missing, applies the settings in the body |
|
| PUT | `/_bm/api/v1/calendar-feed` | | Creates the feed when missing, applies the settings in the body |
|
||||||
@@ -83,17 +83,17 @@ A deadline (`kind` is one of `zaliczenie`, `egzamin`, `praca`, `organizacyjne`,
|
|||||||
{"id": 1, "title": "…", "due_at": "2026-10-05T23:59:00+02:00", "kind": "organizacyjne", "description": null, "course_id": null, "course": null}
|
{"id": 1, "title": "…", "due_at": "2026-10-05T23:59:00+02:00", "kind": "organizacyjne", "description": null, "course_id": null, "course": null}
|
||||||
```
|
```
|
||||||
|
|
||||||
A reading (`read_at` is the caller's, or null):
|
A reading (`read_at` is the caller's, or null; `meeting_id` is the zjazd the reading is meant for, null when none):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"id": 1, "course_id": 1, "course": {…}, "title": "…", "author": null, "source": null, "url": null,
|
{"id": 1, "course_id": 1, "course": {…}, "meeting_id": 4, "title": "…", "author": null, "source": null, "url": null,
|
||||||
"is_required": true, "sort_order": 1, "read_at": "2026-10-02T21:15:04+02:00"}
|
"is_required": true, "sort_order": 1, "read_at": "2026-10-02T21:15:04+02:00"}
|
||||||
```
|
```
|
||||||
|
|
||||||
A material in a list (`type` is one of `pdf`, `notatka`, `link`, `inne`; `files_count` counts public files):
|
A material in a list (`type` is one of `pdf`, `notatka`, `link`, `inne`; `files_count` counts public files; `meeting_id` is the zjazd the material is meant for, null when none):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"id": 1, "course_id": 1, "course": {…}, "title": "…", "type": "notatka", "source": null, "url": null,
|
{"id": 1, "course_id": 1, "course": {…}, "meeting_id": null, "title": "…", "type": "notatka", "source": null, "url": null,
|
||||||
"published_at": "2026-10-01T12:00:00+02:00", "tags": [{"id": 1, "name": "Geopolityka", "slug": "geopolityka"}], "files_count": 1}
|
"published_at": "2026-10-01T12:00:00+02:00", "tags": [{"id": 1, "name": "Geopolityka", "slug": "geopolityka"}], "files_count": 1}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ The owned routes record that the caller owns a copy of the reading, apart from w
|
|||||||
{"data": [<material>, …], "meta": {"current_page": 1, "last_page": 1, "per_page": 20, "total": 2}}
|
{"data": [<material>, …], "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
|
```json
|
||||||
{"data": {…material…, "body": "# …",
|
{"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 |
|
| `courses` | `Course` | courses | `semester`, `form`, `code` | semester, sort_order |
|
||||||
| `meetings` | `Meeting` | schedule | | number |
|
| `meetings` | `Meeting` | schedule | | number |
|
||||||
| `class-sessions` | `ClassSession` | schedule | `course_id`, `meeting_id` | starts_at |
|
| `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 |
|
| `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.
|
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.
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ func TestQueryValidation(t *testing.T) {
|
|||||||
{"materials page zero", h.Materials, "/?page=0", "page"},
|
{"materials page zero", h.Materials, "/?page=0", "page"},
|
||||||
{"materials per_page too large", h.Materials, "/?per_page=1000", "per_page"},
|
{"materials per_page too large", h.Materials, "/?per_page=1000", "per_page"},
|
||||||
{"materials course negative", h.Materials, "/?course=-1", "course"},
|
{"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"},
|
{"materials tag too long", h.Materials, "/?tag=" + longTag(), "tag"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -188,6 +189,7 @@ func TestValidQueriesReachTheDatabase(t *testing.T) {
|
|||||||
{h.Readings, "/?course=3"},
|
{h.Readings, "/?course=3"},
|
||||||
{h.Courses, "/?semester=2"},
|
{h.Courses, "/?semester=2"},
|
||||||
{h.Materials, "/?course=1&tag=geopolityka&type=pdf&page=2&per_page=10"},
|
{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 {
|
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)
|
t.Errorf("%s = %d %v, want to pass validation (503 without a database)", tc.target, code, body)
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ type materialListJSON struct {
|
|||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
CourseID *uint `json:"course_id"`
|
CourseID *uint `json:"course_id"`
|
||||||
Course *courseRef `json:"course"`
|
Course *courseRef `json:"course"`
|
||||||
|
MeetingID *uint `json:"meeting_id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Source *string `json:"source"`
|
Source *string `json:"source"`
|
||||||
@@ -218,6 +219,7 @@ func newMaterialListJSON(m models.Material, files int64) materialListJSON {
|
|||||||
ID: m.ID,
|
ID: m.ID,
|
||||||
CourseID: m.CourseID,
|
CourseID: m.CourseID,
|
||||||
Course: newCourseRef(m.Course),
|
Course: newCourseRef(m.Course),
|
||||||
|
MeetingID: m.MeetingID,
|
||||||
Title: m.Title,
|
Title: m.Title,
|
||||||
Type: m.Type,
|
Type: m.Type,
|
||||||
Source: m.Source,
|
Source: m.Source,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestReadingJSONCarriesMeetingID checks that meeting_id is always present
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,15 +21,19 @@ func published(q *gorm.DB, now time.Time) *gorm.DB {
|
|||||||
|
|
||||||
// materialFilter holds the validated filters of /materials.
|
// materialFilter holds the validated filters of /materials.
|
||||||
type materialFilter struct {
|
type materialFilter struct {
|
||||||
CourseID uint
|
CourseID uint
|
||||||
Tag string
|
MeetingID uint
|
||||||
Type string
|
Tag string
|
||||||
|
Type string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f materialFilter) apply(q *gorm.DB) *gorm.DB {
|
func (f materialFilter) apply(q *gorm.DB) *gorm.DB {
|
||||||
if f.CourseID != 0 {
|
if f.CourseID != 0 {
|
||||||
q = q.Where("jz_bm_materials.course_id = ?", f.CourseID)
|
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 != "" {
|
if f.Type != "" {
|
||||||
q = q.Where("jz_bm_materials.type = ?", 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
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Materials answers GET /materials?course=&tag=&type=&page=&per_page=: a
|
// Materials answers GET /materials?course=&meeting=&tag=&type=&page=&per_page=:
|
||||||
// page of published materials in the {data, meta} shape of lagoon.Paginate.
|
// a page of published materials in the {data, meta} shape of lagoon.Paginate.
|
||||||
func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
|
func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
|
||||||
if _, ok := h.caller(w, r); !ok {
|
if _, ok := h.caller(w, r); !ok {
|
||||||
return
|
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) {
|
if !h.validate(w, r, in, materialsRules) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -108,7 +112,7 @@ func (h *Handlers) Materials(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
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)
|
page := min(intParam(in, "page", 1), 100000)
|
||||||
perPage := intParam(in, "per_page", h.perPage())
|
perPage := intParam(in, "per_page", h.perPage())
|
||||||
rows, total, err := listMaterials(db, h.now(), filter, page, perPage)
|
rows, total, err := listMaterials(db, h.now(), filter, page, perPage)
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ var (
|
|||||||
}
|
}
|
||||||
materialsRules = []lagoon.RequestRule{
|
materialsRules = []lagoon.RequestRule{
|
||||||
{Field: "course", Rules: lagoon.ParseRules("nullable|integer|min:1")},
|
{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: "tag", Rules: lagoon.ParseRules("nullable|string|max:100")},
|
||||||
{Field: "type", Rules: append(lagoon.ParseRules("nullable"), lagoon.In(models.MaterialTypes...))},
|
{Field: "type", Rules: append(lagoon.ParseRules("nullable"), lagoon.In(models.MaterialTypes...))},
|
||||||
{Field: "page", Rules: lagoon.ParseRules("nullable|integer|min:1")},
|
{Field: "page", Rules: lagoon.ParseRules("nullable|integer|min:1")},
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ func (materialsAdmin) RequiredPermissions() []string {
|
|||||||
return []string{PermMaterials}
|
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.
|
// is bound by models.Material.AttachRelations.
|
||||||
func (materialsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
|
func (materialsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
|
||||||
return []cabana.FieldRelationContract{
|
return []cabana.FieldRelationContract{
|
||||||
courseRelation("course_id"),
|
courseRelation("course_id"),
|
||||||
|
meetingRelation(),
|
||||||
{
|
{
|
||||||
Field: "tags",
|
Field: "tags",
|
||||||
Kind: "belongsToMany",
|
Kind: "belongsToMany",
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type Material struct {
|
|||||||
ID uint `gorm:"column:id;primaryKey"`
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
CourseID *uint `gorm:"column:course_id"`
|
CourseID *uint `gorm:"column:course_id"`
|
||||||
Course *Course `gorm:"foreignKey:CourseID"`
|
Course *Course `gorm:"foreignKey:CourseID"`
|
||||||
|
MeetingID *uint `gorm:"column:meeting_id"`
|
||||||
|
Meeting *Meeting `gorm:"foreignKey:MeetingID"`
|
||||||
Title string `gorm:"column:title"`
|
Title string `gorm:"column:title"`
|
||||||
Type string `gorm:"column:type"`
|
Type string `gorm:"column:type"`
|
||||||
Body *string `gorm:"column:body"`
|
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.
|
// Fillable lists the columns mass assignment may set.
|
||||||
func (Material) Fillable() []string {
|
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.
|
// Rules are checked by the admin API on every save.
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ fields:
|
|||||||
label: jz.bm::lang.common.url
|
label: jz.bm::lang.common.url
|
||||||
type: text
|
type: text
|
||||||
span: right
|
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:
|
tags:
|
||||||
label: jz.bm::lang.materials.tags
|
label: jz.bm::lang.materials.tags
|
||||||
type: relation
|
type: relation
|
||||||
|
|||||||
Reference in New Issue
Block a user