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:
@@ -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
29
models/quiz_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user