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:
32
README.md
32
README.md
@@ -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
126
checklists_postgres_test.go
Normal 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)
|
||||
}
|
||||
684
controllers/api/checklists.go
Normal file
684
controllers/api/checklists.go
Normal 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)
|
||||
}
|
||||
@@ -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
3
mcp.go
@@ -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
125
mcp_checklists.go
Normal 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,
|
||||
}
|
||||
}
|
||||
21
mcp_test.go
21
mcp_test.go
@@ -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
45
models/checklist.go
Normal 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
64
models/checklist_item.go
Normal 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
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
20
routes.go
20
routes.go
@@ -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.
|
||||
|
||||
33
updates/20261030120000_create_jz_bm_checklists.go
Normal file
33
updates/20261030120000_create_jz_bm_checklists.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
46
updates/20261030120100_create_jz_bm_checklist_items.go
Normal file
46
updates/20261030120100_create_jz_bm_checklist_items.go
Normal 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
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user