Files
sm-quizzes-plugin/classes/policy.go
Jakub Zych bb18242af9 fix: throttle student answers and attempt starts per user
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.*.
2026-11-09 22:48:50 +01:00

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