- Service.Start resumes an open test attempt, closes expired ones at their deadline and refuses a start past max_attempts under an advisory lock; StartAttempt stays as a wrapper - answers to a test are accepted until the deadline plus golem15.quizzes.test.grace_seconds; one_way answers are final - a test covers the whole quiz and keeps a stable per-attempt order - 409 bodies carry a code; list and attempt rows carry attempts_left, question_types and seconds_left; import accepts the test keys
125 lines
3.9 KiB
Go
125 lines
3.9 KiB
Go
package classes
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/compass"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// OneWay reports whether a test's answers are final: navigation one_way.
|
|
// Learning quizzes ignore the navigation setting.
|
|
func OneWay(q *models.Quiz) bool { return IsTest(q) && q.Navigation == models.NavigationOneWay }
|
|
|
|
// DefaultTestGrace is the test grace without a golem15.quizzes.test.grace_seconds setting.
|
|
const DefaultTestGrace = 5 * time.Second
|
|
|
|
// maxTestGrace caps the configured grace.
|
|
const maxTestGrace = 120 * time.Second
|
|
|
|
// TestGraceFrom reads golem15.quizzes.test.grace_seconds: how long after a
|
|
// test's deadline an answer in flight is still accepted. A nil config, a
|
|
// missing key or a value that is not a number give DefaultTestGrace; the
|
|
// value is clamped to 0..120 seconds. Learning quizzes have no grace.
|
|
func TestGraceFrom(cfg *compass.Config) time.Duration {
|
|
if cfg == nil {
|
|
return DefaultTestGrace
|
|
}
|
|
v, ok := cfg.Lookup("golem15.quizzes.test.grace_seconds")
|
|
if !ok {
|
|
return DefaultTestGrace
|
|
}
|
|
secs, ok := number(v)
|
|
if !ok {
|
|
return DefaultTestGrace
|
|
}
|
|
grace := time.Duration(secs * float64(time.Second))
|
|
return min(max(grace, 0), maxTestGrace)
|
|
}
|
|
|
|
// number is v as a float when it is a number or a numeric string.
|
|
func number(v any) (float64, bool) {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
return n, !math.IsNaN(n)
|
|
case float32:
|
|
return float64(n), true
|
|
case int:
|
|
return float64(n), true
|
|
case int64:
|
|
return float64(n), true
|
|
case uint64:
|
|
return float64(n), true
|
|
case string:
|
|
f, err := strconv.ParseFloat(n, 64)
|
|
return f, err == nil && !math.IsNaN(f)
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// AnswerDeadline is the last moment an answer to the attempt is accepted:
|
|
// the Deadline plus grace for a test, the Deadline itself for a learning
|
|
// quiz, nil without a time limit.
|
|
func AnswerDeadline(a *models.Attempt, grace time.Duration) *time.Time {
|
|
d := Deadline(a)
|
|
if d == nil || !IsTest(a.Quiz) {
|
|
return d
|
|
}
|
|
t := d.Add(grace)
|
|
return &t
|
|
}
|
|
|
|
// Expired reports whether an open test attempt is past its answer deadline,
|
|
// so it can be closed at its Deadline. Learning and completed attempts never
|
|
// expire this way.
|
|
func Expired(a *models.Attempt, now time.Time, grace time.Duration) bool {
|
|
if a == nil || !IsTest(a.Quiz) || a.CompletedAt != nil {
|
|
return false
|
|
}
|
|
d := AnswerDeadline(a, grace)
|
|
return d != nil && now.After(*d)
|
|
}
|
|
|
|
// SecondsLeft is the whole seconds until the attempt's Deadline, rounded up
|
|
// and floored at 0, or nil without a time limit or once completed. The
|
|
// player runs its countdown from it, not from the client clock.
|
|
func SecondsLeft(a *models.Attempt, now time.Time) *int {
|
|
d := Deadline(a)
|
|
if d == nil || a.CompletedAt != nil {
|
|
return nil
|
|
}
|
|
left := max(int(math.Ceil(d.Sub(now).Seconds())), 0)
|
|
return &left
|
|
}
|
|
|
|
// AttemptsLeft is how many attempts the user may still start: nil for a
|
|
// learning quiz or a test without a limit, otherwise max(0, limit - used).
|
|
func AttemptsLeft(q *models.Quiz, used int) *int {
|
|
if !IsTest(q) || q.MaxAttempts == nil {
|
|
return nil
|
|
}
|
|
left := max(*q.MaxAttempts-used, 0)
|
|
return &left
|
|
}
|
|
|
|
// AttemptLockKey names the advisory lock that serialises one user's test
|
|
// starts on one quiz.
|
|
func AttemptLockKey(quizID, userID uint) string {
|
|
return fmt.Sprintf("golem15.quizzes.attempt:%d:%d", quizID, userID)
|
|
}
|