Files
sm-quizzes-plugin/classes/policy.go
Jakub Zych 487d573208 feat: enforce attempt limits, time limits and one-way tests
- 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
2026-11-09 14:07:37 +01:00

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