Datepickers on native time.Time / lagoon.Date fields, file uploads on materials, readings and class sessions managed from the course form (course_id made nullable).
18 KiB
jz.bm
The study dashboard backend of the part-time master's programme "Bezpieczeństwo międzynarodowe" (UWr): courses, meetings, timetable, deadlines, reading lists with per-student progress, and study materials.
import _ "git.golem15.com/jakub/sm-bm-plugin"
Overview
jz.bm is a SummerCMS plugin. It keeps the programme's data in jz_bm_* tables, serves it to signed-in students as a read-only JSON API under /_bm/api/v1, and gives administrators admin screens to edit it. Students write two things only: which readings they have read, and the settings of their calendar feed, a private ICS URL they subscribe to in Google Calendar or on a phone.
Students are the users of golem15.user: the API sits behind its jwt.auth guard, and reading progress points at its users table. Course data is loaded from a JSON file with bm:seed and maintained in the admin afterwards.
Timestamps are stored in UTC (TIMESTAMPTZ) and written in RFC 3339 in Europe/Warsaw (2026-10-03T08:00:00+02:00). Dates are YYYY-MM-DD. A date or time without an offset in a seed file is read as Polish time.
Features
- Models:
Course(przedmiot, one row per course and form),Meeting(zjazd),ClassSession(zajęcia),Deadline(terminarz),Reading(lektura),ReadingProgress,Materialwith file attachments (system_files, fieldfiles) andTag, many-to-many throughjz_bm_material_tag, andCalendarFeed(one per user). - A subscribable calendar feed (
/_bm/calendar/<token>.ics) of the class sessions, meetings and deadlines, see Calendar feed. - Courses that share an
elective_groupare alternatives ("do wyboru"), such as the specialisation courses. - A material without
published_atis a draft; a material whosepublished_atlies in the future is not shown yet. - Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress rows; deleting a user deletes the user's calendar feed.
- 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--pruneto delete what the file no longer lists and--dry-runto preview.
API reference
Every route except the calendar feed itself needs Authorization: Bearer <golem15.user JWT> (or the user plugin's token cookie) and answers 401 {"error": true, "message": "..."} without one. Responses are wrapped in {"data": ...}; /materials adds the meta block of lagoon.Paginate. A missing record, an unpublished material or a non-numeric {id} is a 404. Invalid query parameters are a 422 in Laravel's shape, with messages in the request locale:
{"message": "The to must be a date after or equal to from.", "errors": {"to": ["The to must be a date after or equal to from."]}}
| Method | Path | Query | Answers |
|---|---|---|---|
| GET | /_bm/api/v1/dashboard |
Next sessions, upcoming deadlines, current and next meeting, reading progress | |
| GET | /_bm/api/v1/courses |
semester (1–4) |
Every course with counters |
| GET | /_bm/api/v1/courses/{id} |
One course with its sessions, readings and published materials | |
| GET | /_bm/api/v1/schedule |
from, to (YYYY-MM-DD) |
Class sessions starting in the range |
| GET | /_bm/api/v1/meetings |
Every meeting | |
| GET | /_bm/api/v1/deadlines |
from, to (YYYY-MM-DD) |
Deadlines in the range |
| GET | /_bm/api/v1/readings |
course (ID) |
Readings with the caller's read_at |
| PUT | /_bm/api/v1/readings/{id}/progress |
Marks the reading read (idempotent) | |
| DELETE | /_bm/api/v1/readings/{id}/progress |
Clears the mark (idempotent) | |
| GET | /_bm/api/v1/materials |
course, tag (slug), type, page, per_page (1–100) |
One page of published materials |
| GET | /_bm/api/v1/materials/{id} |
One published material with body, tags and files | |
| GET | /_bm/api/v1/calendar-feed |
The caller's calendar feed, or null |
|
| 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 |
|
| DELETE | /_bm/api/v1/calendar-feed |
Deletes the feed; 204, also when there was none |
Shared shapes
A course reference, embedded in other records:
{"id": 1, "code": null, "name": "…", "form": "wyklad", "semester": 1}
form is one of wyklad, cwiczenia, konwersatorium, seminarium, inne.
A class session:
{"id": 1, "course_id": 1, "meeting_id": 1, "meeting_number": 1,
"starts_at": "2026-10-03T08:00:00+02:00", "ends_at": "2026-10-03T09:30:00+02:00",
"room": "237", "kind": null, "notes": null, "is_online": false,
"course": {"id": 1, "code": null, "name": "…", "form": "wyklad", "semester": 1}}
A meeting:
{"id": 1, "number": 1, "starts_on": "2026-10-03", "ends_on": "2026-10-04", "notes": null, "sessions_count": 12}
A deadline (kind is one of zaliczenie, egzamin, praca, organizacyjne, inne; course is null when the deadline belongs to no course):
{"id": 1, "title": "…", "due_at": "2026-10-05T23:59:00+02:00", "kind": "organizacyjne", "description": null, "course_id": null, "course": null}
A reading (read_at is the caller's, or null):
{"id": 1, "course_id": 1, "course": {…}, "title": "…", "author": null, "source": null, "url": null,
"is_required": true, "sort_order": 1, "read_at": "2026-10-02T21:15:04+02:00"}
A material in a list (type is one of pdf, notatka, link, inne; files_count counts public files):
{"id": 1, "course_id": 1, "course": {…}, "title": "…", "type": "notatka", "source": null, "url": null,
"published_at": "2026-10-01T12:00:00+02:00", "tags": [{"id": 1, "name": "Geopolityka", "slug": "geopolityka"}], "files_count": 1}
A course with counters (readings_read is the caller's; materials counts published materials):
{"id": 1, "code": null, "name": "…", "form": "wyklad", "lecturer": "…", "semester": 1, "ects": 8,
"assessment": "egzamin", "elective_group": null, "description": null, "syllabus_url": null, "sort_order": 1,
"counts": {"sessions": 6, "readings": 4, "readings_read": 1, "materials": 2}}
GET /dashboard
{"data": {
"next_sessions": [<session>, …],
"upcoming_deadlines": [<deadline>, …],
"current_meeting": <meeting> | null,
"next_meeting": <meeting> | null,
"readings": {"total": 40, "read": 3, "required_total": 25, "required_read": 2}
}}
next_sessions are the first sessions that have not ended yet, by start time; upcoming_deadlines the first deadlines not yet due. Both hold jz.bm.dashboard_limit items (5). current_meeting is the meeting whose dates include today in Warsaw; next_meeting the first one that starts after today.
GET /courses, GET /courses/{id}
/courses answers {"data": [<course with counters>, …]} in programme order (semester, sort_order, name). /courses/{id} answers one course with counters plus:
{"data": {…course with counters…,
"sessions": [<session>, …],
"readings": [<reading>, …],
"materials": [<material>, …]}}
GET /schedule, GET /meetings, GET /deadlines
/schedule answers {"data": [<session>, …]} by start time: the sessions that start from midnight of from (default today) until the end of to (default no limit), in Warsaw. /meetings answers {"data": [<meeting>, …]} by number. /deadlines answers {"data": [<deadline>, …]} by due time; without from it starts now, so it lists the deadlines not yet due.
GET /readings, PUT and DELETE /readings/{id}/progress
/readings answers {"data": [<reading>, …]} in course and sort_order order; an unknown course gives an empty list. The progress routes answer:
{"data": {"reading_id": 7, "read_at": "2026-10-02T21:15:04+02:00"}}
Marking a read reading again keeps the first read_at. Clearing answers read_at: null, also when the reading was not marked.
GET /materials, GET /materials/{id}
{"data": [<material>, …], "meta": {"current_page": 1, "last_page": 1, "per_page": 20, "total": 2}}
Newest publication first. The detail adds the Markdown body and the public files; url starts with storage.uploads.public_path_prefix:
{"data": {…material…, "body": "# …",
"files": [{"id": 3, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.pdf", "name": "wyklad.pdf", "size": 123456, "mime": "application/pdf"}]}}
Calendar feed
The calendar-feed routes answer the caller's feed:
{"data": {"url": "https://bm.jakubzych.com/_bm/calendar/<64 hex digits>.ics",
"excluded_course_ids": [9, 10], "include_meetings": true, "include_deadlines": true,
"last_fetched_at": "2026-10-02T21:15:04+02:00"}}
GET answers {"data": null} while the caller has no feed. PUT takes a JSON object whose keys are all optional and creates the feed with a new token and the defaults ([], true, true) when there is none:
{"excluded_course_ids": [9, 10], "include_meetings": true, "include_deadlines": false}
excluded_course_ids replaces the whole list (duplicates are dropped); every ID must be an existing course, otherwise the answer is a 422 on excluded_course_ids.<index>. The booleans take true/false (or 1/0). A body that is not a JSON object is a 400. last_fetched_at is the last time a calendar fetched the feed (written at most once a minute), or null.
url starts with jz.bm.public_url, or with the scheme and Host of the request when that key is empty.
The feed itself, GET /_bm/calendar/{token}.ics (also without .ics), needs no authentication: calendar clients fetch it without credentials, so the 32 random bytes of the token are the credential. Rotate the token when the URL leaks. An unknown or malformed token is a 404. The route is throttled per client IP (throttle:jz.bm.calendar-ip, 60 a minute) and per token (throttle:jz.bm.calendar-token, 20 a minute). The answer is text/calendar; charset=utf-8 with Content-Disposition: inline; filename="bm.ics", Cache-Control: private, max-age=900 and an ETag (304 on revalidation).
The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with times in UTC, so it needs no VTIMEZONE; X-WR-TIMEZONE is Europe/Warsaw and REFRESH-INTERVAL/X-PUBLISHED-TTL ask for a refresh every 6 hours (Google refreshes subscribed calendars on its own schedule, usually every few hours). It lists:
- every class session of the courses not excluded: UID
session-<id>@bm.jakubzych.com, summary<course> – <form>(wykład,ćwiczenia,konwersatorium,seminarium; nothing forinne) plus(online), locationKoszarowa 3, s. <room> (bud. 21 ISMiB)oronline, and a description with the lecturer, the meeting number, the notes, "Przedmiot do wyboru" for an elective and a link to/przedmioty/<course id>; - with
include_meetings, every meeting as an all-day event (meeting-<id>@…,3. zjazd (stacjonarny)or(online)read from the notes,TRANSP:TRANSPARENT); - with
include_deadlines, every deadline not tied to an excluded course, as a 30-minute event ending at the due time (deadline-<id>@…,Termin: <title>) with a display alarm a day before.
DTSTAMP and LAST-MODIFIED are the row's updated_at, so an unchanged timetable gives the same bytes.
Student app
The built single-page app (Vue, source in the application repository's frontend/) is embedded from public/ and served at /. Existing files are served as they are: assets/* (content-hashed) with Cache-Control: public, max-age=31536000, immutable, everything else with no-cache and an ETag (304 on revalidation). A GET for any other extension-less path answers index.html (client routes); a missing path with an extension is a 404. /_user, /_bm, /_quizzes, /storage and the admin path (backend.uri, default /bmadmin) are never answered by the app. Routes fails when public/index.html is missing. The output of npm run build in frontend/ is committed in public/.
Admin
Controllers (admin API path <backend.uri>/api/v1/jz/bm/<name>), all in the "Studia BM" navigation group:
| Controller | Permission |
|---|---|
jz.bm.courses, jz.bm.readings |
jz.bm.access_courses |
jz.bm.meetings, jz.bm.class_sessions, jz.bm.deadlines |
jz.bm.access_schedule |
jz.bm.materials, jz.bm.tags |
jz.bm.access_materials |
Courses, meetings, sessions and deadlines are picked through relation fields; a material's tags through a belongsToMany relation field. Dates and times are edited with date pickers (type: datepicker); the admin shows times in the browser's time zone and stores them in UTC. Labels are in Polish (lang/pl) and English (lang/en).
A material's files are uploaded in its form (type: fileupload on the attachMany files, public, up to 20 files of 50 MB). The course form embeds relation managers for its readings and class sessions, which create, edit and delete them, also before the course is first saved: course_id on both tables is nullable for that, the pending rows are bound to the form session and get the course on its first save, and deferred:purge deletes abandoned ones. The student API leaves out sessions and readings without a course. A class session added from the course form has no meeting; set it on the class sessions screen.
Configuration
Defaults in config/config.yaml, read as jz.bm.<key>:
| Key | Default | Sets |
|---|---|---|
jz.bm.materials_per_page |
20 |
Default page size of /materials (clamped to 1–100). |
jz.bm.dashboard_limit |
5 |
Sessions and deadlines on /dashboard. |
jz.bm.public_url |
"" |
Absolute base URL of the application (https://bm.jakubzych.com), used in the calendar feed URL and the course links inside the feed. Empty means the scheme and Host of the request; set it behind a TLS-terminating proxy. The application overrides it in config/jz.bm.yaml. |
CLI commands
bm:seed
./bin/bm bm:seed data/bm-2026-27.json
./bin/bm bm:seed data/bm-2026-27.json --dry-run --prune
./bin/bm bm:seed data/bm-2026-27.json --prune
Reads a JSON file, validates all of it, and upserts it in one transaction. Each kind is matched on its identity, updated in place when a field changed, and created otherwise; rows that are not in the file are left alone, so running the same file again changes nothing. Any error (an unknown key in the document, a bad value, a course key or meeting number that does not exist in the file) names the record and writes nothing. The command prints a table of created, updated, unchanged and deleted rows per kind.
--prune makes the file authoritative for what it covers: it deletes the courses of the file's semesters that the file does not list (their sessions and readings go with them, their deadlines and materials are unlinked), then the class sessions and readings of the listed courses that the file does not list. Reading progress of a deleted reading is deleted with it. Meetings and deadlines are never pruned. Each deleted row is printed. Use it when a timetable changes: a moved session is a new start time, so without --prune the old one would stay.
--dry-run applies the seed (and the prune) in a transaction that is rolled back, so the table and the deleted rows show what a real run would do.
| Kind | Identity |
|---|---|
| meetings | number |
| courses | name + form + semester |
| class_sessions | course + starts_at |
| deadlines | title + due_at |
| readings | course + title |
The file format (every top-level array is optional, and nullable fields may be left out):
{
"meetings": [{"number": 1, "starts_on": "2026-10-03", "ends_on": "2026-10-04", "notes": "stacjonarny"}],
"courses": [{"key": "glob-w", "code": null, "name": "…", "form": "wyklad", "lecturer": "…", "semester": 1,
"ects": 8, "assessment": "egzamin", "elective_group": null, "description": null, "syllabus_url": null, "sort_order": 1}],
"class_sessions": [{"course": "glob-w", "meeting": 1, "starts_at": "2026-10-03T08:00:00+02:00", "ends_at": "2026-10-03T09:30:00+02:00",
"room": "237", "kind": null, "is_online": false, "notes": null}],
"deadlines": [{"title": "…", "due_at": "2026-10-05T23:59:00+02:00", "course": null, "kind": "organizacyjne", "description": null}],
"readings": [{"course": "glob-w", "title": "…", "author": null, "source": null, "url": null, "is_required": true, "sort_order": 1}]
}
keyis a reference inside the file only;coursein sessions, deadlines and readings names a coursekey,meetingin sessions a meetingnumber.form:wyklad,cwiczenia,konwersatorium,seminarium,inne.semester: 1–4.kindof a deadline:zaliczenie,egzamin,praca,organizacyjne,inne.- Dates are
YYYY-MM-DD; times RFC 3339 with an offset, orYYYY-MM-DD HH:MMin Polish time. - A blank optional text is stored as null.
testdata/seed.example.json is a small example with made-up data.
Dependencies
- SummerCMS framework modules:
pact,party,backpack,surf,wire,bouncer,lagoon(withlagoon/attach),cabana,bonfire,phrasebook. - The
golem15.userplugin (git.golem15.com/golem15/sm-user-plugin): theuserstable and thejwt.authmiddleware.jz.bmdeclares it inRequires, so its migrations run first. - GORM and gormigrate. Tests also use testcontainers-go and pgx.
Testing
go vet ./...
go test ./... # unit tests and Postgres integration tests (Docker)
go test -short ./... # unit tests only
The integration tests start a postgres:16-alpine container through testcontainers and create a fresh database per test; they never touch a development database. They cover the migrations up and down, the schema constraints, bm:seed (idempotency, in-place updates, all-or-nothing, prune and dry run), every API route through the real jwt.auth guard, the calendar feed (per-user isolation, exclusions, rotation, headers), and admin saves through the admin API.