feat: private checklists for zjazd preparation

Per-user checklists with items that may link a course, reading, deadline,
material or own presentation deck. An item with a reading shares the
reading's read mark. Student API routes with token twins, MCP tools,
migrations, tests and README.
This commit is contained in:
Jakub Zych
2026-10-30 12:41:04 +01:00
parent 643f8cbf7c
commit d7cd5baedf
15 changed files with 1262 additions and 21 deletions

View File

@@ -16,11 +16,11 @@ Timestamps are stored in UTC (`TIMESTAMPTZ`) and written in RFC 3339 in Europe/W
## Features
- Models: `Course` (przedmiot, one row per course and form), `Meeting` (zjazd), `ClassSession` (zajęcia), `Deadline` (terminarz), `Reading` (lektura), `ReadingProgress`, `Material` with file attachments (`system_files`, field `files`) and `Tag`, many-to-many through `jz_bm_material_tag`, and `CalendarFeed` (one per user).
- Models: `Course` (przedmiot, one row per course and form), `Meeting` (zjazd), `ClassSession` (zajęcia), `Deadline` (terminarz), `Reading` (lektura), `ReadingProgress`, `Material` with file attachments (`system_files`, field `files`) and `Tag`, many-to-many through `jz_bm_material_tag`, `CalendarFeed` (one per user), and `Checklist` with `ChecklistItem` (each user's own checklists, e.g. the preparation for a zjazd).
- 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 and ownership 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 and checklists; deleting a checklist deletes its items; deleting a meeting, course, reading, deadline or material unlinks the checklists and items that point at it.
- 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.
@@ -47,6 +47,16 @@ Every route except the calendar feed itself needs `Authorization: Bearer <golem1
| DELETE | `/_bm/api/v1/readings/{id}/owned` | | Clears the owned mark (idempotent) |
| 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/checklists` | `meeting` (ID) | The caller's checklists with progress, without items |
| POST | `/_bm/api/v1/checklists` | | Creates a checklist for the caller; 201 |
| GET | `/_bm/api/v1/checklists/{id}` | | One of the caller's checklists with progress and items |
| PATCH | `/_bm/api/v1/checklists/{id}` | | Changes the sent fields |
| DELETE | `/_bm/api/v1/checklists/{id}` | | Deletes it with its items; 204 |
| POST | `/_bm/api/v1/checklists/{id}/items` | | Adds an item, last unless `sort_order` is sent; 201 |
| PATCH | `/_bm/api/v1/checklist-items/{id}` | | Changes the sent fields of an item |
| DELETE | `/_bm/api/v1/checklist-items/{id}` | | Deletes an item; 204 |
| PUT | `/_bm/api/v1/checklist-items/{id}/done` | | Marks the item done (idempotent) |
| DELETE | `/_bm/api/v1/checklist-items/{id}/done` | | Clears the done mark (idempotent) |
| 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 |
| POST | `/_bm/api/v1/calendar-feed/rotate` | | A new token (the old URL stops working); 404 without a feed; `throttle:10,1` |
@@ -146,6 +156,20 @@ Marking a read reading again keeps the first `read_at`. Clearing answers `read_a
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.
### Checklists
Checklists are private: every route reaches only the caller's own checklists and items, and another user's id answers 404 like a missing one. A checklist has `meeting_id` (optional zjazd), `title`, `description` and `sort_order`; it is answered with `meeting` (`id`, `number`, `starts_on`, `ends_on`) and `progress`:
```json
{"total": 4, "done": 1, "percent": 25, "minutes_total": 185, "minutes_done": 40, "minutes_left": 145}
```
An item has `kind` (`lektura`, `artykul`, `ksiazka`, `prezentacja`, `zadanie`, `inne`; default `zadanie`), `title`, `notes`, `url`, `due_at`, `estimate_minutes` (0–10000), `sort_order`, optional links `course_id`, `reading_id`, `deadline_id`, `material_id` (each must exist) and `presentation_slug` (must name one of the caller's decks in `golem15.presentations`, read by table name; the plugin is not required). `checklist_id` moves an item to another of the caller's checklists. It is answered with `done`, `done_at` and the linked rows (`course`, `reading`, `deadline`, `material` (published only), `presentation`), `null` when unset or gone.
**Readings share the mark.** An item with a `reading_id` has no done mark of its own: it is done exactly when the caller has read that reading. Marking it done inserts the `jz_bm_reading_progress` row and clearing it deletes the row, so `/readings` and every checklist with that reading agree. Deleting an item or a checklist leaves reading marks alone.
Bodies are JSON objects of columns checked like the management API: keys outside the writable columns, rule failures and unknown foreign keys answer 422 with errors per field.
### GET /materials, GET /materials/{id}
```json
@@ -251,12 +275,14 @@ The route is throttled per client IP (`throttle:jz.bm.upload-ip`, 10 a minute) a
`jz.bm` implements `pact.HasMCP`. With the framework's MCP endpoint (`pier`, the `summercms_mcp` build tag) its tools are offered at `/mcp` as `bm_<name>`, to OAuth sessions and personal tokens of `golem15.user` (guard `user`). Every tool binds a token-guarded route, every description is static text naming the scope it needs (and, for management tools, the permission code), and no tool has a `dry_run`. `jz.bm` still requires only `golem15.user`.
**Token twins of the student API.** `/_bm/api/v1/token/*` (`TokenPrefix`) mounts the student routes a second time, from the same `mountStudent` helper as the `jwt.auth` group, behind `user.api_token`, the per-user bucket `throttle:jz.bm.token` (240 a minute) and `locale.from-principal`, with `user.scope:read` on the reads and `user.scope:write` on the four reading marks. The handlers read `bouncer.User`, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. `TestTokenMirrorRoutes` fails when a student route is added to one surface only.
**Token twins of the student API.** `/_bm/api/v1/token/*` (`TokenPrefix`) mounts the student routes a second time, from the same `mountStudent` helper as the `jwt.auth` group, behind `user.api_token`, the per-user bucket `throttle:jz.bm.token` (240 a minute) and `locale.from-principal`, with `user.scope:read` on the reads and `user.scope:write` on the reading marks and the checklist writes. The handlers read `bouncer.User`, so a personal token or an MCP session reaches only its own user's data. The calendar feed and the chat have no twin. `TestTokenMirrorRoutes` fails when a student route is added to one surface only.
| Tool | Route | Scope |
|------|-------|-------|
| `bm_dashboard`, `bm_courses`, `bm_course`, `bm_schedule`, `bm_meetings`, `bm_deadlines`, `bm_readings`, `bm_materials`, `bm_material` | `GET /_bm/api/v1/token/…` | read |
| `bm_mark_reading_read`, `bm_unmark_reading_read`, `bm_mark_reading_owned`, `bm_unmark_reading_owned` | `PUT` / `DELETE /_bm/api/v1/token/readings/{id}/progress` and `/owned` | write |
| `bm_checklists`, `bm_checklist` | `GET /_bm/api/v1/token/checklists[/{id}]` | read |
| `bm_create_checklist`, `bm_update_checklist`, `bm_delete_checklist`, `bm_add_checklist_item`, `bm_update_checklist_item`, `bm_delete_checklist_item`, `bm_mark_checklist_item_done`, `bm_unmark_checklist_item_done` | `POST` / `PATCH` / `PUT` / `DELETE /_bm/api/v1/token/checklists…` and `/checklist-items…` | write |
**Management tools** bind the existing `/api/v1/bm` routes, whose `user.scope:*` and `user.permission:jz.bm.access_*` middleware filter the listing: a user whose groups lack the codes never sees them, and write tools need the write grant. Each input mirrors the model's `Fillable()` columns and `Rules()` (required columns are required on create, every update field is optional and nullable); enum values, lengths and date and time formats are in the field descriptions, and the routes still answer 422.

126
checklists_postgres_test.go Normal file
View File

@@ -0,0 +1,126 @@
package bm_test
import (
"encoding/json"
"net/http"
"testing"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
// want is apiClient.send that fails unless the status is code.
func (c apiClient) want(code int, method, path, body string) map[string]any {
c.t.Helper()
got, out := c.send(method, path, body)
if got != code {
c.t.Fatalf("%s %s = %d %v, want %d", method, path, got, out, code)
}
return out
}
func TestChecklistsAgainstDatabase(t *testing.T) {
app, plugins, gdb := migrated(t)
seedFixture(t, plugins, "testdata/seed.example.json")
h, err := surf.Assemble(app, plugins)
if err != nil {
t.Fatalf("Assemble: %v", err)
}
aliceID := createUser(t, gdb, "alice@example.test")
alice := apiClient{t: t, h: h, token: tokenFor(t, aliceID)}
bob := apiClient{t: t, h: h, token: tokenFor(t, createUser(t, gdb, "bob@example.test"))}
const base = "/_bm/api/v1"
var meeting models.Meeting
gdb.Order("number").Take(&meeting)
var reading models.Reading
gdb.Where("course_id IS NOT NULL").Order("id").Take(&reading)
// Create, with validation of the zjazd and unknown fields.
alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists", `{"title":"X","meeting_id":99999}`)
alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists", `{"title":"X","user_id":1}`)
alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists", `{}`)
created := obj(alice.want(http.StatusCreated, "POST", base+"/checklists",
`{"title":"Zjazd 1","meeting_id":`+jsonID(meeting.ID)+`}`)["data"])
cid := id(created["id"])
if obj(created["meeting"])["number"] != float64(meeting.Number) {
t.Errorf("meeting = %v", created["meeting"])
}
// Items: a task with an estimate and an item with a reading.
alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists/"+cid+"/items", `{"title":"X","kind":"film"}`)
alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists/"+cid+"/items", `{"title":"X","presentation_slug":"nie-ma"}`)
alice.want(http.StatusUnprocessableEntity, "POST", base+"/checklists/"+cid+"/items", `{"title":"X","estimate_minutes":-5}`)
task := obj(alice.want(http.StatusCreated, "POST", base+"/checklists/"+cid+"/items",
`{"title":"Slajdy","estimate_minutes":60,"due_at":"2026-11-07 20:00"}`)["data"])
if task["kind"] != "zadanie" || task["done"] != false {
t.Errorf("task = %v", task)
}
lektura := obj(alice.want(http.StatusCreated, "POST", base+"/checklists/"+cid+"/items",
`{"title":"Lektura","kind":"lektura","reading_id":`+jsonID(reading.ID)+`,"estimate_minutes":30}`)["data"])
if lektura["sort_order"].(float64) <= task["sort_order"].(float64) {
t.Errorf("new item not last: %v after %v", lektura["sort_order"], task["sort_order"])
}
if obj(lektura["reading"])["title"] != reading.Title {
t.Errorf("reading = %v", lektura["reading"])
}
// Bob sees none of it and cannot touch it.
if got := list(bob.ok("GET", base+"/checklists")); len(got) != 0 {
t.Errorf("bob sees %v", got)
}
bob.want(http.StatusNotFound, "GET", base+"/checklists/"+cid, "")
bob.want(http.StatusNotFound, "PATCH", base+"/checklists/"+cid, `{"title":"Mine"}`)
bob.want(http.StatusNotFound, "POST", base+"/checklists/"+cid+"/items", `{"title":"X"}`)
bob.want(http.StatusNotFound, "PUT", base+"/checklist-items/"+id(task["id"])+"/done", "")
bob.want(http.StatusNotFound, "DELETE", base+"/checklist-items/"+id(task["id"]), "")
bobList := obj(bob.want(http.StatusCreated, "POST", base+"/checklists", `{"title":"Bob"}`)["data"])
alice.want(http.StatusUnprocessableEntity, "PATCH", base+"/checklist-items/"+id(task["id"]), `{"checklist_id":`+id(bobList["id"])+`}`)
// Done marks: a task gets done_at, an item with a reading marks the
// reading read, and a reading read in the reading list shows done.
done := obj(alice.ok("PUT", base+"/checklist-items/"+id(task["id"])+"/done")["data"])
if done["done"] != true || done["done_at"] == nil {
t.Errorf("task done = %v", done)
}
alice.ok("PUT", base+"/checklist-items/"+id(lektura["id"])+"/done")
var n int64
gdb.Model(&models.ReadingProgress{}).Where("reading_id = ? AND user_id = ?", reading.ID, aliceID).Count(&n)
if n != 1 {
t.Errorf("reading progress rows = %d, want 1", n)
}
detail := obj(alice.ok("GET", base+"/checklists/"+cid)["data"])
progress := obj(detail["progress"])
if progress["total"] != 2.0 || progress["done"] != 2.0 || progress["percent"] != 100.0 || progress["minutes_left"] != 0.0 || progress["minutes_total"] != 90.0 {
t.Errorf("progress = %v", progress)
}
alice.ok("DELETE", "/_bm/api/v1/readings/"+jsonID(reading.ID)+"/progress")
summary := list(alice.ok("GET", base+"/checklists?meeting="+jsonID(meeting.ID)))
if len(summary) != 1 || obj(summary[0]["progress"])["done"] != 1.0 || obj(summary[0]["progress"])["minutes_left"] != 30.0 {
t.Errorf("after unread: %v", summary)
}
if summary[0]["items"] != nil {
t.Errorf("list carries items: %v", summary[0]["items"])
}
undone := obj(alice.ok("DELETE", base+"/checklist-items/"+id(task["id"])+"/done")["data"])
if undone["done"] != false {
t.Errorf("task undone = %v", undone)
}
// Patch, the token twin, and delete with cascade.
patched := obj(alice.want(http.StatusOK, "PATCH", base+"/checklist-items/"+id(task["id"]), `{"title":"Slajdy v2","notes":""}`)["data"])
if patched["title"] != "Slajdy v2" || patched["estimate_minutes"] != 60.0 {
t.Errorf("patched = %v", patched)
}
alice.want(http.StatusNoContent, "DELETE", base+"/checklist-items/"+id(task["id"]), "")
alice.want(http.StatusNoContent, "DELETE", base+"/checklists/"+cid, "")
if c := count(t, gdb, "jz_bm_checklist_items"); c != 0 {
t.Errorf("items left = %d", c)
}
alice.want(http.StatusNotFound, "GET", base+"/checklists/"+cid, "")
}
func jsonID(id uint) string {
b, _ := json.Marshal(id)
return string(b)
}

View File

@@ -0,0 +1,684 @@
package api
import (
"context"
"net/http"
"time"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/golem15/summercms/modules/wire"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
// The checklists of the student API: each user's own lists (usually the
// preparation for one zjazd), never anyone else's; a checklist or item of
// another user answers 404 like a missing one. Bodies are JSON objects of
// columns, checked as on the management API (Fillable, Rules, existing
// foreign keys). An item with a reading shares its done mark with the
// reading list: marking it done marks the reading read, and a reading read
// in the reading list shows its items done.
// presentationsTable is the deck table of golem15.presentations, read by
// name without importing the plugin.
const presentationsTable = "golem15_presentations_presentations"
var checklistsRules = []lagoon.RequestRule{
{Field: "meeting", Rules: lagoon.ParseRules("nullable|integer|min:1")},
}
// checklistProgress counts a checklist's items.
type checklistProgress struct {
Total int `json:"total"`
Done int `json:"done"`
Percent int `json:"percent"`
MinutesTotal int `json:"minutes_total"`
MinutesDone int `json:"minutes_done"`
MinutesLeft int `json:"minutes_left"`
}
func (p *checklistProgress) add(item checklistItemJSON) {
p.Total++
minutes := 0
if item.EstimateMinutes != nil {
minutes = *item.EstimateMinutes
}
p.MinutesTotal += minutes
if item.Done {
p.Done++
p.MinutesDone += minutes
} else {
p.MinutesLeft += minutes
}
p.Percent = p.Done * 100 / p.Total
}
// checklistMeetingRef is the short form of a checklist's zjazd.
type checklistMeetingRef struct {
ID uint `json:"id"`
Number int `json:"number"`
StartsOn string `json:"starts_on"`
EndsOn string `json:"ends_on"`
}
// checklistJSON is a checklist with its progress, and its items on the
// detail route.
type checklistJSON struct {
ID uint `json:"id"`
MeetingID *uint `json:"meeting_id"`
Meeting *checklistMeetingRef `json:"meeting"`
Title string `json:"title"`
Description *string `json:"description"`
SortOrder int `json:"sort_order"`
Progress checklistProgress `json:"progress"`
Items []checklistItemJSON `json:"items,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type linkedReading struct {
ID uint `json:"id"`
Title string `json:"title"`
Author *string `json:"author"`
}
type linkedDeadline struct {
ID uint `json:"id"`
Title string `json:"title"`
DueAt string `json:"due_at"`
Kind string `json:"kind"`
}
type linkedMaterial struct {
ID uint `json:"id"`
Title string `json:"title"`
}
type linkedPresentation struct {
Slug string `json:"slug"`
Title string `json:"title"`
}
// checklistItemJSON is one item with its done state and the rows it points
// at (null when unset or gone; presentation is null when the caller owns no
// deck with that slug).
type checklistItemJSON struct {
ID uint `json:"id"`
ChecklistID uint `json:"checklist_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Notes *string `json:"notes"`
URL *string `json:"url"`
CourseID *uint `json:"course_id"`
Course *courseRef `json:"course"`
ReadingID *uint `json:"reading_id"`
Reading *linkedReading `json:"reading"`
DeadlineID *uint `json:"deadline_id"`
Deadline *linkedDeadline `json:"deadline"`
MaterialID *uint `json:"material_id"`
Material *linkedMaterial `json:"material"`
PresentationSlug *string `json:"presentation_slug"`
Presentation *linkedPresentation `json:"presentation"`
DueAt *string `json:"due_at"`
EstimateMinutes *int `json:"estimate_minutes"`
SortOrder int `json:"sort_order"`
Done bool `json:"done"`
DoneAt *string `json:"done_at"`
}
// checklistItemRow is an item with its effective done mark: the reading's
// read_at for an item with a reading, else its own done_at.
type checklistItemRow struct {
models.ChecklistItem `gorm:"embedded"`
DoneMark *time.Time `gorm:"column:done_mark"`
}
func formatTime(t *time.Time) *string {
if t == nil {
return nil
}
s := models.FormatDateTime(*t)
return &s
}
// loadChecklistItems returns the caller's items of the given checklists,
// keyed by checklist, in display order.
func loadChecklistItems(db *gorm.DB, userID uint, checklistIDs []uint) (map[uint][]checklistItemJSON, error) {
out := map[uint][]checklistItemJSON{}
if len(checklistIDs) == 0 {
return out, nil
}
var rows []checklistItemRow
err := db.Table("jz_bm_checklist_items AS i").
Select("i.*, CASE WHEN i.reading_id IS NULL THEN i.done_at ELSE p.read_at END AS done_mark").
Joins("JOIN jz_bm_checklists c ON c.id = i.checklist_id").
Joins("LEFT JOIN jz_bm_reading_progress p ON p.reading_id = i.reading_id AND p.user_id = c.user_id").
Where("c.user_id = ? AND i.checklist_id IN ?", userID, checklistIDs).
Order("i.checklist_id, i.sort_order, i.id").
Scan(&rows).Error
if err != nil {
return nil, err
}
var courseIDs, readingIDs, deadlineIDs, materialIDs []uint
var slugs []string
for _, row := range rows {
if row.CourseID != nil {
courseIDs = append(courseIDs, *row.CourseID)
}
if row.ReadingID != nil {
readingIDs = append(readingIDs, *row.ReadingID)
}
if row.DeadlineID != nil {
deadlineIDs = append(deadlineIDs, *row.DeadlineID)
}
if row.MaterialID != nil {
materialIDs = append(materialIDs, *row.MaterialID)
}
if row.PresentationSlug != nil {
slugs = append(slugs, *row.PresentationSlug)
}
}
courses, err := coursesByID(db, courseIDs)
if err != nil {
return nil, err
}
readings := map[uint]*linkedReading{}
if len(readingIDs) > 0 {
var rs []models.Reading
if err := db.Where("id IN ?", readingIDs).Find(&rs).Error; err != nil {
return nil, err
}
for _, r := range rs {
readings[r.ID] = &linkedReading{ID: r.ID, Title: r.Title, Author: r.Author}
}
}
deadlines := map[uint]*linkedDeadline{}
if len(deadlineIDs) > 0 {
var ds []models.Deadline
if err := db.Where("id IN ?", deadlineIDs).Find(&ds).Error; err != nil {
return nil, err
}
for _, d := range ds {
deadlines[d.ID] = &linkedDeadline{ID: d.ID, Title: d.Title, DueAt: models.FormatDateTime(d.DueAt), Kind: d.Kind}
}
}
materials := map[uint]*linkedMaterial{}
if len(materialIDs) > 0 {
// Only published materials, as in the material list.
var ms []models.Material
if err := db.Select("id", "title").Where("id IN ? AND published_at IS NOT NULL", materialIDs).Find(&ms).Error; err != nil {
return nil, err
}
for _, m := range ms {
materials[m.ID] = &linkedMaterial{ID: m.ID, Title: m.Title}
}
}
decks, err := ownedDecks(db, userID, slugs)
if err != nil {
return nil, err
}
for _, row := range rows {
item := checklistItemJSON{
ID: row.ID,
ChecklistID: row.ChecklistID,
Kind: row.Kind,
Title: row.Title,
Notes: row.Notes,
URL: row.URL,
CourseID: row.CourseID,
ReadingID: row.ReadingID,
DeadlineID: row.DeadlineID,
MaterialID: row.MaterialID,
PresentationSlug: row.PresentationSlug,
DueAt: formatTime(row.DueAt),
EstimateMinutes: row.EstimateMinutes,
SortOrder: row.SortOrder,
Done: row.DoneMark != nil,
DoneAt: formatTime(row.DoneMark),
}
if row.CourseID != nil {
item.Course = newCourseRef(courses[*row.CourseID])
}
if row.ReadingID != nil {
item.Reading = readings[*row.ReadingID]
}
if row.DeadlineID != nil {
item.Deadline = deadlines[*row.DeadlineID]
}
if row.MaterialID != nil {
item.Material = materials[*row.MaterialID]
}
if row.PresentationSlug != nil {
item.Presentation = decks[*row.PresentationSlug]
}
out[row.ChecklistID] = append(out[row.ChecklistID], item)
}
return out, nil
}
// ownedDecks returns the caller's decks among slugs, keyed by slug. Without
// golem15.presentations installed there are none.
func ownedDecks(db *gorm.DB, userID uint, slugs []string) (map[string]*linkedPresentation, error) {
out := map[string]*linkedPresentation{}
if len(slugs) == 0 || !db.Migrator().HasTable(presentationsTable) {
return out, nil
}
var rows []linkedPresentation
err := db.Table(presentationsTable).Select("slug, title").
Where("owner_user_id = ? AND slug IN ?", userID, slugs).Scan(&rows).Error
if err != nil {
return nil, err
}
for i := range rows {
out[rows[i].Slug] = &rows[i]
}
return out, nil
}
// checklistsJSON builds the answer for the given checklists, with items
// when withItems.
func checklistsJSON(db *gorm.DB, userID uint, lists []models.Checklist, withItems bool) ([]checklistJSON, error) {
ids := make([]uint, 0, len(lists))
meetingIDs := []uint{}
for _, c := range lists {
ids = append(ids, c.ID)
if c.MeetingID != nil {
meetingIDs = append(meetingIDs, *c.MeetingID)
}
}
items, err := loadChecklistItems(db, userID, ids)
if err != nil {
return nil, err
}
meetings := map[uint]*checklistMeetingRef{}
if len(meetingIDs) > 0 {
var ms []models.Meeting
if err := db.Where("id IN ?", meetingIDs).Find(&ms).Error; err != nil {
return nil, err
}
for _, m := range ms {
meetings[m.ID] = &checklistMeetingRef{ID: m.ID, Number: m.Number, StartsOn: m.StartsOn.String(), EndsOn: m.EndsOn.String()}
}
}
out := make([]checklistJSON, 0, len(lists))
for _, c := range lists {
item := checklistJSON{
ID: c.ID,
MeetingID: c.MeetingID,
Title: c.Title,
Description: c.Description,
SortOrder: c.SortOrder,
CreatedAt: models.FormatDateTime(c.CreatedAt),
UpdatedAt: models.FormatDateTime(c.UpdatedAt),
}
if c.MeetingID != nil {
item.Meeting = meetings[*c.MeetingID]
}
for _, it := range items[c.ID] {
item.Progress.add(it)
}
if withItems {
item.Items = items[c.ID]
if item.Items == nil {
item.Items = []checklistItemJSON{}
}
}
out = append(out, item)
}
return out, nil
}
// Checklists answers GET /checklists?meeting=ID: the caller's checklists
// with their progress, without items.
func (h *Handlers) Checklists(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
in := query(r, "meeting")
if !h.validate(w, r, in, checklistsRules) {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
q := db.Where("user_id = ?", userID)
if id := uintParam(in, "meeting"); id != 0 {
q = q.Where("meeting_id = ?", id)
}
var lists []models.Checklist
if err := q.Order("sort_order, id").Find(&lists).Error; err != nil {
writeOpaque500(w)
return
}
out, err := checklistsJSON(db, userID, lists, false)
if err != nil {
writeOpaque500(w)
return
}
writeData(w, out)
}
// checklistFor resolves {id} to one of the caller's checklists and answers
// 404 itself when there is none.
func checklistFor(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) (*models.Checklist, bool) {
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return nil, false
}
var c models.Checklist
res := db.Where("id = ? AND user_id = ?", id, userID).Limit(1).Find(&c)
if res.Error != nil {
writeOpaque500(w)
return nil, false
}
if res.RowsAffected == 0 {
writeNotFound(w)
return nil, false
}
return &c, true
}
// itemFor resolves {id} to an item of one of the caller's checklists and
// answers 404 itself when there is none.
func itemFor(w http.ResponseWriter, r *http.Request, db *gorm.DB, userID uint) (*models.ChecklistItem, bool) {
id, ok := surf.IntParam(r, "id")
if !ok {
writeNotFound(w)
return nil, false
}
var item models.ChecklistItem
res := db.Table("jz_bm_checklist_items AS i").Select("i.*").
Joins("JOIN jz_bm_checklists c ON c.id = i.checklist_id").
Where("i.id = ? AND c.user_id = ?", id, userID).Limit(1).Find(&item)
if res.Error != nil {
writeOpaque500(w)
return nil, false
}
if res.RowsAffected == 0 {
writeNotFound(w)
return nil, false
}
return &item, true
}
// writeChecklist answers one checklist with its items.
func writeChecklist(w http.ResponseWriter, db *gorm.DB, userID uint, c models.Checklist, status int) {
out, err := checklistsJSON(db, userID, []models.Checklist{c}, true)
if err != nil || len(out) != 1 {
writeOpaque500(w)
return
}
wire.WriteJSON(w, status, map[string]any{"data": out[0]})
}
// writeItem answers one item as the detail route shows it.
func writeItem(w http.ResponseWriter, db *gorm.DB, userID uint, item models.ChecklistItem, status int) {
items, err := loadChecklistItems(db, userID, []uint{item.ChecklistID})
if err != nil {
writeOpaque500(w)
return
}
for _, it := range items[item.ChecklistID] {
if it.ID == item.ID {
wire.WriteJSON(w, status, map[string]any{"data": it})
return
}
}
writeOpaque500(w)
}
// Checklist answers GET /checklists/{id}: one of the caller's checklists
// with its items.
func (h *Handlers) Checklist(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
c, ok := checklistFor(w, r, db, userID)
if !ok {
return
}
writeChecklist(w, db, userID, *c, http.StatusOK)
}
var checklistReferences = map[string]string{"meeting_id": "jz_bm_meetings"}
var itemReferences = map[string]string{
"course_id": "jz_bm_courses",
"reading_id": "jz_bm_readings",
"deadline_id": "jz_bm_deadlines",
"material_id": "jz_bm_materials",
}
// CreateChecklist answers POST /checklists: 201 with the new checklist.
func (h *Handlers) CreateChecklist(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
c := &models.Checklist{UserID: userID}
if _, ok := h.fillRecord(w, r, db, c, c, checklistReferences, nil); !ok {
return
}
if err := db.Create(c).Error; err != nil {
writeOpaque500(w)
return
}
writeChecklist(w, db, userID, *c, http.StatusCreated)
}
// UpdateChecklist answers PATCH /checklists/{id}: the sent columns change.
func (h *Handlers) UpdateChecklist(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
c, ok := checklistFor(w, r, db, userID)
if !ok {
return
}
if _, ok := h.fillRecord(w, r, db, c, c, checklistReferences, nil); !ok {
return
}
if err := db.Save(c).Error; err != nil {
writeOpaque500(w)
return
}
writeChecklist(w, db, userID, *c, http.StatusOK)
}
// DeleteChecklist answers DELETE /checklists/{id}: 204; its items go with
// it. Reading marks stay.
func (h *Handlers) DeleteChecklist(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
c, ok := checklistFor(w, r, db, userID)
if !ok {
return
}
if err := db.Delete(c).Error; err != nil {
writeOpaque500(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
// checkItem adds the item checks the rules cannot make: the target
// checklist is the caller's, and a deck slug names one of the caller's
// decks.
func checkItem(db *gorm.DB, userID uint) func(map[string]any, map[string][]string) error {
return func(check map[string]any, errs map[string][]string) error {
if id, ok := check["checklist_id"].(uint); ok {
var n int64
if err := db.Model(&models.Checklist{}).Where("id = ? AND user_id = ?", id, userID).Count(&n).Error; err != nil {
return err
}
if n == 0 {
errs["checklist_id"] = append(errs["checklist_id"], "The selected checklist_id is invalid.")
}
}
if slug, ok := check["presentation_slug"].(string); ok && slug != "" {
decks, err := ownedDecks(db, userID, []string{slug})
if err != nil {
return err
}
if decks[slug] == nil {
errs["presentation_slug"] = append(errs["presentation_slug"], "The selected presentation_slug is not one of your decks.")
}
}
return nil
}
}
// CreateChecklistItem answers POST /checklists/{id}/items: 201 with the new
// item, at the end of the list unless sort_order is sent. kind defaults to
// zadanie.
func (h *Handlers) CreateChecklistItem(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
c, ok := checklistFor(w, r, db, userID)
if !ok {
return
}
var last struct{ Max *int }
if err := db.Model(&models.ChecklistItem{}).Select("MAX(sort_order) AS max").Where("checklist_id = ?", c.ID).Scan(&last).Error; err != nil {
writeOpaque500(w)
return
}
item := &models.ChecklistItem{ChecklistID: c.ID, Kind: "zadanie"}
if last.Max != nil {
item.SortOrder = *last.Max + 1
}
if _, ok := h.fillRecord(w, r, db, item, item, itemReferences, checkItem(db, userID)); !ok {
return
}
if err := db.Create(item).Error; err != nil {
writeOpaque500(w)
return
}
writeItem(w, db, userID, *item, http.StatusCreated)
}
// UpdateChecklistItem answers PATCH /checklist-items/{id}: the sent columns
// change; checklist_id moves the item to another of the caller's lists.
func (h *Handlers) UpdateChecklistItem(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
item, ok := itemFor(w, r, db, userID)
if !ok {
return
}
if _, ok := h.fillRecord(w, r, db, item, item, itemReferences, checkItem(db, userID)); !ok {
return
}
if err := db.Save(item).Error; err != nil {
writeOpaque500(w)
return
}
writeItem(w, db, userID, *item, http.StatusOK)
}
// DeleteChecklistItem answers DELETE /checklist-items/{id}: 204. A reading
// mark stays.
func (h *Handlers) DeleteChecklistItem(w http.ResponseWriter, r *http.Request) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
item, ok := itemFor(w, r, db, userID)
if !ok {
return
}
if err := db.Delete(item).Error; err != nil {
writeOpaque500(w)
return
}
w.WriteHeader(http.StatusNoContent)
}
// MarkChecklistItemDone answers PUT /checklist-items/{id}/done. An item
// with a reading marks the reading read by the caller (the reading list
// shows it read); any other item gets done_at. Marking it again keeps the
// first mark.
func (h *Handlers) MarkChecklistItemDone(w http.ResponseWriter, r *http.Request) {
h.setItemDone(w, r, true)
}
// UnmarkChecklistItemDone answers DELETE /checklist-items/{id}/done: it
// clears the mark, and for an item with a reading the caller's read mark.
func (h *Handlers) UnmarkChecklistItemDone(w http.ResponseWriter, r *http.Request) {
h.setItemDone(w, r, false)
}
func (h *Handlers) setItemDone(w http.ResponseWriter, r *http.Request, done bool) {
userID, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
item, ok := itemFor(w, r, db, userID)
if !ok {
return
}
now := h.now().UTC()
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
if item.ReadingID != nil {
if done {
return tx.Exec(`INSERT INTO jz_bm_reading_progress (reading_id, user_id, read_at) VALUES (?, ?, ?)
ON CONFLICT (reading_id, user_id) DO NOTHING`, *item.ReadingID, userID, now).Error
}
return tx.Where("reading_id = ? AND user_id = ?", *item.ReadingID, userID).Delete(&models.ReadingProgress{}).Error
}
q := tx.Model(&models.ChecklistItem{}).Where("id = ?", item.ID)
if done {
return q.Where("done_at IS NULL").Update("done_at", now).Error
}
return q.Update("done_at", nil).Error
})
if err != nil {
writeOpaque500(w)
return
}
writeItem(w, db, userID, *item, http.StatusOK)
}

View File

@@ -175,18 +175,31 @@ func (rec Record[T, PT]) find(w http.ResponseWriter, r *http.Request, db *gorm.D
// save decodes the body onto row, validates the result and writes it.
func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handlers, db *gorm.DB, row *T, created bool) {
if _, ok := h.fillRecord(w, r, db, row, PT(row), rec.References, nil); !ok {
return
}
writeSaved(w, r, db, row, created)
}
// fillRecord decodes the JSON body onto row (only model.Fillable() keys),
// then checks model.Rules() and TemporalErrors() against the row as it
// would be saved, that every foreign key in references names an existing
// row, and extra (when given) for checks of its own. It answers 422 or 500
// itself and returns false when the row must not be saved; else it returns
// the row as a column map.
func (h *Handlers) fillRecord(w http.ResponseWriter, r *http.Request, db *gorm.DB, row any, model recordModel,
references map[string]string, extra func(check map[string]any, errs map[string][]string) error) (map[string]any, bool) {
raw, ok := body(w, r)
if !ok {
return
return nil, false
}
var in map[string]any
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&in); err != nil || in == nil {
writeInvalid(w, "The body must be a JSON object of columns.")
return
return nil, false
}
model := PT(row)
fillable := model.Fillable()
errs := map[string][]string{}
for key := range in {
@@ -196,7 +209,7 @@ func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handler
}
if len(errs) > 0 {
writeValidation(w, errs)
return
return nil, false
}
values, fieldErrs := normalizeValues(row, in)
for k, v := range fieldErrs {
@@ -208,12 +221,12 @@ func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handler
errs[typeErr.Key] = []string{"The " + typeErr.Key + " has the wrong type."}
} else if err != nil {
writeOpaque500(w)
return
return nil, false
}
}
if len(errs) > 0 {
writeValidation(w, errs)
return
return nil, false
}
// The rules run on the row as it would be saved, so a PATCH of one
// column still meets the required ones.
@@ -221,17 +234,17 @@ func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handler
verrs, err := lagoon.Validate(r.Context(), db, row, model.Rules(), check, h.translator())
if err != nil {
writeOpaque500(w)
return
return nil, false
}
for k, v := range verrs {
errs[k] = v
}
if tc, ok := any(row).(temporalChecker); ok {
if tc, ok := row.(temporalChecker); ok {
for k, v := range tc.TemporalErrors() {
errs[k] = append(errs[k], v...)
}
}
for col, table := range rec.References {
for col, table := range references {
id, ok := check[col].(uint)
if !ok || id == 0 {
continue
@@ -239,17 +252,29 @@ func (rec Record[T, PT]) save(w http.ResponseWriter, r *http.Request, h *Handler
var n int64
if err := db.Table(table).Where("id = ?", id).Count(&n).Error; err != nil {
writeOpaque500(w)
return
return nil, false
}
if n == 0 {
errs[col] = append(errs[col], "The selected "+col+" is invalid.")
}
}
if extra != nil {
if err := extra(check, errs); err != nil {
writeOpaque500(w)
return nil, false
}
}
if len(errs) > 0 {
writeValidation(w, errs)
return
return nil, false
}
err = lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
return check, true
}
// writeSaved saves row in a transaction and answers it as a column map, 201
// when created.
func writeSaved(w http.ResponseWriter, r *http.Request, db *gorm.DB, row any, created bool) {
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
return tx.Save(row).Error
})
if err != nil {

3
mcp.go
View File

@@ -12,6 +12,7 @@ var _ pact.HasMCP = (*Plugin)(nil)
// signed-in user under golem15.user's "user" guard, and their titles and
// descriptions are static text, never built from data.
func (p *Plugin) MCP() pact.MCP {
tools := append(studentTools(), manageTools()...)
tools := append(studentTools(), checklistTools()...)
tools = append(tools, manageTools()...)
return pact.MCP{Tools: append(tools, fileTools()...)}
}

125
mcp_checklists.go Normal file
View File

@@ -0,0 +1,125 @@
package bm
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
)
// checklistsInput is the input of bm_checklists.
type checklistsInput struct {
Meeting uint `json:"meeting,omitempty" query:"meeting" jsonschema:"only checklists of this zjazd (meeting) id"`
}
// checklistIDInput names one of the student's checklists.
type checklistIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the checklist id, as listed by bm_checklists"`
}
// checklistItemIDInput names one item of the student's checklists.
type checklistItemIDInput struct {
ID uint `json:"id" path:"id" jsonschema:"the checklist item id, as listed by bm_checklist"`
}
// createChecklistInput is the body of bm_create_checklist.
type createChecklistInput struct {
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing zjazd (meeting) id, as listed by bm_meetings"`
Title string `json:"title" jsonschema:"title, at most 255 characters, e.g. Zjazd 4"`
Description *string `json:"description,omitempty" jsonschema:"description, at most 10000 characters"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position among the student's checklists, an integer"`
}
// updateChecklistInput is the body of bm_update_checklist.
type updateChecklistInput struct {
ID uint `json:"id" path:"id" jsonschema:"the checklist id"`
MeetingID *uint `json:"meeting_id,omitempty" jsonschema:"an existing zjazd (meeting) id; null clears it"`
Title *string `json:"title,omitempty" jsonschema:"title, at most 255 characters; required, so not null"`
Description *string `json:"description,omitempty" jsonschema:"description, at most 10000 characters"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position among the student's checklists, an integer"`
}
// addChecklistItemInput is the body of bm_add_checklist_item.
type addChecklistItemInput struct {
ID uint `json:"id" path:"id" jsonschema:"the checklist id"`
Kind *string `json:"kind,omitempty" jsonschema:"one of: lektura, artykul, ksiazka, prezentacja, zadanie, inne (default zadanie)"`
Title string `json:"title" jsonschema:"title, at most 500 characters"`
Notes *string `json:"notes,omitempty" jsonschema:"notes, at most 10000 characters"`
URL *string `json:"url,omitempty" jsonschema:"link, at most 2048 characters"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
ReadingID *uint `json:"reading_id,omitempty" jsonschema:"an existing reading id; the item is then done exactly when the reading is marked read"`
DeadlineID *uint `json:"deadline_id,omitempty" jsonschema:"an existing deadline id"`
MaterialID *uint `json:"material_id,omitempty" jsonschema:"an existing material id"`
PresentationSlug *string `json:"presentation_slug,omitempty" jsonschema:"slug of one of the student's own presentation decks"`
DueAt *string `json:"due_at,omitempty" jsonschema:"when it should be done: RFC 3339 or YYYY-MM-DD HH:MM in Europe/Warsaw"`
EstimateMinutes *int `json:"estimate_minutes,omitempty" jsonschema:"estimated work in minutes, 0 to 10000"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position in the checklist, an integer (default: last)"`
}
// updateChecklistItemInput is the body of bm_update_checklist_item.
type updateChecklistItemInput struct {
ID uint `json:"id" path:"id" jsonschema:"the checklist item id"`
ChecklistID *uint `json:"checklist_id,omitempty" jsonschema:"move the item to another of the student's checklists"`
Kind *string `json:"kind,omitempty" jsonschema:"one of: lektura, artykul, ksiazka, prezentacja, zadanie, inne"`
Title *string `json:"title,omitempty" jsonschema:"title, at most 500 characters; required, so not null"`
Notes *string `json:"notes,omitempty" jsonschema:"notes, at most 10000 characters"`
URL *string `json:"url,omitempty" jsonschema:"link, at most 2048 characters"`
CourseID *uint `json:"course_id,omitempty" jsonschema:"an existing course id"`
ReadingID *uint `json:"reading_id,omitempty" jsonschema:"an existing reading id; the item is then done exactly when the reading is marked read"`
DeadlineID *uint `json:"deadline_id,omitempty" jsonschema:"an existing deadline id"`
MaterialID *uint `json:"material_id,omitempty" jsonschema:"an existing material id"`
PresentationSlug *string `json:"presentation_slug,omitempty" jsonschema:"slug of one of the student's own presentation decks"`
DueAt *string `json:"due_at,omitempty" jsonschema:"when it should be done: RFC 3339 or YYYY-MM-DD HH:MM in Europe/Warsaw"`
EstimateMinutes *int `json:"estimate_minutes,omitempty" jsonschema:"estimated work in minutes, 0 to 10000"`
SortOrder *int `json:"sort_order,omitempty" jsonschema:"position in the checklist, an integer (default: last)"`
}
// The sentences of the checklist tool descriptions.
const (
checklistsPrivate = " Checklists are the signed-in student's own; another student's id answers 404."
checklistAnswer = " Returns the checklist with its progress (total, done, percent, minutes_total, minutes_done, minutes_left) and its items."
itemAnswer = " Returns the item with its done mark and the reading, deadline, material, course or deck it points at."
checklistInvalid = " Unknown fields and invalid values answer 422 with the errors per field."
)
// checklistTools are the student's checklist tools over the token twins of
// the student API.
func checklistTools() []pact.MCPTool {
tool := func(name, title, desc, method, path string, input any) pact.MCPTool {
return pact.MCPTool{Name: name, Title: title, Description: desc + checklistsPrivate, Guard: "user", Method: method, Path: TokenPrefix + path, Input: input}
}
unmark := tool("unmark_checklist_item_done", "Unmark a checklist item as done",
"Clears the done mark of a checklist item; for an item with a reading it removes the student's read mark of that reading."+itemAnswer+needsScopeWrite,
http.MethodDelete, "/checklist-items/{id}/done", checklistItemIDInput{})
unmark.Destructive = boolPtr(false)
unmark.Idempotent = boolPtr(true)
return []pact.MCPTool{
tool("checklists", "BM checklists",
"Lists the student's checklists (e.g. the preparation for a zjazd) with zjazd and progress, without items."+needsScopeRead,
http.MethodGet, "/checklists", checklistsInput{}),
tool("checklist", "BM checklist",
"Returns one of the student's checklists."+checklistAnswer+needsScopeRead,
http.MethodGet, "/checklists/{id}", checklistIDInput{}),
tool("create_checklist", "Create a checklist",
"Creates a checklist for the student, optionally for one zjazd."+checklistAnswer+checklistInvalid+needsScopeWrite,
http.MethodPost, "/checklists", createChecklistInput{}),
tool("update_checklist", "Update a checklist",
"Changes the sent fields of one of the student's checklists; the rest stay."+checklistAnswer+checklistInvalid+needsScopeWrite,
http.MethodPatch, "/checklists/{id}", updateChecklistInput{}),
tool("delete_checklist", "Delete a checklist",
"Deletes one of the student's checklists with its items, for good; reading marks stay."+needsScopeWrite,
http.MethodDelete, "/checklists/{id}", checklistIDInput{}),
tool("add_checklist_item", "Add a checklist item",
"Adds an item (a reading, article, book, presentation, task) to one of the student's checklists, last unless sort_order is given."+itemAnswer+checklistInvalid+needsScopeWrite,
http.MethodPost, "/checklists/{id}/items", addChecklistItemInput{}),
tool("update_checklist_item", "Update a checklist item",
"Changes the sent fields of one checklist item; the rest stay."+itemAnswer+checklistInvalid+needsScopeWrite,
http.MethodPatch, "/checklist-items/{id}", updateChecklistItemInput{}),
tool("delete_checklist_item", "Delete a checklist item",
"Deletes one checklist item for good; a reading mark stays."+needsScopeWrite,
http.MethodDelete, "/checklist-items/{id}", checklistItemIDInput{}),
tool("mark_checklist_item_done", "Mark a checklist item as done",
"Marks a checklist item done; for an item with a reading it marks that reading read (the reading list shows it). Marking again keeps the first time."+itemAnswer+needsScopeWrite,
http.MethodPut, "/checklist-items/{id}/done", checklistItemIDInput{}),
unmark,
}
}

View File

@@ -51,10 +51,16 @@ func (h mountHost) Owner(method, path string) (string, bool) {
// pinnedTools is the sorted tool list of golem15.user plus jz.bm.
var pinnedTools = []string{
"bm_add_checklist_item",
"bm_checklist",
"bm_checklists",
"bm_course",
"bm_courses",
"bm_create_checklist",
"bm_dashboard",
"bm_deadlines",
"bm_delete_checklist",
"bm_delete_checklist_item",
"bm_manage_attach_material_file",
"bm_manage_create_class_session",
"bm_manage_create_course",
@@ -86,6 +92,7 @@ var pinnedTools = []string{
"bm_manage_update_material",
"bm_manage_update_meeting",
"bm_manage_update_reading",
"bm_mark_checklist_item_done",
"bm_mark_reading_owned",
"bm_mark_reading_read",
"bm_material",
@@ -93,8 +100,11 @@ var pinnedTools = []string{
"bm_meetings",
"bm_readings",
"bm_schedule",
"bm_unmark_checklist_item_done",
"bm_unmark_reading_owned",
"bm_unmark_reading_read",
"bm_update_checklist",
"bm_update_checklist_item",
"user_me",
}
@@ -113,6 +123,7 @@ var queryKeys = map[string][]string{
"schedule": {"from", "to"},
"deadlines": {"from", "to"},
"readings": {"course"},
"checklists": {"meeting"},
"materials": {"course", "meeting", "tag", "type", "page", "per_page"},
"material": {"include_body"},
}
@@ -449,6 +460,16 @@ var studentRoutes = []string{
"DELETE /readings/{id}/owned",
"GET /materials",
"GET /materials/{id}",
"GET /checklists",
"POST /checklists",
"GET /checklists/{id}",
"PATCH /checklists/{id}",
"DELETE /checklists/{id}",
"POST /checklists/{id}/items",
"PATCH /checklist-items/{id}",
"DELETE /checklist-items/{id}",
"PUT /checklist-items/{id}/done",
"DELETE /checklist-items/{id}/done",
}
// TestTokenMirrorRoutes is the invariant of the two student surfaces: every

45
models/checklist.go Normal file
View File

@@ -0,0 +1,45 @@
package models
import (
"time"
"gorm.io/gorm"
)
// Checklist is a user's own list of things to do, usually the preparation
// for one zjazd. Only its owner (a golem15.user users row) sees it.
type Checklist struct {
ID uint `gorm:"column:id;primaryKey"`
UserID uint `gorm:"column:user_id"`
MeetingID *uint `gorm:"column:meeting_id"`
Meeting *Meeting `gorm:"foreignKey:MeetingID"`
Title string `gorm:"column:title"`
Description *string `gorm:"column:description"`
SortOrder int `gorm:"column:sort_order"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
// TableName is the plugin table.
func (Checklist) TableName() string { return "jz_bm_checklists" }
// Fillable lists the columns the student API may set; user_id comes from
// the caller.
func (Checklist) Fillable() []string {
return []string{"meeting_id", "title", "description", "sort_order"}
}
// Rules are checked by the student API on every save.
func (Checklist) Rules() map[string]string {
return map[string]string{
"title": "required|max:255",
"description": "nullable|max:10000",
"sort_order": "nullable|integer",
}
}
// BeforeSave stores NULL for empty optional text.
func (c *Checklist) BeforeSave(*gorm.DB) error {
blank(&c.Description)
return nil
}

64
models/checklist_item.go Normal file
View File

@@ -0,0 +1,64 @@
package models
import (
"time"
"gorm.io/gorm"
)
// ChecklistItemKinds lists the allowed ChecklistItem.Kind values.
var ChecklistItemKinds = []string{"lektura", "artykul", "ksiazka", "prezentacja", "zadanie", "inne"}
// ChecklistItem is one entry of a Checklist. It may point at a reading, a
// deadline, a material or a course, and name a deck of golem15.presentations
// by slug. An item with a reading is done when the checklist's owner has
// read it (ReadingProgress); any other item is done when DoneAt is set.
type ChecklistItem struct {
ID uint `gorm:"column:id;primaryKey"`
ChecklistID uint `gorm:"column:checklist_id"`
Kind string `gorm:"column:kind"`
Title string `gorm:"column:title"`
Notes *string `gorm:"column:notes"`
URL *string `gorm:"column:url"`
CourseID *uint `gorm:"column:course_id"`
ReadingID *uint `gorm:"column:reading_id"`
DeadlineID *uint `gorm:"column:deadline_id"`
MaterialID *uint `gorm:"column:material_id"`
PresentationSlug *string `gorm:"column:presentation_slug"`
DueAt *time.Time `gorm:"column:due_at"`
EstimateMinutes *int `gorm:"column:estimate_minutes"`
SortOrder int `gorm:"column:sort_order"`
DoneAt *time.Time `gorm:"column:done_at"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
}
// TableName is the plugin table.
func (ChecklistItem) TableName() string { return "jz_bm_checklist_items" }
// Fillable lists the columns the student API may set. checklist_id moves an
// item to another of the caller's checklists; done_at is set by the done
// routes only.
func (ChecklistItem) Fillable() []string {
return []string{"checklist_id", "kind", "title", "notes", "url", "course_id", "reading_id", "deadline_id",
"material_id", "presentation_slug", "due_at", "estimate_minutes", "sort_order"}
}
// Rules are checked by the student API on every save.
func (ChecklistItem) Rules() map[string]string {
return map[string]string{
"kind": "required|in:lektura,artykul,ksiazka,prezentacja,zadanie,inne",
"title": "required|max:500",
"notes": "nullable|max:10000",
"url": "nullable|max:2048",
"presentation_slug": "nullable|max:255",
"estimate_minutes": "nullable|integer|min:0|max:10000",
"sort_order": "nullable|integer",
}
}
// BeforeSave stores NULL for empty optional text.
func (i *ChecklistItem) BeforeSave(*gorm.DB) error {
blank(&i.Notes, &i.URL, &i.PresentationSlug)
return nil
}

View File

@@ -76,11 +76,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 != 11 {
t.Errorf("Models = %d, want 11", n)
if n := len(p.Models()); n != 13 {
t.Errorf("Models = %d, want 13", n)
}
if n := len(p.Migrations()); n != 16 {
t.Errorf("Migrations = %d, want 16", n)
if n := len(p.Migrations()); n != 18 {
t.Errorf("Migrations = %d, want 18", n)
}
if !slices.ContainsFunc(p.Commands(), func(c bonfire.Command) bool { return c.Name == "bm:seed" }) {
t.Error("bm:seed is not registered")
@@ -117,6 +117,16 @@ func TestRoutes(t *testing.T) {
"DELETE /_bm/api/v1/readings/{id}/owned",
"GET /_bm/api/v1/materials",
"GET /_bm/api/v1/materials/{id}",
"GET /_bm/api/v1/checklists",
"POST /_bm/api/v1/checklists",
"GET /_bm/api/v1/checklists/{id}",
"PATCH /_bm/api/v1/checklists/{id}",
"DELETE /_bm/api/v1/checklists/{id}",
"POST /_bm/api/v1/checklists/{id}/items",
"PATCH /_bm/api/v1/checklist-items/{id}",
"DELETE /_bm/api/v1/checklist-items/{id}",
"PUT /_bm/api/v1/checklist-items/{id}/done",
"DELETE /_bm/api/v1/checklist-items/{id}/done",
"GET /_bm/api/v1/calendar-feed",
"PUT /_bm/api/v1/calendar-feed",
"POST /_bm/api/v1/calendar-feed/rotate",
@@ -134,6 +144,16 @@ func TestRoutes(t *testing.T) {
"DELETE /_bm/api/v1/token/readings/{id}/owned",
"GET /_bm/api/v1/token/materials",
"GET /_bm/api/v1/token/materials/{id}",
"GET /_bm/api/v1/token/checklists",
"POST /_bm/api/v1/token/checklists",
"GET /_bm/api/v1/token/checklists/{id}",
"PATCH /_bm/api/v1/token/checklists/{id}",
"DELETE /_bm/api/v1/token/checklists/{id}",
"POST /_bm/api/v1/token/checklists/{id}/items",
"PATCH /_bm/api/v1/token/checklist-items/{id}",
"DELETE /_bm/api/v1/token/checklist-items/{id}",
"PUT /_bm/api/v1/token/checklist-items/{id}/done",
"DELETE /_bm/api/v1/token/checklist-items/{id}/done",
"GET /api/v1/bm/me",
"GET /api/v1/bm/materials",
"GET /api/v1/bm/materials/{id}",

View File

@@ -156,6 +156,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_reading_ownership", "jz_bm_tags", "jz_bm_materials", "jz_bm_material_tag", "jz_bm_calendar_feeds",
"jz_bm_checklists", "jz_bm_checklist_items",
}
func TestMigrateUpAndRollback(t *testing.T) {
@@ -201,6 +202,8 @@ func TestSchemaConstraints(t *testing.T) {
`INSERT INTO jz_bm_class_sessions (course_id, starts_at, ends_at) VALUES (999, NOW(), NOW() + interval '1 hour')`,
`INSERT INTO jz_bm_deadlines (title, due_at, kind) VALUES ('X', NOW(), 'party')`,
`INSERT INTO jz_bm_materials (title, type) VALUES ('X', 'video')`,
`INSERT INTO jz_bm_checklists (user_id, title) VALUES (999, 'X')`,
`INSERT INTO jz_bm_checklist_items (checklist_id, title) VALUES (999, 'X')`,
} {
if err := exec(bad); err == nil {
t.Errorf("accepted: %s", bad)

View File

@@ -14,6 +14,8 @@ import (
func generatedModels() []any {
return []any{
&models.CalendarFeed{},
&models.Checklist{},
&models.ChecklistItem{},
&models.ClassSession{},
&models.Course{},
&models.Deadline{},
@@ -45,6 +47,8 @@ func generatedMigrations() []*gormigrate.Migration {
updates.CreateReadingOwnership(),
updates.SeedChatGroups(),
updates.AddMeetingToReadingsAndMaterials(),
updates.CreateChecklists(),
updates.CreateChecklistItems(),
}
}

View File

@@ -152,7 +152,7 @@ const TokenPrefix = APIPrefix + "/token"
// 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 and owned marks.
// their own reading progress and owned marks and their own checklists.
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) {
@@ -373,6 +373,24 @@ func mountStudent(g pact.Router, h *api.Handlers, read, write []string) {
g.Get("/materials", h.Materials, read...)
g.Get("/materials/{id}", h.Material, read...)
g.Where("id", "[0-9]+")
g.Get("/checklists", h.Checklists, read...)
g.Post("/checklists", h.CreateChecklist, write...)
g.Get("/checklists/{id}", h.Checklist, read...)
g.Where("id", "[0-9]+")
g.Patch("/checklists/{id}", h.UpdateChecklist, write...)
g.Where("id", "[0-9]+")
g.Delete("/checklists/{id}", h.DeleteChecklist, write...)
g.Where("id", "[0-9]+")
g.Post("/checklists/{id}/items", h.CreateChecklistItem, write...)
g.Where("id", "[0-9]+")
g.Patch("/checklist-items/{id}", h.UpdateChecklistItem, write...)
g.Where("id", "[0-9]+")
g.Delete("/checklist-items/{id}", h.DeleteChecklistItem, write...)
g.Where("id", "[0-9]+")
g.Put("/checklist-items/{id}/done", h.MarkChecklistItemDone, write...)
g.Where("id", "[0-9]+")
g.Delete("/checklist-items/{id}/done", h.UnmarkChecklistItemDone, write...)
g.Where("id", "[0-9]+")
}
// recordRoutes is what Record provides to mountRecords.

View File

@@ -0,0 +1,33 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// CreateChecklists returns the 20261030120000_create_jz_bm_checklists
// gormigrate entry: a user's own checklists, e.g. one per zjazd to plan the
// preparation. They go with the golem15.user user; a deleted zjazd leaves
// the checklist without one.
func CreateChecklists() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20261030120000_create_jz_bm_checklists",
Migrate: func(tx *gorm.DB) error {
return execAll(tx, `CREATE TABLE jz_bm_checklists (
id BIGSERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
meeting_id BIGINT NULL REFERENCES jz_bm_meetings (id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX jz_bm_checklists_user_id_index ON jz_bm_checklists (user_id);
CREATE INDEX jz_bm_checklists_meeting_id_index ON jz_bm_checklists (meeting_id)`)
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("DROP TABLE IF EXISTS jz_bm_checklists").Error
},
}
}

View File

@@ -0,0 +1,46 @@
package updates
import (
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// CreateChecklistItems returns the
// 20261030120100_create_jz_bm_checklist_items gormigrate entry: the items
// of a checklist. An item may point at a reading, a deadline, a material or
// a course (the link is cleared when that row goes) and name a deck of
// golem15.presentations by slug (no foreign key across plugins). done_at is
// the done mark of an item without a reading; an item with one is done when
// its owner has read the reading.
func CreateChecklistItems() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20261030120100_create_jz_bm_checklist_items",
Migrate: func(tx *gorm.DB) error {
return execAll(tx, `CREATE TABLE jz_bm_checklist_items (
id BIGSERIAL PRIMARY KEY,
checklist_id BIGINT NOT NULL REFERENCES jz_bm_checklists (id) ON DELETE CASCADE,
kind VARCHAR(20) NOT NULL DEFAULT 'zadanie'
CHECK (kind IN ('lektura', 'artykul', 'ksiazka', 'prezentacja', 'zadanie', 'inne')),
title VARCHAR(500) NOT NULL,
notes TEXT NULL,
url VARCHAR(2048) NULL,
course_id BIGINT NULL REFERENCES jz_bm_courses (id) ON DELETE SET NULL,
reading_id BIGINT NULL REFERENCES jz_bm_readings (id) ON DELETE SET NULL,
deadline_id BIGINT NULL REFERENCES jz_bm_deadlines (id) ON DELETE SET NULL,
material_id BIGINT NULL REFERENCES jz_bm_materials (id) ON DELETE SET NULL,
presentation_slug VARCHAR(255) NULL,
due_at TIMESTAMPTZ NULL,
estimate_minutes INTEGER NULL CHECK (estimate_minutes >= 0),
sort_order INTEGER NOT NULL DEFAULT 0,
done_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX jz_bm_checklist_items_checklist_id_index ON jz_bm_checklist_items (checklist_id, sort_order);
CREATE INDEX jz_bm_checklist_items_reading_id_index ON jz_bm_checklist_items (reading_id)`)
},
Rollback: func(tx *gorm.DB) error {
return tx.Exec("DROP TABLE IF EXISTS jz_bm_checklist_items").Error
},
}
}