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) }