From cf84f1d0e8654adc4acccf726b7a9d262745dd0b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 26 Oct 2026 11:11:22 +0100 Subject: [PATCH] feat: per-student owned flag on readings - new table jz_bm_reading_ownership and its migration 20261026120000_create_jz_bm_reading_ownership - PUT and DELETE /_bm/api/v1/readings/{id}/owned, idempotent - owned_at on every reading in /readings and /courses/{id} - read semantics, progress routes and counts unchanged --- README.md | 10 ++- controllers/api/handlers_test.go | 2 + controllers/api/json.go | 3 +- controllers/api/readings.go | 74 ++++++++++++++++++- lang/en/lang.yaml | 1 + lang/pl/lang.yaml | 1 + models/reading_ownership.go | 16 ++++ plugin_test.go | 10 ++- postgres_test.go | 2 +- registry.gen.go | 2 + routes.go | 6 +- ...26120000_create_jz_bm_reading_ownership.go | 29 ++++++++ 12 files changed, 142 insertions(+), 14 deletions(-) create mode 100644 models/reading_ownership.go create mode 100644 updates/20261026120000_create_jz_bm_reading_ownership.go diff --git a/README.md b/README.md index 1902b04..3101b95 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/W - A subscribable calendar feed (`/_bm/calendar/.ics`) of the class sessions, meetings and deadlines, see [Calendar feed](#calendar-feed). - Courses that share an `elective_group` are alternatives ("do wyboru"), such as the specialisation courses. - A material without `published_at` is a draft; a material whose `published_at` lies in the future is not shown yet. -- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress rows; deleting a user deletes the user's calendar feed. +- Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress and ownership rows; deleting a user deletes the user's calendar feed. - Admin screens for every model in a "Studia BM" navigation group, guarded by three permissions. - `bm:seed`: an idempotent upsert of meetings, courses, class sessions, deadlines and readings from a JSON file, all or nothing, with `--prune` to delete what the file no longer lists and `--dry-run` to preview. @@ -43,6 +43,8 @@ Every route except the calendar feed itself needs `Authorization: Bearer , …]}` by start time: the sessions that start from midnight of `from` (default today) until the end of `to` (default no limit), in Warsaw. `/meetings` answers `{"data": [, …]}` by number. `/deadlines` answers `{"data": [, …]}` by due time; without `from` it starts now, so it lists the deadlines not yet due. -### GET /readings, PUT and DELETE /readings/{id}/progress +### GET /readings, PUT and DELETE /readings/{id}/progress and /owned -`/readings` answers `{"data": [, …]}` in course and `sort_order` order; an unknown `course` gives an empty list. The progress routes answer: +`/readings` answers `{"data": [, …]}` in course and `sort_order` order; an unknown `course` gives an empty list. Each reading carries the caller's `read_at` and `owned_at`, `null` when unset; a course's readings in `/courses/{id}` carry the same pair. The progress routes answer: ```json {"data": {"reading_id": 7, "read_at": "2026-10-02T21:15:04+02:00"}} @@ -142,6 +144,8 @@ A course with counters (`readings_read` is the caller's; `materials` counts publ Marking a read reading again keeps the first `read_at`. Clearing answers `read_at: null`, also when the reading was not marked. +The owned routes record that the caller owns a copy of the reading, apart from whether it is read, and answer `{"data": {"reading_id": 7, "owned_at": "2026-10-26T12:00:00+01:00"}}` with the same rules: marking an owned reading again keeps the first `owned_at`, and clearing answers `owned_at: null`, also when the reading was not owned. + ### GET /materials, GET /materials/{id} ```json diff --git a/controllers/api/handlers_test.go b/controllers/api/handlers_test.go index 235eb96..0832e4d 100644 --- a/controllers/api/handlers_test.go +++ b/controllers/api/handlers_test.go @@ -47,6 +47,8 @@ func handlers(h *api.Handlers) map[string]http.HandlerFunc { "readings": h.Readings, "mark": h.MarkRead, "unmark": h.UnmarkRead, + "own": h.MarkOwned, + "unown": h.UnmarkOwned, "materials": h.Materials, "material": h.Material, "feed": h.CalendarFeed, diff --git a/controllers/api/json.go b/controllers/api/json.go index d8deb97..98eccf7 100644 --- a/controllers/api/json.go +++ b/controllers/api/json.go @@ -168,7 +168,7 @@ func newDeadlinesJSON(rows []models.Deadline) []deadlineJSON { return out } -// readingJSON is one reading with the caller's read state. +// readingJSON is one reading with the caller's read and owned state. type readingJSON struct { ID uint `json:"id"` CourseID uint `json:"course_id"` @@ -180,6 +180,7 @@ type readingJSON struct { IsRequired bool `json:"is_required"` SortOrder int `json:"sort_order"` ReadAt *string `json:"read_at"` + OwnedAt *string `json:"owned_at"` } // tagJSON is one material tag. diff --git a/controllers/api/readings.go b/controllers/api/readings.go index a1b44b3..cdf5749 100644 --- a/controllers/api/readings.go +++ b/controllers/api/readings.go @@ -9,19 +9,22 @@ import ( "gorm.io/gorm" ) -// readingRow is a reading joined with the caller's progress row. +// readingRow is a reading joined with the caller's progress and ownership +// rows. type readingRow struct { models.Reading `gorm:"embedded"` ReadAt *time.Time `gorm:"column:read_at"` + OwnedAt *time.Time `gorm:"column:owned_at"` } // listReadings returns the readings, optionally of one course, in course and -// reading order, each with the caller's read_at. +// reading order, each with the caller's read_at and owned_at. func listReadings(db *gorm.DB, userID, courseID uint) ([]readingJSON, error) { q := db.Table("jz_bm_readings AS r"). - Select("r.*, p.read_at"). + Select("r.*, p.read_at, o.owned_at"). Joins("JOIN jz_bm_courses c ON c.id = r.course_id"). - Joins("LEFT JOIN jz_bm_reading_progress p ON p.reading_id = r.id AND p.user_id = ?", userID) + Joins("LEFT JOIN jz_bm_reading_progress p ON p.reading_id = r.id AND p.user_id = ?", userID). + Joins("LEFT JOIN jz_bm_reading_ownership o ON o.reading_id = r.id AND o.user_id = ?", userID) if courseID != 0 { q = q.Where("r.course_id = ?", courseID) } @@ -50,6 +53,10 @@ func listReadings(db *gorm.DB, userID, courseID uint) ([]readingJSON, error) { s := models.FormatDateTime(*row.ReadAt) item.ReadAt = &s } + if row.OwnedAt != nil { + s := models.FormatDateTime(*row.OwnedAt) + item.OwnedAt = &s + } out = append(out, item) } return out, nil @@ -185,3 +192,62 @@ func (h *Handlers) UnmarkRead(w http.ResponseWriter, r *http.Request) { } writeData(w, progressJSON{ReadingID: readingID}) } + +// ownershipJSON is the answer of the owned routes. +type ownershipJSON struct { + ReadingID uint `json:"reading_id"` + OwnedAt *string `json:"owned_at"` +} + +// MarkOwned answers PUT /readings/{id}/owned: it marks the reading owned +// by the caller. Marking it again keeps the first owned_at. +func (h *Handlers) MarkOwned(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + readingID, ok := readingFor(w, r, db) + if !ok { + return + } + now := h.now().UTC() + err := db.Exec(`INSERT INTO jz_bm_reading_ownership (reading_id, user_id, owned_at) VALUES (?, ?, ?) +ON CONFLICT (reading_id, user_id) DO NOTHING`, readingID, userID, now).Error + if err != nil { + writeOpaque500(w) + return + } + var ownership models.ReadingOwnership + if err := db.Where("reading_id = ? AND user_id = ?", readingID, userID).Take(&ownership).Error; err != nil { + writeOpaque500(w) + return + } + ownedAt := models.FormatDateTime(ownership.OwnedAt) + writeData(w, ownershipJSON{ReadingID: readingID, OwnedAt: &ownedAt}) +} + +// UnmarkOwned answers DELETE /readings/{id}/owned: it clears the caller's +// owned mark. Clearing a reading that is not owned is not an error. +func (h *Handlers) UnmarkOwned(w http.ResponseWriter, r *http.Request) { + userID, ok := h.caller(w, r) + if !ok { + return + } + db, ok := h.db(w, r) + if !ok { + return + } + readingID, ok := readingFor(w, r, db) + if !ok { + return + } + if err := db.Where("reading_id = ? AND user_id = ?", readingID, userID).Delete(&models.ReadingOwnership{}).Error; err != nil { + writeOpaque500(w) + return + } + writeData(w, ownershipJSON{ReadingID: readingID}) +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index b32de3c..5f71fb8 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -78,6 +78,7 @@ readings: form: Reading author: Author is_required: Required + owned: Owned materials: title: Materials form: Material diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index bf3d12f..361e9e0 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -78,6 +78,7 @@ readings: form: Lektura author: Autor is_required: Obowiązkowa + owned: Mam materials: title: Materiały form: Materiał diff --git a/models/reading_ownership.go b/models/reading_ownership.go new file mode 100644 index 0000000..64823fe --- /dev/null +++ b/models/reading_ownership.go @@ -0,0 +1,16 @@ +package models + +import "time" + +// ReadingOwnership records that a user (a golem15.user users row) owns a +// copy of a reading. One row per reading and user, kept apart from the read +// mark in ReadingProgress. +type ReadingOwnership struct { + ID uint `gorm:"column:id;primaryKey"` + ReadingID uint `gorm:"column:reading_id"` + UserID uint `gorm:"column:user_id"` + OwnedAt time.Time `gorm:"column:owned_at"` +} + +// TableName is the plugin table. +func (ReadingOwnership) TableName() string { return "jz_bm_reading_ownership" } diff --git a/plugin_test.go b/plugin_test.go index 17bb48a..3feecc5 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -74,11 +74,11 @@ func TestPluginActivates(t *testing.T) { if p.ID() != "jz.bm" || !slices.Equal(p.Requires(), []string{"golem15.user"}) { t.Errorf("ID %q Requires %v", p.ID(), p.Requires()) } - if n := len(p.Models()); n != 10 { - t.Errorf("Models = %d, want 10", n) + if n := len(p.Models()); n != 11 { + t.Errorf("Models = %d, want 11", n) } - if n := len(p.Migrations()); n != 13 { - t.Errorf("Migrations = %d, want 13", n) + if n := len(p.Migrations()); n != 14 { + t.Errorf("Migrations = %d, want 14", n) } if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) { t.Error("bm:seed is not registered") @@ -111,6 +111,8 @@ func TestRoutes(t *testing.T) { "GET /_bm/api/v1/readings", "PUT /_bm/api/v1/readings/{id}/progress", "DELETE /_bm/api/v1/readings/{id}/progress", + "PUT /_bm/api/v1/readings/{id}/owned", + "DELETE /_bm/api/v1/readings/{id}/owned", "GET /_bm/api/v1/materials", "GET /_bm/api/v1/materials/{id}", "GET /_bm/api/v1/calendar-feed", diff --git a/postgres_test.go b/postgres_test.go index a1f293a..4e154d9 100644 --- a/postgres_test.go +++ b/postgres_test.go @@ -155,7 +155,7 @@ func migrated(t *testing.T) (*backpack.App, []party.Plugin, *gorm.DB) { var bmTables = []string{ "jz_bm_meetings", "jz_bm_courses", "jz_bm_class_sessions", "jz_bm_deadlines", "jz_bm_readings", - "jz_bm_reading_progress", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", "jz_bm_calendar_feeds", + "jz_bm_reading_progress", "jz_bm_reading_ownership", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", "jz_bm_calendar_feeds", } func TestMigrateUpAndRollback(t *testing.T) { diff --git a/registry.gen.go b/registry.gen.go index 51aa9f0..da1df19 100644 --- a/registry.gen.go +++ b/registry.gen.go @@ -22,6 +22,7 @@ func generatedModels() []any { &models.Meeting{}, &models.Reading{}, &models.ReadingProgress{}, + &models.ReadingOwnership{}, &models.Tag{}, } } @@ -41,6 +42,7 @@ func generatedMigrations() []*gormigrate.Migration { updates.CreateCalendarFeeds(), updates.MakeCourseIDNullable(), updates.AddLecturerToClassSessions(), + updates.CreateReadingOwnership(), } } diff --git a/routes.go b/routes.go index 387fa5f..f6cddcc 100644 --- a/routes.go +++ b/routes.go @@ -87,7 +87,7 @@ const ManagePrefix = "/api/v1/bm" // Routes declares the student API and the management API. Every student // route needs a golem15.user JWT (jwt.auth); students only read, except for -// their own reading progress. +// their own reading progress and owned marks. func (p *Plugin) Routes(r pact.Router) error { h := p.handlers() r.Group(APIPrefix, surf.Use("jwt.auth", "locale.from-principal"), func(g pact.Router) { @@ -103,6 +103,10 @@ func (p *Plugin) Routes(r pact.Router) error { g.Where("id", "[0-9]+") g.Delete("/readings/{id}/progress", h.UnmarkRead) g.Where("id", "[0-9]+") + g.Put("/readings/{id}/owned", h.MarkOwned) + g.Where("id", "[0-9]+") + g.Delete("/readings/{id}/owned", h.UnmarkOwned) + g.Where("id", "[0-9]+") g.Get("/materials", h.Materials) g.Get("/materials/{id}", h.Material) g.Where("id", "[0-9]+") diff --git a/updates/20261026120000_create_jz_bm_reading_ownership.go b/updates/20261026120000_create_jz_bm_reading_ownership.go new file mode 100644 index 0000000..c4a257b --- /dev/null +++ b/updates/20261026120000_create_jz_bm_reading_ownership.go @@ -0,0 +1,29 @@ +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// CreateReadingOwnership returns the +// 20261026120000_create_jz_bm_reading_ownership gormigrate entry: who owns a +// copy of which reading, apart from who has read it. The rows go with the +// reading or the golem15.user user. +func CreateReadingOwnership() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "20261026120000_create_jz_bm_reading_ownership", + Migrate: func(tx *gorm.DB) error { + return execAll(tx, `CREATE TABLE jz_bm_reading_ownership ( + id BIGSERIAL PRIMARY KEY, + reading_id BIGINT NOT NULL REFERENCES jz_bm_readings (id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + owned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (reading_id, user_id) +); +CREATE INDEX jz_bm_reading_ownership_user_id_index ON jz_bm_reading_ownership (user_id)`) + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS jz_bm_reading_ownership").Error + }, + } +}