2026-10-02 14:26:43 +02:00
2026-10-02 14:26:43 +02:00
2026-10-02 14:26:43 +02:00

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, Material with file attachments (system_files, field files) and Tag, many-to-many through jz_bm_material_tag.
  • Courses that share an elective_group are alternatives ("do wyboru"), such as the specialisation courses.
  • A material without published_at is a draft; a material whose published_at lies in the future is not shown yet.
  • Cascades: deleting a course deletes its class sessions and readings and unlinks its deadlines and materials; deleting a meeting unlinks its sessions; deleting a reading or a user deletes the progress rows.
  • 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.

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

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. --dry-run only validates. The command prints a table of created, updated and unchanged rows per kind.

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}]
}
  • key is a reference inside the file only; course in sessions, deadlines and readings names a course key, meeting in sessions a meeting number.
  • form: wyklad, cwiczenia, konwersatorium, seminarium, inne. semester: 1–4. kind of a deadline: zaliczenie, egzamin, praca, organizacyjne, inne.
  • Dates are YYYY-MM-DD; times RFC 3339 with an offset, or YYYY-MM-DD HH:MM in 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 (with lagoon/attach), cabana, bonfire, phrasebook.
  • The golem15.user plugin (git.golem15.com/golem15/sm-user-plugin): the users table and the jwt.auth middleware. jz.bm declares it in Requires, 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), every API route through the real jwt.auth guard, and admin saves through the admin API.

Description
No description provided
Readme 4.3 MiB
Languages
Go 96.6%
HTML 2%
JavaScript 1.4%