feat: zjazd (meeting) on readings and materials

The Pulpit hero lists what to read for the current or next zjazd, so
readings and materials get a nullable meeting_id (FK to jz_bm_meetings,
ON DELETE SET NULL, indexed). The reading admin form gets a "Na zjazd"
picker, the student reading JSON carries meeting_id, and the management
/readings records filter and validate it.
This commit is contained in:
Jakub Zych
2026-10-27 01:41:48 +01:00
parent 8843e73f98
commit 3241cc9894
15 changed files with 94 additions and 15 deletions

View File

@@ -173,6 +173,7 @@ type readingJSON struct {
ID uint `json:"id"`
CourseID uint `json:"course_id"`
Course *courseRef `json:"course"`
MeetingID *uint `json:"meeting_id"`
Title string `json:"title"`
Author *string `json:"author"`
Source *string `json:"source"`

View File

@@ -0,0 +1,28 @@
package api
import (
"encoding/json"
"strings"
"testing"
)
// TestReadingJSONCarriesMeetingID checks that meeting_id is always present
// in a reading, as a number or as null.
func TestReadingJSONCarriesMeetingID(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(readingJSON{ID: 1, Title: "Lektura", MeetingID: tc.meeting})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(b), tc.want) {
t.Errorf("reading JSON %s does not contain %s", b, tc.want)
}
}
}

View File

@@ -42,6 +42,7 @@ func listReadings(db *gorm.DB, userID, courseID uint) ([]readingJSON, error) {
ID: row.ID,
CourseID: idOf(row.CourseID),
Course: newCourseRef(courses[idOf(row.CourseID)]),
MeetingID: row.MeetingID,
Title: row.Title,
Author: row.Author,
Source: row.Source,

View File

@@ -33,13 +33,7 @@ func (classSessionsAdmin) RequiredPermissions() []string {
func (classSessionsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{
courseRelation("course_id"),
{
Field: "meeting",
Kind: "belongsTo",
NewRelated: func() any { return &models.Meeting{} },
ForeignKey: "meeting_id",
LabelColumn: "number",
},
meetingRelation(),
}
}

View File

@@ -54,3 +54,14 @@ func courseRelation(foreignKey string) cabana.FieldRelationContract {
LabelColumn: "name",
}
}
// meetingRelation is the belongsTo picker of a meeting_id column (zjazd).
func meetingRelation() cabana.FieldRelationContract {
return cabana.FieldRelationContract{
Field: "meeting",
Kind: "belongsTo",
NewRelated: func() any { return &models.Meeting{} },
ForeignKey: "meeting_id",
LabelColumn: "number",
}
}

View File

@@ -29,9 +29,9 @@ func (readingsAdmin) RequiredPermissions() []string {
return []string{PermCourses}
}
// AdminFieldRelations binds the course picker.
// AdminFieldRelations binds the course and meeting pickers.
func (readingsAdmin) AdminFieldRelations() []cabana.FieldRelationContract {
return []cabana.FieldRelationContract{courseRelation("course_id")}
return []cabana.FieldRelationContract{courseRelation("course_id"), meetingRelation()}
}
// ReadingsController returns the jz.bm.readings admin controller.

View File

@@ -23,6 +23,8 @@ common:
sort_order: Sort order
created_at: Created
none: — none —
meeting: For meeting
meeting_comment: The dashboard suggests this item before that meeting.
forms:
wyklad: Lecture
cwiczenia: Tutorial

View File

@@ -23,6 +23,8 @@ common:
sort_order: Kolejność
created_at: Utworzono
none: — brak —
meeting: Na zjazd
meeting_comment: Pulpit poleca tę pozycję przed tym zjazdem.
forms:
wyklad: Wykład
cwiczenia: Ćwiczenia

View File

@@ -13,6 +13,8 @@ type Reading 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"`
Author *string `gorm:"column:author"`
Source *string `gorm:"column:source"`
@@ -28,7 +30,7 @@ func (Reading) TableName() string { return "jz_bm_readings" }
// Fillable lists the columns mass assignment may set.
func (Reading) Fillable() []string {
return []string{"course_id", "title", "author", "source", "url", "is_required", "sort_order"}
return []string{"course_id", "meeting_id", "title", "author", "source", "url", "is_required", "sort_order"}
}
// Rules are checked by the admin API on every save.

View File

@@ -31,3 +31,10 @@ fields:
type: number
span: left
default: 0
meeting:
label: jz.bm::lang.common.meeting
type: relation
nameFrom: number
span: right
emptyOption: jz.bm::lang.common.none
comment: jz.bm::lang.common.meeting_comment

View File

@@ -1,5 +1,6 @@
# The reading form inside the course form's relation manager: the course is
# the parent, so there is no course picker.
# the parent, so there is no course picker, and a relation form has no
# relation pickers, so the meeting (zjazd) is set on the readings screen.
fields:
title:
label: jz.bm::lang.common.title

View File

@@ -77,8 +77,8 @@ func TestPluginActivates(t *testing.T) {
if n := len(p.Models()); n != 11 {
t.Errorf("Models = %d, want 11", n)
}
if n := len(p.Migrations()); n != 15 {
t.Errorf("Migrations = %d, want 15", n)
if n := len(p.Migrations()); n != 16 {
t.Errorf("Migrations = %d, want 16", n)
}
if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) {
t.Error("bm:seed is not registered")

View File

@@ -44,6 +44,7 @@ func generatedMigrations() []*gormigrate.Migration {
updates.AddLecturerToClassSessions(),
updates.CreateReadingOwnership(),
updates.SeedChatGroups(),
updates.AddMeetingToReadingsAndMaterials(),
}
}

View File

@@ -166,8 +166,8 @@ func (p *Plugin) Routes(r pact.Router) error {
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"},
Filters: []string{"course_id", "meeting_id", "is_required"}, Order: "course_id, sort_order, id",
References: map[string]string{"course_id": "jz_bm_courses", "meeting_id": "jz_bm_meetings"},
}, true)
mountRecords(g, h, "/deadlines", controllers.PermSchedule, api.Record[models.Deadline, *models.Deadline]{
Filters: []string{"course_id", "kind"}, Order: "due_at, id",

View File

@@ -0,0 +1,29 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddMeetingToReadingsAndMaterials returns the
// 20261027120000_add_meeting_to_readings_and_materials gormigrate entry: a
// reading or a material may be assigned to the zjazd (meeting) it should be
// read for, so the dashboard can suggest it before that zjazd. Deleting the
// meeting keeps the rows and clears the assignment.
func AddMeetingToReadingsAndMaterials() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20261027120000_add_meeting_to_readings_and_materials",
Migrate: func(tx *gorm.DB) error {
return execAll(tx, `
ALTER TABLE jz_bm_readings ADD COLUMN meeting_id BIGINT NULL REFERENCES jz_bm_meetings (id) ON DELETE SET NULL;
CREATE INDEX jz_bm_readings_meeting_id_index ON jz_bm_readings (meeting_id);
ALTER TABLE jz_bm_materials ADD COLUMN meeting_id BIGINT NULL REFERENCES jz_bm_meetings (id) ON DELETE SET NULL;
CREATE INDEX jz_bm_materials_meeting_id_index ON jz_bm_materials (meeting_id)`)
},
Rollback: func(tx *gorm.DB) error {
return execAll(tx, `
ALTER TABLE jz_bm_readings DROP COLUMN IF EXISTS meeting_id;
ALTER TABLE jz_bm_materials DROP COLUMN IF EXISTS meeting_id`)
},
}
}