fix: include answer_text of self_check questions in the quiz payload

This commit is contained in:
Jakub Zych
2026-10-02 14:44:15 +02:00
parent 2945a286d4
commit a5be13c2be
3 changed files with 29 additions and 13 deletions

View File

@@ -33,7 +33,7 @@ Deleting a quiz deletes its questions, answers and attempts. Deleting a question
- **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`, explanations or `answer_text`. 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.
- **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.
- **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).
@@ -60,7 +60,7 @@ All routes are mounted under `/_quizzes/api/v1` with the `jwt.auth` middleware o
| 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. Query: `category`. |
| `GET /quizzes/{slug}` | `api.Handlers.ShowQuiz` | A published quiz with its questions and answers, without solutions (a `self_check` question carries its `answer_text`). Query: `category`. |
| `POST /quizzes/{slug}/attempts` | `api.Handlers.StartAttempt` | Starts an 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. |
@@ -115,7 +115,7 @@ The messages are translated into the request locale (`golem15.quizzes::lang.api.
"categories": ["Documents", "Theory"],
"questions": [
{"id": 10, "text": "What is societal resilience?", "type": "self_check", "category": "Theory",
"points": 1, "sort_order": 1, "answers": []},
"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"}]}
]

View File

@@ -134,18 +134,28 @@ func TestQuizAPIFlow(t *testing.T) {
}
}
// The quiz payload never reveals a solution.
// The quiz payload never reveals a solution; only self_check flashcards carry answer_text.
code, raw := c.do("GET", apiPrefix+"/quizzes/swiat", token, "")
if code != 200 {
t.Fatalf("GET /quizzes/swiat = %d", code)
}
body, _ := json.Marshal(raw)
for _, leak := range []string{"is_correct", "explanation", "answer_text", "Warszawa od 1596"} {
for _, leak := range []string{"is_correct", "explanation", "Warszawa od 1596"} {
if strings.Contains(string(body), leak) {
t.Errorf("GET /quizzes/swiat leaks %q: %s", leak, body)
}
}
quiz := data(raw)
for _, qi := range quiz["questions"].([]any) {
qm := qi.(map[string]any)
text, has := qm["answer_text"]
if (qm["type"] == "self_check") != has {
t.Errorf("question %v (%v): answer_text present = %v", qm["id"], qm["type"], has)
}
if has && text != "Zdolność społeczeństwa do..." {
t.Errorf("self_check answer_text = %v", text)
}
}
if qs := quiz["questions"].([]any); len(qs) != 3 || qs[0].(map[string]any)["id"] != float64(f.single.ID) {
t.Errorf("questions = %v, want three in sort order", qs)
}

View File

@@ -62,15 +62,18 @@ type quizJSON struct {
}
// questionJSON is a question as the player sees it before answering: no
// is_correct, explanation or answer_text.
// is_correct or explanation, and no answer_text except for self_check
// flashcards, which have no solution to hide.
type questionJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Category *string `json:"category"`
Points int `json:"points"`
SortOrder int `json:"sort_order"`
Answers []answerJSON `json:"answers"`
ID uint `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Category *string `json:"category"`
Points int `json:"points"`
SortOrder int `json:"sort_order"`
// AnswerText is the model answer of a self_check question only.
AnswerText *string `json:"answer_text,omitempty"`
Answers []answerJSON `json:"answers"`
}
type answerJSON struct {
@@ -110,6 +113,9 @@ func question(q models.Question) questionJSON {
SortOrder: q.SortOrder,
Answers: make([]answerJSON, 0, len(q.Answers)),
}
if q.Type == models.TypeSelfCheck {
out.AnswerText = q.AnswerText
}
for _, a := range q.Answers {
out.Answers = append(out.Answers, answerJSON{ID: a.ID, Text: a.Text})
}