2026-10-07 11:18:06 +02:00
2026-10-07 11:18:06 +02:00

golem15.quizzes

A reusable quiz plugin for SummerCMS applications: quizzes with single-choice, multiple-choice and self-check (flashcard) questions, scored attempts for signed-in users, an admin backend and a JSON importer.

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 and round size.
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.
  • 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.

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:

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). 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.
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."} Answering or finishing a finished attempt.
409 {"error": "The time limit of this attempt has been exceeded."} Answering after started_at + time_limit_seconds.
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.

The messages are translated into the request locale (golem15.quizzes::lang.api.*, Polish and English).

GET /quizzes

{"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
}]}

attempts_count counts the caller's finished attempts and best_score_percent is their best finished result (null before the first one).

GET /quizzes/{slug}

{"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"],
  "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.

POST /quizzes/{slug}/attempts

Body, optional: {"category": "Theory"} limits the attempt to one category; a category without questions is a 422 on category. Answers status 201:

{"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
}}

expires_at is started_at + time_limit_seconds, or null without a limit. max_points sums the points of the questions the attempt covers. This attempt shape is also each row of GET /attempts.

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 (409), the question belongs to it (422 on question_id), the answer fits the question (422 on answer_ids or known). Answering a question again overwrites the stored answer. Answers status 200:

{"data": {
  "question_id": 11,
  "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.

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:

{"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,
  "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.

GET /attempts

{"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); ParseImport and Import (the importer).
  • 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.

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.

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 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:

{
  "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.
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

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.

Description
No description provided
Readme 129 KiB
Languages
Go 99.9%