docs: add the plugin README
Overview, features, the API reference with every route and response shape, admin screens, configuration, the bm:seed file format, dependencies and testing.
This commit is contained in:
230
README.md
Normal file
230
README.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# 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"}]}}
|
||||
```
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user