Files
sm-bm-plugin/README.md
2026-10-02 14:43:02 +02:00

235 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```go
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:
```json
{"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:
```json
{"id": 1, "code": null, "name": "…", "form": "wyklad", "semester": 1}
```
`form` is one of `wyklad`, `cwiczenia`, `konwersatorium`, `seminarium`, `inne`.
A class session:
```json
{"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:
```json
{"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):
```json
{"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):
```json
{"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):
```json
{"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):
```json
{"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
```json
{"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:
```json
{"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:
```json
{"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}
```json
{"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`:
```json
{"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
```sh
./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):
```json
{
"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
```sh
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.