WR-03: every new or changed open answer in a learning quiz with a grader is a paid grading call. Add per-user buckets golem15.quizzes.answer (60/min) and golem15.quizzes.start (10/min), configurable under golem15.quizzes.throttle.*.
155 lines
4.8 KiB
Go
155 lines
4.8 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)
|
|
}
|
|
|
|
// Default per-user rate limits of the student write routes, per minute. In
|
|
// a learning quiz with a grader every new or changed open answer is a paid
|
|
// grading call, so answering and starting attempts are throttled (WR-03).
|
|
const (
|
|
DefaultAnswersPerMinute = 60
|
|
DefaultAttemptsPerMinute = 10
|
|
)
|
|
|
|
// maxPerMinute caps a configured per-minute limit.
|
|
const maxPerMinute = 10000
|
|
|
|
// PerMinuteFrom reads a per-minute rate limit at key (for example
|
|
// golem15.quizzes.throttle.answers_per_minute). A nil config, a missing
|
|
// key or a value that is not a number of at least 1 give def; the value
|
|
// is truncated to a whole number and capped at 10000.
|
|
func PerMinuteFrom(cfg *compass.Config, key string, def int) int {
|
|
if cfg == nil {
|
|
return def
|
|
}
|
|
v, ok := cfg.Lookup(key)
|
|
if !ok {
|
|
return def
|
|
}
|
|
n, ok := number(v)
|
|
if !ok || n < 1 || math.IsInf(n, 0) {
|
|
return def
|
|
}
|
|
return int(min(n, maxPerMinute))
|
|
}
|
|
|
|
// 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)
|
|
}
|