14 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 one thing only: which readings they have read.
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 typed without an offset (in the admin or 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. - 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.
- 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 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 |
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"}]}}
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 are typed as 2026-10-03 and times as 2026-10-03 08:00 (Polish time) or RFC 3339; a value that does not parse is a 422 on its field. Labels are in Polish (lang/pl) and English (lang/en).
Two things the admin cannot do yet, because the framework does not offer them: upload a material's files (there is no file upload form widget, so files are attached as system_files rows outside the admin), and list a course's readings and sessions inside the course form (relation managers handle only belongsToMany links). Both are marked TODO: requires SummerCMS change in the code.
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. |
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, and admin saves through the admin API.