Files
sm-quizzes-plugin/controllers/api/json.go
Jakub Zych 6c6ecae9d8 feat: add test mode to quizzes and withhold its solutions
- mode, navigation and max_attempts columns with Go-side defaults
- classes.Withheld gates every solution field of the answer feedback
  and the attempt result while a test attempt is open
- a test's quiz detail lists no questions before the attempt starts
- admin fields and lang keys for the three settings
2026-11-09 14:03:34 +01:00

338 lines
12 KiB
Go

package api
import (
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/wire"
)
// The response shapes are built field by field, so a column added to a
// model never leaks into the API.
// quizSummaryJSON is one row of GET /quizzes.
type quizSummaryJSON struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Description *string `json:"description"`
Context *string `json:"context"`
QuestionCount int `json:"question_count"`
TimeLimitSeconds *int `json:"time_limit_seconds"`
PassingScore int `json:"passing_score"`
RoundSize *int `json:"round_size"`
ShuffleQuestions bool `json:"shuffle_questions"`
BestScorePercent *float64 `json:"best_score_percent"`
AttemptsCount int `json:"attempts_count"`
Mode string `json:"mode"`
Navigation string `json:"navigation"`
MaxAttempts *int `json:"max_attempts"`
}
func quizSummary(s classes.QuizSummary) quizSummaryJSON {
q := s.Quiz
return quizSummaryJSON{
ID: q.ID,
Slug: q.Slug,
Title: q.Title,
Description: q.Description,
Context: q.Context,
QuestionCount: s.QuestionCount,
TimeLimitSeconds: q.TimeLimitSeconds,
PassingScore: q.PassingScore,
RoundSize: q.RoundSize,
ShuffleQuestions: q.ShuffleQuestions,
BestScorePercent: s.BestScorePercent,
AttemptsCount: s.AttemptsCount,
Mode: quizMode(&q),
Navigation: quizNavigation(&q),
MaxAttempts: q.MaxAttempts,
}
}
// quizMode is the quiz's mode as the API reports it: test, or learning for
// a zero or unknown value (the classes.IsTest rule).
func quizMode(q *models.Quiz) string {
if classes.IsTest(q) {
return models.ModeTest
}
return models.ModeLearning
}
// quizNavigation is one_way when the quiz says so and free otherwise.
func quizNavigation(q *models.Quiz) string {
if q != nil && q.Navigation == models.NavigationOneWay {
return models.NavigationOneWay
}
return models.NavigationFree
}
// quizJSON is GET /quizzes/{slug}: the quiz without any solution. A test
// lists no questions (QuestionCount still counts them): the test player
// reads them from its attempt, so a timed test cannot be read before its
// clock starts.
type quizJSON struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Description *string `json:"description"`
Context *string `json:"context"`
TimeLimitSeconds *int `json:"time_limit_seconds"`
PassingScore int `json:"passing_score"`
RoundSize *int `json:"round_size"`
ShuffleQuestions bool `json:"shuffle_questions"`
QuestionCount int `json:"question_count"`
Categories []string `json:"categories"`
Questions []questionJSON `json:"questions"`
Mode string `json:"mode"`
Navigation string `json:"navigation"`
MaxAttempts *int `json:"max_attempts"`
}
// questionJSON is a question as the player sees it before answering: no
// 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"`
// AnswerText is the model answer of a self_check question only.
AnswerText *string `json:"answer_text,omitempty"`
Answers []answerJSON `json:"answers"`
}
type answerJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
}
func quizDetail(d *classes.QuizDetail) quizJSON {
q := d.Quiz
out := quizJSON{
ID: q.ID,
Slug: q.Slug,
Title: q.Title,
Description: q.Description,
Context: q.Context,
TimeLimitSeconds: q.TimeLimitSeconds,
PassingScore: q.PassingScore,
RoundSize: q.RoundSize,
ShuffleQuestions: q.ShuffleQuestions,
QuestionCount: len(d.Questions),
Categories: wire.Slice(d.Categories),
Questions: make([]questionJSON, 0, len(d.Questions)),
Mode: quizMode(&q),
Navigation: quizNavigation(&q),
MaxAttempts: q.MaxAttempts,
}
if classes.IsTest(&q) {
return out
}
for _, qu := range d.Questions {
out.Questions = append(out.Questions, question(qu))
}
return out
}
func question(q models.Question) questionJSON {
out := questionJSON{
ID: q.ID,
Text: q.Text,
Type: q.Type,
Category: q.Category,
Points: q.Points,
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})
}
return out
}
// attemptJSON is an attempt without its questions: POST
// /quizzes/{slug}/attempts and each row of GET /attempts.
type attemptJSON struct {
ID uint `json:"id"`
QuizID uint `json:"quiz_id"`
QuizSlug string `json:"quiz_slug"`
QuizTitle string `json:"quiz_title"`
Category *string `json:"category"`
StartedAt wire.Time `json:"started_at"`
ExpiresAt *wire.Time `json:"expires_at"`
CompletedAt *wire.Time `json:"completed_at"`
AnsweredCount int `json:"answered_count"`
ScorePoints float64 `json:"score_points"`
MaxPoints int `json:"max_points"`
ScorePercent *float64 `json:"score_percent"`
PassingScore int `json:"passing_score"`
Passed *bool `json:"passed"`
// QuizMode and QuizNavigation tell the player which flow to run.
QuizMode string `json:"quiz_mode"`
QuizNavigation string `json:"quiz_navigation"`
}
func attempt(s classes.AttemptSummary) attemptJSON {
a := s.Attempt
out := attemptJSON{
ID: a.ID,
QuizID: a.QuizID,
Category: a.Category,
StartedAt: wireTime(a.StartedAt),
AnsweredCount: s.AnsweredCount,
ScorePoints: a.ScorePoints,
MaxPoints: a.MaxPoints,
ScorePercent: a.ScorePercent,
QuizMode: quizMode(a.Quiz),
QuizNavigation: quizNavigation(a.Quiz),
}
if a.Quiz != nil {
out.QuizSlug, out.QuizTitle, out.PassingScore = a.Quiz.Slug, a.Quiz.Title, a.Quiz.PassingScore
}
if d := classes.Deadline(&a); d != nil {
t := wireTime(*d)
out.ExpiresAt = &t
}
if a.CompletedAt != nil {
t := wireTime(*a.CompletedAt)
out.CompletedAt = &t
passed := a.ScorePercent != nil && *a.ScorePercent >= float64(out.PassingScore)
out.Passed = &passed
}
return out
}
// resultJSON is GET /attempts/{id} and POST /attempts/{id}/finish.
// SolutionsWithheld is true for an open test attempt (classes.Withheld).
type resultJSON struct {
attemptJSON
SolutionsWithheld bool `json:"solutions_withheld"`
Questions []questionResultJSON `json:"questions"`
}
// questionResultJSON is one question of a result. The solution fields
// (is_correct of the answers, correct_answer_ids, explanation, answer_text)
// are revealed for an answered question and for every question once the
// attempt is completed; otherwise they are null. While the solutions are
// withheld (an open test attempt) every solution field, is_correct and
// points_earned included, is null for every question; answered, answer_ids
// and known (the student's own input) stay.
type questionResultJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
Type string `json:"type"`
Category *string `json:"category"`
Points int `json:"points"`
Answered bool `json:"answered"`
AnswerIDs []uint `json:"answer_ids"`
Known *bool `json:"known"`
IsCorrect *bool `json:"is_correct"`
PointsEarned *float64 `json:"points_earned"`
CorrectAnswerIDs []uint `json:"correct_answer_ids"`
Explanation *string `json:"explanation"`
AnswerText *string `json:"answer_text"`
Answers []resultAnswerJSON `json:"answers"`
}
type resultAnswerJSON struct {
ID uint `json:"id"`
Text string `json:"text"`
IsCorrect *bool `json:"is_correct"`
Explanation *string `json:"explanation"`
}
func result(r *classes.AttemptResult) resultJSON {
withheld := classes.Withheld(&r.Attempt)
out := resultJSON{
attemptJSON: attempt(r.AttemptSummary),
SolutionsWithheld: withheld,
Questions: make([]questionResultJSON, 0, len(r.Questions)),
}
completed := r.Attempt.CompletedAt != nil
for _, qr := range r.Questions {
q := qr.Question
row := questionResultJSON{
ID: q.ID,
Text: q.Text,
Type: q.Type,
Category: q.Category,
Points: q.Points,
AnswerIDs: []uint{},
Answers: make([]resultAnswerJSON, 0, len(q.Answers)),
}
if a := qr.Answer; a != nil {
row.Answered = true
row.AnswerIDs = wire.Slice(a.AnswerIDs.Data)
row.Known = a.SelfCheckKnown
if !withheld {
correct, points := a.IsCorrect, a.Points
row.IsCorrect, row.PointsEarned = &correct, &points
}
} else if !withheld {
// Learning output keeps false and 0 for an unanswered question.
correct, points := false, 0.0
row.IsCorrect, row.PointsEarned = &correct, &points
}
reveal := !withheld && (completed || row.Answered)
if reveal {
row.CorrectAnswerIDs = classes.CorrectAnswerIDs(q)
row.Explanation = q.Explanation
row.AnswerText = q.AnswerText
}
for _, ans := range q.Answers {
ra := resultAnswerJSON{ID: ans.ID, Text: ans.Text}
if reveal {
correct := ans.IsCorrect
ra.IsCorrect = &correct
ra.Explanation = ans.Explanation
}
row.Answers = append(row.Answers, ra)
}
out.Questions = append(out.Questions, row)
}
return out
}
// feedbackJSON is POST /attempts/{id}/answers. When Withheld (an answer to
// an open test attempt) every solution field is null and the answers carry
// only their id and text.
type feedbackJSON struct {
QuestionID uint `json:"question_id"`
Withheld bool `json:"withheld"`
IsCorrect *bool `json:"is_correct"`
Points *float64 `json:"points"`
CorrectAnswerIDs []uint `json:"correct_answer_ids"`
Explanation *string `json:"explanation"`
AnswerText *string `json:"answer_text"`
Answers []resultAnswerJSON `json:"answers"`
}
func feedback(fb *classes.Feedback) feedbackJSON {
q := fb.Question
out := feedbackJSON{QuestionID: q.ID, Withheld: fb.Withheld, Answers: make([]resultAnswerJSON, 0, len(q.Answers))}
if fb.Withheld {
for _, a := range q.Answers {
out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text})
}
return out
}
correct, points := fb.Result.IsCorrect, fb.Result.Points
out.IsCorrect, out.Points = &correct, &points
out.CorrectAnswerIDs = wire.Slice(fb.CorrectAnswerIDs)
out.Explanation, out.AnswerText = q.Explanation, q.AnswerText
for _, a := range q.Answers {
c := a.IsCorrect
out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text, IsCorrect: &c, Explanation: a.Explanation})
}
return out
}
func wireTime(t time.Time) wire.Time { return wire.Time{Time: t.UTC().Truncate(time.Second)} }