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
This commit is contained in:
10
README.md
10
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/<token>.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 <golem1
|
||||
| GET | `/_bm/api/v1/readings` | `course` (ID) | Readings with the caller's `read_at` |
|
||||
| PUT | `/_bm/api/v1/readings/{id}/progress` | | Marks the reading read (idempotent) |
|
||||
| DELETE | `/_bm/api/v1/readings/{id}/progress` | | Clears the mark (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) |
|
||||
| GET | `/_bm/api/v1/materials` | `course`, `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/calendar-feed` | | The caller's calendar feed, or `null` |
|
||||
@@ -132,9 +134,9 @@ A course with counters (`readings_read` is the caller's; `materials` counts publ
|
||||
|
||||
`/schedule` answers `{"data": [<session>, …]}` 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": [<meeting>, …]}` by number. `/deadlines` answers `{"data": [<deadline>, …]}` 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": [<reading>, …]}` in course and `sort_order` order; an unknown `course` gives an empty list. The progress routes answer:
|
||||
`/readings` answers `{"data": [<reading>, …]}` 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ readings:
|
||||
form: Reading
|
||||
author: Author
|
||||
is_required: Required
|
||||
owned: Owned
|
||||
materials:
|
||||
title: Materials
|
||||
form: Material
|
||||
|
||||
@@ -78,6 +78,7 @@ readings:
|
||||
form: Lektura
|
||||
author: Autor
|
||||
is_required: Obowiązkowa
|
||||
owned: Mam
|
||||
materials:
|
||||
title: Materiały
|
||||
form: Materiał
|
||||
|
||||
16
models/reading_ownership.go
Normal file
16
models/reading_ownership.go
Normal file
@@ -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" }
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]+")
|
||||
|
||||
29
updates/20261026120000_create_jz_bm_reading_ownership.go
Normal file
29
updates/20261026120000_create_jz_bm_reading_ownership.go
Normal file
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user