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

15
classes/policy.go Normal file
View File

@@ -0,0 +1,15 @@
package classes
import "git.golem15.com/golem15/sm-quizzes-plugin/models"
// IsTest reports whether the quiz runs as a test. A zero or unknown mode is
// learning, so a quiz written before test mode existed keeps its behaviour.
func IsTest(q *models.Quiz) bool { return q != nil && q.Mode == models.ModeTest }
// Withheld reports whether an attempt's solutions must stay hidden: it is
// an attempt at a test that is not finished yet. Every student serializer
// drops is_correct, points, correct answers, explanations and model answers
// while it is true.
func Withheld(a *models.Attempt) bool {
return a != nil && IsTest(a.Quiz) && a.CompletedAt == nil
}

39
classes/policy_test.go Normal file
View File

@@ -0,0 +1,39 @@
package classes_test
import (
"testing"
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
)
func TestIsTestAndWithheld(t *testing.T) {
if classes.IsTest(nil) {
t.Error("IsTest(nil) = true")
}
for mode, want := range map[string]bool{"": false, models.ModeLearning: false, "exam": false, models.ModeTest: true} {
if got := classes.IsTest(&models.Quiz{Mode: mode}); got != want {
t.Errorf("IsTest(mode %q) = %v, want %v", mode, got, want)
}
}
done := time.Now()
test, learning := &models.Quiz{Mode: models.ModeTest}, &models.Quiz{Mode: models.ModeLearning}
cases := []struct {
name string
a *models.Attempt
want bool
}{
{"open test", &models.Attempt{Quiz: test}, true},
{"completed test", &models.Attempt{Quiz: test, CompletedAt: &done}, false},
{"open learning", &models.Attempt{Quiz: learning}, false},
{"zero mode", &models.Attempt{Quiz: &models.Quiz{}}, false},
{"nil attempt", nil, false},
{"nil quiz", &models.Attempt{}, false},
}
for _, tc := range cases {
if got := classes.Withheld(tc.a); got != tc.want {
t.Errorf("Withheld(%s) = %v, want %v", tc.name, got, tc.want)
}
}
}

View File

@@ -258,6 +258,10 @@ type Feedback struct {
Question models.Question
Result ScoreResult
CorrectAnswerIDs []uint
// Withheld is set for an open test attempt: the solutions stay hidden
// and the serializer must drop Result, CorrectAnswerIDs and every
// solution field of Question.
Withheld bool
}
// Answer records or overwrites the user's answer to one question of an
@@ -334,7 +338,7 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
}).Create(&row).Error; err != nil {
return err
}
fb = &Feedback{Question: question, Result: res, CorrectAnswerIDs: correct}
fb = &Feedback{Question: question, Result: res, CorrectAnswerIDs: correct, Withheld: Withheld(attempt)}
return nil
})
if err != nil {