Files
sm-quizzes-plugin/README.md
2026-11-09 14:34:41 +01:00

380 lines
30 KiB
Markdown

# golem15.quizzes
A reusable quiz plugin for SummerCMS applications: quizzes with single-choice, multiple-choice and self-check (flashcard) questions, a learning and a test mode that withholds solutions until an attempt is finished, scored attempts for signed-in users, an admin backend, a JSON importer and owner management over a token API and MCP tools.
```go
import _ "git.golem15.com/golem15/sm-quizzes-plugin"
```
Module `git.golem15.com/golem15/sm-quizzes-plugin`, package `quizzes`, plugin ID `golem15.quizzes`.
## Overview
The plugin is a compiled SummerCMS plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`. It requires `golem15.user`: the API runs behind that plugin's `jwt.auth` middleware, and attempts reference its `users` table.
The plugin knows nothing about the application around it. A quiz links to a record of another plugin through an optional free-form `context` string, such as `acme.courses:course:12`; clients list the quizzes of a context with `GET /quizzes?context=...`.
It owns five tables:
| Table | Holds |
|-------|-------|
| `golem15_quizzes_quizzes` | Quizzes: title, unique slug, description, context, published flag, time limit, passing score, question shuffling, round size, and the test settings `mode`, `navigation` and `max_attempts`. |
| `golem15_quizzes_questions` | Questions of a quiz: text, type, category, model answer, explanation, sort order and points. |
| `golem15_quizzes_answers` | Answers of a single or multiple question, with `is_correct` and an explanation. |
| `golem15_quizzes_attempts` | One user's run through a quiz, or through one category of it, with the score once finished. |
| `golem15_quizzes_attempt_answers` | The user's answer to one question of an attempt; one row per attempt and question. |
Deleting a quiz deletes its questions, answers and attempts. Deleting a question deletes its answers and the attempt answers given to it. Deleting a user deletes their attempts.
## Features
- **Three question types.** `single` (one correct answer), `multiple` (one or more correct answers) and `self_check`, a flashcard: the client shows the question, the user reveals `answer_text` and marks themselves as knowing it or not.
- **Categories.** A question may carry a category. `GET /quizzes/{slug}?category=` returns one category, and an attempt can be limited to one category.
- **Rounds and shuffling.** `round_size` tells the client to present the questions in rounds of N; `shuffle_questions` shuffles the question order on every `GET /quizzes/{slug}`.
- **Attempts.** A user starts an attempt, answers questions in any order (answering again overwrites the earlier answer and returns fresh feedback) and finishes it. Answers are refused after the time limit; finishing is still allowed and counts the answers given in time.
- **Scoring.** A pure function in `classes` (`classes.Score`, `classes.Totals`) with optional partial credit for multiple questions and an optional penalty for wrong answers. See [Configuration](#configuration).
- **No solution leaks.** The quiz payload never contains `is_correct` or explanations, and `answer_text` only for `self_check` flashcards, which have nothing to hide. Feedback reveals the solution of the question just answered; a result reveals the solutions of answered questions, and of every question once the attempt is finished.
- **Test mode.** A quiz set to `test` withholds every solution until the attempt is finished, and the server enforces the attempt limit, the time limit and one-way navigation. See [Test mode](#test-mode).
- **Admin backend.** Quizzes, Questions (filtered by quiz, category and type) and Answers (filtered by quiz) under the `golem15.quizzes.access_quizzes` permission, and a read-only Attempts list under `golem15.quizzes.access_attempts`. Polish and English labels.
- **Importer.** `quizzes:import` loads a quiz from a JSON file, idempotent by slug. See [CLI commands](#cli-commands).
## Test mode
A quiz has a `mode`: `learning` (the default; every existing quiz) or `test`. Learning quizzes behave exactly as before test mode existed. Two more settings only apply to tests: `navigation` (`free`, the default, or `one_way`) and `max_attempts` (`null` for unlimited).
- **Withholding.** While a test attempt is open, no student response carries a solution: the answer feedback has `withheld: true` and `null` for `is_correct`, `points`, `correct_answer_ids`, `explanation` and `answer_text`; the result has `solutions_withheld: true` and `null` for `is_correct`, `points_earned`, `correct_answer_ids`, `explanation` and `answer_text` on every question, answered or not, and no answer carries `is_correct` or `explanation`. The student's own input (`answered`, `answer_ids`, `known`) stays. `GET /quizzes/{slug}` of a test lists no questions (`questions: []`, `question_count` counts them), so a timed test cannot be read before its clock starts; the test player reads the questions from `GET /attempts/{id}`.
- **Full review after finish.** Once `completed_at` is set (finish, or a lazy close) the result reveals everything, exactly as for a finished learning attempt, with `passed` against `passing_score`.
- **Attempt limit.** `max_attempts` counts every attempt of the user on the quiz, open or finished, in any mode. A start past the limit is a 409 with `code: attempt_limit_reached`. Learning quizzes have no limit.
- **Resume.** Starting a test while an open, unexpired attempt exists returns that attempt (200, `resumed: true`) instead of creating one, so a page reload does not burn an attempt. Starts are serialised per quiz and user with a Postgres advisory lock (`pg_advisory_xact_lock`), so parallel starts cannot exceed the limit.
- **Time limit and grace.** An answer to a test is refused (409, `code: time_limit_exceeded`) after `started_at + time_limit_seconds` plus a grace for answers still in flight, `golem15.quizzes.test.grace_seconds` (default 5). Learning quizzes have no grace. Finishing stays allowed after the deadline; unanswered questions score 0. `seconds_left` on every attempt row is counted by the server.
- **Lazy close.** A test attempt past its deadline and grace is closed on the next start of that quiz or the next `GET /attempts/{id}`: `completed_at` is set to its deadline and it is scored with the answers given in time, as a finish would. No job queue is involved.
- **Whole quiz only.** A test always covers every question: starting one with a `category` is a 422 on `category` (`category_not_allowed`), and `GET /quizzes/{slug}` of a test ignores `?category=`.
- **Stable order.** With `shuffle_questions`, a test attempt's questions come in an order seeded by the attempt, the same on every `GET /attempts/{id}` and on finish.
- **One-way lock.** With `navigation: one_way` an answer is final: a second answer to the same question is a 409 with `code: answer_locked` and the stored answer is unchanged. With `free` an answer is overwritten until the attempt is finished.
## Usage
Mount the plugin in an application as a git submodule at `plugins/golem15/quizzes`, next to `plugins/golem15/user`, add it to the application's `go.work`, require it in the application's `go.mod` with a local replace, and list it in `summer.yaml` after `golem15.user`:
```yaml
plugins:
- id: golem15.user
module: git.golem15.com/golem15/sm-user-plugin
- id: golem15.quizzes
module: git.golem15.com/golem15/sm-quizzes-plugin
```
The plugin's own `go.mod` replaces the framework with `../../../../summercms.go` and `golem15.user` with `../user`, so it expects the framework checkout next to the application checkout.
Run `migrate` after `summer build` to create the tables, then import a quiz and publish it in the admin (or set `"is_published": true` in the file).
## API reference
All routes are mounted under `/_quizzes/api/v1` with the `jwt.auth` middleware of `golem15.user`: send `Authorization: Bearer <token>`. A missing or invalid token is a 401 from the guard. Every successful body wraps its payload in `data`. Timestamps are UTC in the form `2026-10-02T12:00:00+00:00`.
| Method and path | Handler | Does |
|-----------------|---------|------|
| `GET /quizzes` | `api.Handlers.ListQuizzes` | Published quizzes, by title, with the caller's statistics. Query: `context`. |
| `GET /quizzes/{slug}` | `api.Handlers.ShowQuiz` | A published quiz with its questions and answers, without solutions (a `self_check` question carries its `answer_text`); a test lists no questions. Query: `category`. |
| `POST /quizzes/{slug}/attempts` | `api.Handlers.StartAttempt` | Starts an attempt, or resumes an open test attempt. Body (optional): `{"category": "..."}`. |
| `POST /attempts/{id}/answers` | `api.Handlers.Answer` | Saves or overwrites the answer to one question and returns feedback. |
| `POST /attempts/{id}/finish` | `api.Handlers.Finish` | Finishes the attempt and returns the result. |
| `GET /attempts/{id}` | `api.Handlers.ShowAttempt` | The result with per-question details. |
| `GET /attempts` | `api.Handlers.ListAttempts` | The caller's attempts, newest first. Query: `quiz` (slug), `page`, `per_page` (default 20, at most 100). |
`{slug}` matches `[a-z0-9-]+` and `{id}` matches `[0-9]+`; anything else is a 404.
### Errors
| Status | Body | When |
|--------|------|------|
| 401 | `{"error": true, "message": "Token not provided"}` | No or invalid token (from the guard). |
| 404 | `{"error": "Quiz not found"}` | Unknown or unpublished slug. |
| 404 | `{"error": "Attempt not found"}` | Unknown attempt, or another user's attempt. |
| 409 | `{"error": "The attempt is already completed.", "code": "attempt_completed"}` | Answering or finishing a finished attempt. |
| 409 | `{"error": "The time limit of this attempt has been exceeded.", "code": "time_limit_exceeded"}` | Answering after `started_at + time_limit_seconds` (plus the grace of a test). |
| 409 | `{"error": "All attempts at this test have been used.", "code": "attempt_limit_reached"}` | Starting a test after `max_attempts` attempts. |
| 409 | `{"error": "An answer in this test cannot be changed.", "code": "answer_locked"}` | Answering a question of a `one_way` test again. |
| 413 | `{"error": "Payload too large"}` | A body over the route's limit. |
| 422 | `{"error": "<first message>", "errors": {"<field>": ["<message>"]}}` | Invalid input, the shape `golem15.user` uses. Keys follow the rule order. Starting a test with a category is a 422 on `category`: "A test covers the whole quiz; a category cannot be chosen." |
Every 409 body carries `code`, a stable key clients can branch on; the `error` message is for people. The messages are translated into the request locale (`golem15.quizzes::lang.api.*`, Polish and English).
### GET /quizzes
```json
{"data": [{
"id": 1,
"slug": "security-basics",
"title": "Security basics",
"description": "Flashcards and checks.",
"context": "acme.courses:course:12",
"question_count": 54,
"time_limit_seconds": null,
"passing_score": 50,
"round_size": 5,
"shuffle_questions": false,
"best_score_percent": 83.33,
"attempts_count": 2,
"mode": "test",
"navigation": "free",
"max_attempts": 3,
"attempts_left": 1,
"question_types": {"single": 20, "multiple": 10, "self_check": 24}
}]}
```
`attempts_count` counts the caller's finished attempts and `best_score_percent` is their best finished result (`null` before the first one). `question_types` counts the questions per type, with all three keys always present. `attempts_left` is how many attempts the caller may still start at a test with a limit (every attempt counts, open or finished); it is `null` for a learning quiz or a test without a limit.
### GET /quizzes/{slug}
```json
{"data": {
"id": 1, "slug": "security-basics", "title": "Security basics", "description": null,
"context": "acme.courses:course:12", "time_limit_seconds": null, "passing_score": 50,
"round_size": 5, "shuffle_questions": false,
"question_count": 2,
"categories": ["Documents", "Theory"],
"mode": "learning", "navigation": "free", "max_attempts": null, "attempts_left": null,
"questions": [
{"id": 10, "text": "What is societal resilience?", "type": "self_check", "category": "Theory",
"points": 1, "sort_order": 1, "answer_text": "The ability of a society to withstand a crisis.", "answers": []},
{"id": 11, "text": "Which is a strategy document?", "type": "single", "category": "Documents",
"points": 1, "sort_order": 2, "answers": [{"id": 30, "text": "National Security Strategy"}, {"id": 31, "text": "Highway Code"}]}
]
}}
```
`categories` lists every category of the quiz; with `?category=` the questions are only that category's and `question_count` counts them. Questions come in `sort_order`, or shuffled when `shuffle_questions` is true. Answers come in their sort order. For a test, `questions` is empty, `question_count` counts the whole quiz, `?category=` is ignored and `attempts_left` is set as in the list.
### POST /quizzes/{slug}/attempts
Body, optional: `{"category": "Theory"}` limits the attempt to one category; a category without questions is a 422 on `category`, and a test refuses any category. Answers status 201 with a new attempt, or 200 when an open test attempt is resumed:
```json
{"data": {
"id": 7, "quiz_id": 1, "quiz_slug": "security-basics", "quiz_title": "Security basics",
"category": null,
"started_at": "2026-10-02T12:00:00+00:00",
"expires_at": null,
"completed_at": null,
"answered_count": 0,
"score_points": 0, "max_points": 54, "score_percent": null,
"passing_score": 50, "passed": null,
"quiz_mode": "learning", "quiz_navigation": "free", "seconds_left": null,
"resumed": false, "attempts_left": null
}}
```
`expires_at` is `started_at + time_limit_seconds`, or `null` without a limit. `seconds_left` is the whole seconds until `expires_at`, rounded up and never below 0, counted by the server; it is `null` without a limit and once the attempt is finished. `quiz_mode` and `quiz_navigation` tell the player which flow to run. `max_points` sums the points of the questions the attempt covers. This attempt shape, without `resumed` and `attempts_left`, is also each row of `GET /attempts` and the head of a result. `resumed` is true when an open test attempt was returned; `attempts_left` counts the attempts left after this one (`null` for a learning quiz or without a limit).
### POST /attempts/{id}/answers
Body:
| Field | Rules |
|-------|-------|
| `question_id` | Required integer; the question must belong to the attempt's quiz (and category). |
| `answer_ids` | Array of answer IDs. Required for `single` (exactly one) and `multiple`; every ID must belong to the question. |
| `known` | Boolean. Required for `self_check`. |
Checks run in this order: the body (422), the attempt belongs to the caller (404), it is not finished (409), it is within the time limit plus the grace of a test (409), the question belongs to it (422 on `question_id`), a `one_way` test has no answer to it yet (409), the answer fits the question (422 on `answer_ids` or `known`). Answering a question again overwrites the stored answer. Answers status 200:
```json
{"data": {
"question_id": 11,
"withheld": false,
"is_correct": false,
"points": 0,
"correct_answer_ids": [30],
"explanation": "A strategy sets goals and the ways to reach them.",
"answer_text": null,
"answers": [
{"id": 30, "text": "National Security Strategy", "is_correct": true, "explanation": null},
{"id": 31, "text": "Highway Code", "is_correct": false, "explanation": "A traffic law."}
]
}}
```
`points` can be negative when `scoring.penalty_wrong` is set. For a `self_check` question `correct_answer_ids` and `answers` are empty and `answer_text` holds the model answer. For an open test attempt `withheld` is true and `is_correct`, `points`, `correct_answer_ids`, `explanation` and `answer_text` are `null`; the answers carry only `id` and `text` (`is_correct` and `explanation` `null`).
### POST /attempts/{id}/finish and GET /attempts/{id}
Finishing sums the stored points, floors the total at 0, sets `score_percent = score_points / max_points * 100` (two decimals) and `completed_at`. Finishing a finished attempt is a 409. Both routes answer the attempt shape plus `questions`:
```json
{"data": {
"id": 7, "quiz_id": 1, "quiz_slug": "security-basics", "quiz_title": "Security basics", "category": null,
"started_at": "2026-10-02T12:00:00+00:00", "expires_at": null, "completed_at": "2026-10-02T12:09:41+00:00",
"answered_count": 2, "score_points": 1, "max_points": 2, "score_percent": 50,
"passing_score": 50, "passed": true,
"quiz_mode": "learning", "quiz_navigation": "free", "seconds_left": null,
"solutions_withheld": false,
"questions": [
{"id": 10, "text": "What is societal resilience?", "type": "self_check", "category": "Theory", "points": 1,
"answered": true, "answer_ids": [], "known": true, "is_correct": true, "points_earned": 1,
"correct_answer_ids": [], "explanation": null, "answer_text": "The ability to withstand a crisis...", "answers": []},
{"id": 11, "text": "Which is a strategy document?", "type": "single", "category": "Documents", "points": 1,
"answered": true, "answer_ids": [31], "known": null, "is_correct": false, "points_earned": 0,
"correct_answer_ids": [30], "explanation": "A strategy sets goals...", "answer_text": null,
"answers": [{"id": 30, "text": "National Security Strategy", "is_correct": true, "explanation": null},
{"id": 31, "text": "Highway Code", "is_correct": false, "explanation": "A traffic law."}]}
]
}}
```
`passed` is `score_percent >= passing_score` once the attempt is finished, else `null`. While the attempt is open, an unanswered question has `correct_answer_ids`, `explanation` and `answer_text` set to `null` and `is_correct`/`explanation` of its answers set to `null`; its own `is_correct` is `false` and `points_earned` 0. While a test attempt is open `solutions_withheld` is true and every question, answered or not, has `is_correct`, `points_earned`, `correct_answer_ids`, `explanation` and `answer_text` set to `null`, with no answer `is_correct` or `explanation`; `answered`, `answer_ids` and `known` stay. With `shuffle_questions` a test attempt lists its questions in an order seeded by the attempt, the same on every call; other attempts list them in sort order.
### GET /attempts
```json
{"data": [ /* attempt shapes, newest first */ ],
"meta": {"current_page": 1, "last_page": 1, "per_page": 20, "total": 2}}
```
### Packages
- `classes`: `Score`, `Totals` and `ScoringConfigFrom` (scoring); `Service` (the API's queries and writes); `IsTest`, `Withheld`, `OneWay`, `AnswerDeadline`, `Expired`, `SecondsLeft`, `AttemptsLeft`, `AttemptLockKey` and `TestGraceFrom` (the test-mode policy); `ParseImport`, `Import` and `CheckQuestion` (the importer and its per-question rules); `ListManagedQuizzes`, `ManagedQuiz`, `CreateQuiz`, `UpdateQuiz`, `DeleteQuiz`, `SetPublished`, `CreateQuestion`, `UpdateQuestion`, `MergeQuestion`, `DeleteQuestion`, `ReorderQuestions`, `QuizStats` and `BuildStats` (owner management).
- `controllers/api`: `Handlers`, the HTTP handlers.
- `controllers`: the admin controllers `QuizzesController`, `QuestionsController`, `AnswersController` and `AttemptsController`.
- `models`: `Quiz`, `Question`, `Answer`, `Attempt`, `AttemptAnswer` and `Slugify`.
- `console`: `ImportCommand`.
## Management API and MCP tools
The owner manages quizzes from an MCP client (or any HTTP client) with a `golem15.user` API token. Every route below runs behind `user.api_token`, the per-user throttle `golem15.quizzes.manage` (120 requests a minute), the token scope named in the table and the `golem15.quizzes.access_quizzes` permission. The plugin declares that permission through `pact.HasFrontendPermissions`, so it shows up in golem15.user's User Groups editor. Grant it to the owner's group only, because the stats show student emails and names. Declaring it grants nothing. There is no owner column: a holder of the permission manages every quiz, as in the admin. Quizzes are addressed by slug.
| Method and path under `/api/v1/quizzes` | Scope | MCP tool | Answer |
|---|---|---|---|
| `GET ""` (`?context=`) | read | `quizzes_list_quizzes` | Every quiz, published or not, ordered by context (none last) and title. |
| `GET /{slug}` | read | `quizzes_show_quiz` | The quiz with its questions and answers, solutions included. |
| `POST ""` | write | `quizzes_create_quiz` | 201. The new quiz starts unpublished. A taken slug is a 409. |
| `PATCH /{slug}` | write | `quizzes_update_quiz` | Only sent fields change. `0` clears `time_limit_seconds`, `max_attempts` and `round_size`, and `""` clears `description` and `context`. It cannot publish or change the slug. |
| `DELETE /{slug}?confirm={slug}` | write | `quizzes_delete_quiz` | `{id, slug, title, questions, attempts}`. A 422 unless `confirm` repeats the slug. |
| `POST /{slug}/publish`, `POST /{slug}/unpublish` | write | `quizzes_publish`, `quizzes_unpublish` | The quiz. Both are idempotent. |
| `GET /{slug}/stats` | read | `quizzes_quiz_stats` | `students` and `questions` (see below). |
| `POST /{slug}/questions` | write | `quizzes_create_question` | 201 with the question. It goes last unless `sort_order` is given, and its answers come inline. |
| `PATCH /{slug}/questions/{id}` | write | `quizzes_update_question` | The question. Only sent fields change. A sent `answers` list keeps and updates the answers with an `id`, creates the ones without and deletes the missing ones. |
| `DELETE /{slug}/questions/{id}` | write | `quizzes_delete_question` | `{id, quiz_slug}`. |
| `PUT /{slug}/questions/order` | write | `quizzes_reorder_questions` | The quiz with its questions. `question_ids` must list every question exactly once. |
| `POST /import?replace=` | write | `quizzes_import_quiz` | The import route (see CLI commands). It has its own `throttle:60,1` and is unchanged. |
The quiz shape is `id, slug, title, description, context, mode, navigation, max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size, is_published, question_count, attempts_count, created_at, updated_at`. The question shape is `id, text, type, category, answer_text, explanation, points, sort_order, answers [{id, text, is_correct, explanation, sort_order}]`. These owner shapes are never used by a student route.
Input is checked before the database is read. Invalid JSON, an unknown key or a broken rule is a 422 `{"message"}`. A merged question must pass the importer's rules (`classes.CheckQuestion`), and an answer `id` that belongs to another question is refused. A missing quiz or question is a 404.
**History (D-16).** A question can be edited while its quiz has attempts. Finished attempts keep their stored scores, and their reviews show the current question. Answers are updated by id and never deleted and recreated, so the answer ids stored in past attempts stay valid. Deleting a question cascades to its answers and to the attempt answers given to it, and an import with `replace` does the same for every question. On a quiz with attempts, prefer the per-question tools.
**Stats.** The stats route first closes the quiz's expired open test attempts at their deadline. `students [{user_id, email, name, surname, attempts, best_score_percent, last_score_percent, last_completed_at, passed}]` counts each student's completed whole-quiz attempts. Learning rounds of one category are left out, and `passed` is `best_score_percent >= passing_score`. `questions [{id, text, type, answered, correct, correct_percent, most_chosen_wrong}]` counts the answers of every completed attempt. `correct_percent` is `null` when nobody answered. `most_chosen_wrong` is `{answer_id, text, count}`, or `null` for self_check questions and when no wrong answer was chosen.
## Configuration
Defaults live in `config/config.yaml` and are merged under `golem15.quizzes.*`.
| Key | Default | Meaning |
|-----|---------|---------|
| `golem15.quizzes.scoring.multiple_partial` | `false` | Partial credit on `multiple` questions: `(correct chosen - wrong chosen) / correct answers` of the points, floored at 0. Without it only the exact set earns points. |
| `golem15.quizzes.scoring.penalty_wrong` | `0` | Fraction (0 to 1) of a question's points subtracted for a wrong answer: a non-empty `single` or `multiple` answer that earns nothing. `self_check` answers marked as unknown and empty answers are never penalised. An attempt's total never goes below 0. |
| `golem15.quizzes.test.grace_seconds` | `5` | Seconds after a test's time limit during which an answer still in flight is accepted, clamped to 0..120. A missing or non-numeric value is 5. Learning quizzes have no grace. |
Scores are rounded to two decimals.
### Admin
| Controller | Path | Permission |
|------------|------|------------|
| Quizzes | `golem15/quizzes/quizzes` | `golem15.quizzes.access_quizzes` |
| Questions | `golem15/quizzes/questions` | `golem15.quizzes.access_quizzes` |
| Answers | `golem15/quizzes/answers` | `golem15.quizzes.access_quizzes` |
| Attempts (read-only) | `golem15/quizzes/attempts` | `golem15.quizzes.access_attempts` |
The quiz form has three test settings: Mode (Learning / Test), Test navigation (Free / One way) and Attempt limit (empty for none); the last two say in their comments that they apply to tests only. The quiz list shows the mode.
The navigation item is "Quizzes" ("Quizy" in Polish). The admin's relation manager links records through a pivot table only, so questions and answers have their own lists instead of being edited inside the quiz form; filter them by quiz.
## CLI commands
| Command | Purpose |
|---------|---------|
| `quizzes:import <file.json> [--replace]` | Import one quiz from a JSON file. |
The same import is served over HTTP as `POST /api/v1/quizzes/import` (add `?replace=1` for `--replace`), with the import document as the body. The route runs behind the application-wide API token of `golem15.user` (`user.api_token`, created with `user:token:create`): the token needs the `write` scope and its user the `golem15.quizzes.access_quizzes` permission in one of their user groups. It answers `{"data": {"id", "slug", "created", "questions", "answers"}}`, 422 with a `message` for a document that does not parse, and 409 for an existing slug without `replace`. It needs a `golem15.user` that ships the shared API tokens.
The import runs in one transaction and is idempotent by slug:
- a new slug creates the quiz;
- an existing slug is refused unless `--replace` is given;
- with `--replace` the quiz's fields are updated and all its questions are deleted and recreated. The attempts stay, but the attempt answers given to the old questions are deleted with them.
The file is checked before anything is written, and unknown keys are an error. Format:
```json
{
"title": "Security basics",
"slug": "security-basics",
"description": "Flashcards and checks.",
"context": "acme.courses:course:12",
"is_published": true,
"passing_score": 60,
"round_size": 5,
"time_limit_seconds": 1800,
"shuffle_questions": false,
"questions": [
{"text": "What is societal resilience?", "type": "self_check", "category": "Theory",
"answer_text": "The ability of a society to withstand a crisis and recover."},
{"text": "Which is a strategy document?", "type": "single", "category": "Documents",
"explanation": "A strategy sets goals and the ways to reach them.",
"answers": [
{"text": "National Security Strategy", "is_correct": true},
{"text": "Highway Code", "is_correct": false, "explanation": "A traffic law."}
]},
{"text": "Which countries are NATO members?", "type": "multiple", "points": 2,
"answers": [
{"text": "Poland", "is_correct": true},
{"text": "Sweden", "is_correct": true},
{"text": "Switzerland", "is_correct": false}
]}
]
}
```
| Field | Required | Notes |
|-------|----------|-------|
| `title` | yes | |
| `slug` | no | Normalised to lowercase ASCII with hyphens; built from the title when missing. |
| `description`, `context` | no | |
| `is_published` | no | Default `false`. |
| `passing_score` | no | 0 to 100, default 50. |
| `round_size`, `time_limit_seconds` | no | At least 1; `null` or missing for none. |
| `shuffle_questions` | no | Default `false`. |
| `mode` | no | `learning` or `test`. Missing keeps the quiz's current mode on `--replace` and `learning` on create. |
| `navigation` | no | `free` or `one_way` (tests only). Missing keeps the current value on `--replace` and `free` on create. |
| `max_attempts` | no | At least 1 (tests only). Missing keeps the current value on `--replace` and no limit on create. |
| `questions` | yes | At least one. The file order becomes `sort_order`. |
| `questions[].text`, `questions[].type` | yes | `type` is `single`, `multiple` or `self_check`. |
| `questions[].category`, `answer_text`, `explanation` | no | |
| `questions[].points` | no | Default 1, not negative. |
| `questions[].answers` | for `single` and `multiple` | `single`: at least two answers, exactly one correct. `multiple`: at least two, one or more correct. `self_check`: none. |
| `answers[].text` | yes | |
| `answers[].is_correct`, `answers[].explanation` | no | |
`testdata/quiz.json` is a complete example.
## Dependencies
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `cabana`, `lagoon`, `pact`, `party`, `phrasebook`, `surf`, `wire`).
- `golem15.user`, `git.golem15.com/golem15/sm-user-plugin`: the `jwt.auth` middleware and the `users` table.
- gormigrate and GORM, as pinned in `go.mod`.
- testcontainers-go and pgx for the Postgres-backed tests.
## Testing
```sh
go test -short ./... # fast run, no Docker
go test ./... # full run, starts Postgres with testcontainers (needs Docker)
```
The unit tests cover scoring, request validation, the handlers without a database, the response shapes, the import parser and the admin schema compilation. The Postgres tests run the whole API flow, time limits, cascades, the admin lists and filters and the import command against a throwaway database per test.
Known framework limitations are marked `TODO: requires SummerCMS change` in the code: admin filter choices read the database through `models.FilterDB` because `pact.FilterOptions` receives no context or database; category filter values travel hex-encoded (`models.EncodeCategoryValue`) because the admin accepts only integer or identifier scope values; and the quiz form cannot embed its questions because the relation manager handles pivot relations only.