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
This commit is contained in:
Jakub Zych
2026-11-09 14:03:34 +01:00
parent 67a83dba32
commit 6c6ecae9d8
14 changed files with 506 additions and 36 deletions

View File

@@ -10,8 +10,28 @@ import (
"gorm.io/gorm"
)
// Quiz modes.
const (
ModeLearning = "learning"
ModeTest = "test"
)
// QuizModes lists the valid values of Quiz.Mode.
var QuizModes = []string{ModeLearning, ModeTest}
// Test navigation.
const (
NavigationFree = "free"
NavigationOneWay = "one_way"
)
// QuizNavigations lists the valid values of Quiz.Navigation.
var QuizNavigations = []string{NavigationFree, NavigationOneWay}
// Quiz is a set of questions a signed-in user can attempt. Context is a
// free-form link to a record of another plugin, such as "acme.shop:course:12".
// Mode test withholds solutions until the attempt is finished; Navigation
// (free or one_way) and MaxAttempts (nil is unlimited) only apply to tests.
type Quiz struct {
ID uint `gorm:"column:id;primaryKey"`
Title string `gorm:"column:title"`
@@ -23,6 +43,9 @@ type Quiz struct {
PassingScore int `gorm:"column:passing_score"`
ShuffleQuestions bool `gorm:"column:shuffle_questions"`
RoundSize *int `gorm:"column:round_size"`
Mode string `gorm:"column:mode"`
Navigation string `gorm:"column:navigation"`
MaxAttempts *int `gorm:"column:max_attempts"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
Questions []Question `gorm:"foreignKey:QuizID"`
@@ -36,6 +59,7 @@ func (Quiz) Fillable() []string {
return []string{
"title", "slug", "description", "context", "is_published",
"time_limit_seconds", "passing_score", "shuffle_questions", "round_size",
"mode", "navigation", "max_attempts",
}
}
@@ -49,22 +73,42 @@ func (Quiz) Rules() map[string]string {
"passing_score": "required|integer|between:0,100",
"time_limit_seconds": "nullable|integer|min:1",
"round_size": "nullable|integer|min:1",
"mode": "nullable|in:learning,test",
"navigation": "nullable|in:free,one_way",
"max_attempts": "nullable|integer|min:1",
}
}
// BeforeValidate fills the slug from the title before the admin form
// validates, so the unique rule sees the final slug.
// BeforeValidate fills the slug from the title and the mode defaults before
// the admin form validates, so the rules see the final values.
func (q *Quiz) BeforeValidate(*gorm.DB) error {
q.fillSlug()
q.fillDefaults()
return nil
}
// BeforeSave fills the slug from the title on every other write path.
// BeforeSave fills the slug and the mode defaults on every other write path.
func (q *Quiz) BeforeSave(*gorm.DB) error {
q.fillSlug()
q.fillDefaults()
return nil
}
// fillDefaults stores an empty mode or navigation as learning / free and a
// non-positive attempt limit as unlimited. GORM writes Go zero values, so
// without it a models.Quiz{} would hit the columns' CHECK constraints.
func (q *Quiz) fillDefaults() {
if q.Mode == "" {
q.Mode = ModeLearning
}
if q.Navigation == "" {
q.Navigation = NavigationFree
}
if q.MaxAttempts != nil && *q.MaxAttempts <= 0 {
q.MaxAttempts = nil
}
}
func (q *Quiz) fillSlug() {
if strings.TrimSpace(q.Slug) == "" {
q.Slug = Slugify(q.Title)

29
models/quiz_test.go Normal file
View File

@@ -0,0 +1,29 @@
package models
import "testing"
func TestQuizDefaults(t *testing.T) {
zero, negative := 0, -1
for name, hook := range map[string]func(*Quiz) error{
"BeforeSave": func(q *Quiz) error { return q.BeforeSave(nil) },
"BeforeValidate": func(q *Quiz) error { return q.BeforeValidate(nil) },
} {
for _, limit := range []*int{&zero, &negative} {
q := &Quiz{Title: "Q", MaxAttempts: limit}
if err := hook(q); err != nil {
t.Fatalf("%s: %v", name, err)
}
if q.Mode != ModeLearning || q.Navigation != NavigationFree || q.MaxAttempts != nil {
t.Errorf("%s with max_attempts %d = %q %q %v, want learning free nil", name, *limit, q.Mode, q.Navigation, q.MaxAttempts)
}
}
two := 2
q := &Quiz{Title: "Q", Mode: ModeTest, Navigation: NavigationOneWay, MaxAttempts: &two}
if err := hook(q); err != nil {
t.Fatalf("%s: %v", name, err)
}
if q.Mode != ModeTest || q.Navigation != NavigationOneWay || q.MaxAttempts == nil || *q.MaxAttempts != 2 {
t.Errorf("%s changed a test quiz: %q %q %v", name, q.Mode, q.Navigation, q.MaxAttempts)
}
}
}

View File

@@ -8,6 +8,8 @@ columns:
context:
label: golem15.quizzes::lang.quizzes.context
searchable: true
mode:
label: golem15.quizzes::lang.quizzes.mode
is_published:
label: golem15.quizzes::lang.quizzes.is_published
type: switch

View File

@@ -42,3 +42,26 @@ fields:
label: golem15.quizzes::lang.quizzes.shuffle_questions
type: switch
span: right
mode:
label: golem15.quizzes::lang.quizzes.mode
type: dropdown
span: left
default: learning
options:
learning: golem15.quizzes::lang.quizzes.mode_learning
test: golem15.quizzes::lang.quizzes.mode_test
comment: golem15.quizzes::lang.quizzes.mode_comment
navigation:
label: golem15.quizzes::lang.quizzes.navigation
type: dropdown
span: right
default: free
options:
free: golem15.quizzes::lang.quizzes.navigation_free
one_way: golem15.quizzes::lang.quizzes.navigation_one_way
comment: golem15.quizzes::lang.quizzes.navigation_comment
max_attempts:
label: golem15.quizzes::lang.quizzes.max_attempts
type: number
span: left
comment: golem15.quizzes::lang.quizzes.max_attempts_comment