Files
sm-bm-plugin/README.md

14 KiB
Raw Blame History

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, with --prune to delete what the file no longer lists and --dry-run to 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}]
}
  • 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, prune and dry run), every API route through the real jwt.auth guard, and admin saves through the admin API.