From 487d57320815f008d28d4d527e4f61fccd2d7840 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 9 Nov 2026 14:07:37 +0100 Subject: [PATCH] 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 --- classes/importer.go | 26 ++- classes/importer_test.go | 27 +++ classes/policy.go | 111 +++++++++++- classes/policy_test.go | 109 ++++++++++++ classes/service.go | 283 ++++++++++++++++++++++++++----- config/config.yaml | 4 + controllers/api/handlers.go | 60 +++++-- controllers/api/handlers_test.go | 33 ++-- controllers/api/json.go | 39 ++++- controllers/api/json_test.go | 43 +++++ lang/en/lang.yaml | 3 + lang/pl/lang.yaml | 3 + 12 files changed, 669 insertions(+), 72 deletions(-) diff --git a/classes/importer.go b/classes/importer.go index fbd3756..c0c9a7d 100644 --- a/classes/importer.go +++ b/classes/importer.go @@ -13,7 +13,9 @@ import ( "gorm.io/gorm" ) -// ImportQuiz is the JSON document quizzes:import reads. +// ImportQuiz is the JSON document quizzes:import reads. Mode, Navigation +// and MaxAttempts keep the quiz's current value (the model default on +// create) when absent. type ImportQuiz struct { Title string `json:"title"` Slug string `json:"slug"` @@ -24,6 +26,9 @@ type ImportQuiz struct { PassingScore *int `json:"passing_score"` ShuffleQuestions *bool `json:"shuffle_questions"` RoundSize *int `json:"round_size"` + Mode *string `json:"mode"` + Navigation *string `json:"navigation"` + MaxAttempts *int `json:"max_attempts"` Questions []ImportQuestion `json:"questions"` } @@ -79,6 +84,15 @@ func ParseImport(raw []byte) (*ImportQuiz, error) { if doc.TimeLimitSeconds != nil && *doc.TimeLimitSeconds < 1 { problems = append(problems, "time_limit_seconds must be at least 1") } + if doc.Mode != nil && !slices.Contains(models.QuizModes, *doc.Mode) { + problems = append(problems, "mode must be one of "+strings.Join(models.QuizModes, ", ")) + } + if doc.Navigation != nil && !slices.Contains(models.QuizNavigations, *doc.Navigation) { + problems = append(problems, "navigation must be one of "+strings.Join(models.QuizNavigations, ", ")) + } + if doc.MaxAttempts != nil && *doc.MaxAttempts < 1 { + problems = append(problems, "max_attempts must be at least 1") + } if len(doc.Questions) == 0 { problems = append(problems, "questions must not be empty") } @@ -164,6 +178,16 @@ func Import(ctx context.Context, db *gorm.DB, doc *ImportQuiz, replace bool) (*I if doc.ShuffleQuestions != nil { quiz.ShuffleQuestions = *doc.ShuffleQuestions } + if doc.Mode != nil { + quiz.Mode = *doc.Mode + } + if doc.Navigation != nil { + quiz.Navigation = *doc.Navigation + } + if doc.MaxAttempts != nil { + limit := *doc.MaxAttempts + quiz.MaxAttempts = &limit + } if err := tx.Save(&quiz).Error; err != nil { return err } diff --git a/classes/importer_test.go b/classes/importer_test.go index f24b530..5c32ac4 100644 --- a/classes/importer_test.go +++ b/classes/importer_test.go @@ -55,3 +55,30 @@ func TestParseImportRejects(t *testing.T) { t.Errorf("explicit slug = %v %v, want moj-slug", doc, err) } } + +func TestParseImportTestMode(t *testing.T) { + doc, err := classes.ParseImport([]byte(`{"title":"T","mode":"test","navigation":"one_way","max_attempts":2,"questions":[{"text":"Q","type":"self_check"}]}`)) + if err != nil { + t.Fatalf("ParseImport: %v", err) + } + if doc.Mode == nil || *doc.Mode != "test" || doc.Navigation == nil || *doc.Navigation != "one_way" || doc.MaxAttempts == nil || *doc.MaxAttempts != 2 { + t.Errorf("doc = %+v", doc) + } + for raw, want := range map[string]string{ + `{"title":"T","mode":"exam","questions":[{"text":"Q","type":"self_check"}]}`: "mode must be one of learning, test", + `{"title":"T","navigation":"back","questions":[{"text":"Q","type":"self_check"}]}`: "navigation must be one of free, one_way", + `{"title":"T","max_attempts":0,"questions":[{"text":"Q","type":"self_check"}]}`: "max_attempts must be at least 1", + } { + if _, err := classes.ParseImport([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("ParseImport(%s) = %v, want %q", raw, err, want) + } + } + raw, err := os.ReadFile("../testdata/quiz.json") + if err != nil { + t.Fatal(err) + } + doc, err = classes.ParseImport(raw) + if err != nil || doc.Mode != nil || doc.Navigation != nil || doc.MaxAttempts != nil { + t.Errorf("sample = %+v %v, want no test-mode keys", doc, err) + } +} diff --git a/classes/policy.go b/classes/policy.go index 878f311..1d5641e 100644 --- a/classes/policy.go +++ b/classes/policy.go @@ -1,6 +1,14 @@ package classes -import "git.golem15.com/golem15/sm-quizzes-plugin/models" +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. @@ -13,3 +21,104 @@ func IsTest(q *models.Quiz) bool { return q != nil && q.Mode == models.ModeTest 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) +} diff --git a/classes/policy_test.go b/classes/policy_test.go index d5f8d5f..fb4ad21 100644 --- a/classes/policy_test.go +++ b/classes/policy_test.go @@ -6,6 +6,7 @@ import ( "git.golem15.com/golem15/sm-quizzes-plugin/classes" "git.golem15.com/golem15/sm-quizzes-plugin/models" + "git.golem15.com/golem15/summercms/modules/compass" ) func TestIsTestAndWithheld(t *testing.T) { @@ -37,3 +38,111 @@ func TestIsTestAndWithheld(t *testing.T) { } } } + +func TestTestRules(t *testing.T) { + limit, two := 60, 2 + start := time.Date(2026, 11, 9, 10, 0, 0, 0, time.UTC) + test := &models.Quiz{Mode: models.ModeTest, TimeLimitSeconds: &limit} + learning := &models.Quiz{Mode: models.ModeLearning, TimeLimitSeconds: &limit} + grace := 5 * time.Second + open := &models.Attempt{StartedAt: start, Quiz: test} + deadline := start.Add(60 * time.Second) + + if d := classes.AnswerDeadline(open, grace); d == nil || !d.Equal(deadline.Add(grace)) { + t.Errorf("test AnswerDeadline = %v, want deadline + grace", d) + } + if d := classes.AnswerDeadline(&models.Attempt{StartedAt: start, Quiz: learning}, grace); d == nil || !d.Equal(deadline) { + t.Errorf("learning AnswerDeadline = %v, want the deadline itself", d) + } + if classes.AnswerDeadline(&models.Attempt{StartedAt: start, Quiz: &models.Quiz{Mode: models.ModeTest}}, grace) != nil { + t.Error("AnswerDeadline without a time limit is set") + } + + if classes.Expired(open, deadline.Add(4*time.Second), grace) { + t.Error("test attempt expired inside the grace") + } + if !classes.Expired(open, deadline.Add(6*time.Second), grace) { + t.Error("test attempt past deadline + grace did not expire") + } + if classes.Expired(&models.Attempt{StartedAt: start, Quiz: learning}, deadline.Add(time.Hour), grace) { + t.Error("learning attempt expired") + } + done := deadline + if classes.Expired(&models.Attempt{StartedAt: start, Quiz: test, CompletedAt: &done}, deadline.Add(time.Hour), grace) { + t.Error("completed attempt expired") + } + if classes.Expired(&models.Attempt{StartedAt: start, Quiz: &models.Quiz{Mode: models.ModeTest}}, deadline.Add(time.Hour), grace) { + t.Error("attempt without a time limit expired") + } + + if got := classes.SecondsLeft(open, start.Add(500*time.Millisecond)); got == nil || *got != 60 { + t.Errorf("SecondsLeft at +0.5s = %v, want 60 (rounded up)", got) + } + if got := classes.SecondsLeft(open, start.Add(59*time.Second)); got == nil || *got != 1 { + t.Errorf("SecondsLeft at +59s = %v, want 1", got) + } + if got := classes.SecondsLeft(open, deadline.Add(time.Minute)); got == nil || *got != 0 { + t.Errorf("SecondsLeft after the deadline = %v, want 0", got) + } + if classes.SecondsLeft(&models.Attempt{StartedAt: start, Quiz: &models.Quiz{}}, start) != nil { + t.Error("SecondsLeft without a time limit is set") + } + if classes.SecondsLeft(&models.Attempt{StartedAt: start, Quiz: test, CompletedAt: &done}, start) != nil { + t.Error("SecondsLeft of a completed attempt is set") + } + + if classes.AttemptsLeft(&models.Quiz{Mode: models.ModeLearning, MaxAttempts: &two}, 1) != nil { + t.Error("AttemptsLeft of a learning quiz is set") + } + if classes.AttemptsLeft(&models.Quiz{Mode: models.ModeTest}, 1) != nil { + t.Error("AttemptsLeft of an unlimited test is set") + } + for used, want := range map[int]int{0: 2, 1: 1, 2: 0, 3: 0} { + if got := classes.AttemptsLeft(&models.Quiz{Mode: models.ModeTest, MaxAttempts: &two}, used); got == nil || *got != want { + t.Errorf("AttemptsLeft(max 2, used %d) = %v, want %d", used, got, want) + } + } + + for _, tc := range []struct { + q *models.Quiz + want bool + }{ + {&models.Quiz{Mode: models.ModeTest, Navigation: models.NavigationOneWay}, true}, + {&models.Quiz{Mode: models.ModeTest, Navigation: models.NavigationFree}, false}, + {&models.Quiz{Mode: models.ModeLearning, Navigation: models.NavigationOneWay}, false}, + {nil, false}, + } { + if got := classes.OneWay(tc.q); got != tc.want { + t.Errorf("OneWay(%+v) = %v, want %v", tc.q, got, tc.want) + } + } + + if got := classes.AttemptLockKey(3, 7); got != "golem15.quizzes.attempt:3:7" { + t.Errorf("AttemptLockKey = %q", got) + } +} + +func TestTestGraceFrom(t *testing.T) { + if got := classes.TestGraceFrom(nil); got != 5*time.Second || classes.DefaultTestGrace != 5*time.Second { + t.Errorf("nil config = %v, want 5s", got) + } + dir := t.TempDir() + cfg, err := compass.Open(compass.Options{Dir: dir, Env: "testing", Environ: []string{}}) + if err != nil { + t.Fatal(err) + } + if got := classes.TestGraceFrom(cfg); got != 5*time.Second { + t.Errorf("missing key = %v, want 5s", got) + } + for _, tc := range []struct { + value any + want time.Duration + }{{0, 0}, {30, 30 * time.Second}, {"x", 5 * time.Second}, {900, 120 * time.Second}, {-1, 0}, {"10", 10 * time.Second}} { + if err := cfg.Set("golem15.quizzes.test.grace_seconds", tc.value); err != nil { + t.Fatal(err) + } + if got := classes.TestGraceFrom(cfg); got != tc.want { + t.Errorf("grace_seconds %v = %v, want %v", tc.value, got, tc.want) + } + } +} diff --git a/classes/service.go b/classes/service.go index 8768353..4c1921a 100644 --- a/classes/service.go +++ b/classes/service.go @@ -19,8 +19,13 @@ var ( ErrNotFound = errors.New("quizzes: not found") // ErrCompleted is a write to a finished attempt. ErrCompleted = errors.New("quizzes: attempt already completed") - // ErrTimeLimit is an answer after the quiz's time limit ran out. + // ErrTimeLimit is an answer after the quiz's time limit ran out (plus + // the grace of a test). ErrTimeLimit = errors.New("quizzes: time limit exceeded") + // ErrAttemptLimit is a test start after the user used every attempt. + ErrAttemptLimit = errors.New("quizzes: attempt limit reached") + // ErrAnswerLocked is a changed answer in a one_way test. + ErrAnswerLocked = errors.New("quizzes: answer locked") ) // FieldError is an input error on one request field (a 422). @@ -40,6 +45,9 @@ type Service struct { // Shuffle reorders questions of a quiz with shuffle_questions; it // defaults to math/rand. Shuffle func(n int, swap func(i, j int)) + // Grace is how long after a test's deadline an answer is still + // accepted; it never applies to learning quizzes. See TestGraceFrom. + Grace time.Duration } func (s *Service) now() time.Time { @@ -57,11 +65,17 @@ type QuizSummary struct { QuestionCount int BestScorePercent *float64 AttemptsCount int + // QuestionTypes counts the questions per type; every models.QuestionTypes + // key is present. + QuestionTypes map[string]int + // AttemptsUsed counts every attempt of the user, open or completed. + AttemptsUsed int } // ListQuizzes returns the published quizzes, optionally only those whose // context equals quizContext, ordered by title. AttemptsCount counts the -// user's completed attempts and BestScorePercent is their best result. +// user's completed attempts, AttemptsUsed every attempt, and +// BestScorePercent is their best result. func (s *Service) ListQuizzes(ctx context.Context, userID uint, quizContext string) ([]QuizSummary, error) { q := s.db(ctx).Where("is_published = ?", true) if quizContext != "" { @@ -81,27 +95,34 @@ func (s *Service) ListQuizzes(ctx context.Context, userID uint, quizContext stri } type countRow struct { QuizID uint + Type string N int } var counts []countRow - if err := s.db(ctx).Model(&models.Question{}).Select("quiz_id, COUNT(*) AS n"). - Where("quiz_id IN ?", ids).Group("quiz_id").Scan(&counts).Error; err != nil { + if err := s.db(ctx).Model(&models.Question{}).Select("quiz_id, type, COUNT(*) AS n"). + Where("quiz_id IN ?", ids).Group("quiz_id, type").Scan(&counts).Error; err != nil { return nil, err } type statRow struct { QuizID uint N int + Used int Best *float64 } var stats []statRow - if err := s.db(ctx).Model(&models.Attempt{}).Select("quiz_id, COUNT(*) AS n, MAX(score_percent) AS best"). - Where("quiz_id IN ? AND user_id = ? AND completed_at IS NOT NULL", ids, userID). + if err := s.db(ctx).Model(&models.Attempt{}). + Select("quiz_id, COUNT(*) FILTER (WHERE completed_at IS NOT NULL) AS n, COUNT(*) AS used, "+ + "MAX(score_percent) FILTER (WHERE completed_at IS NOT NULL) AS best"). + Where("quiz_id IN ? AND user_id = ?", ids, userID). Group("quiz_id").Scan(&stats).Error; err != nil { return nil, err } - countBy := map[uint]int{} + typesBy := map[uint]map[string]int{} for _, c := range counts { - countBy[c.QuizID] = c.N + if typesBy[c.QuizID] == nil { + typesBy[c.QuizID] = map[string]int{} + } + typesBy[c.QuizID][c.Type] += c.N } statBy := map[uint]statRow{} for _, st := range stats { @@ -109,7 +130,19 @@ func (s *Service) ListQuizzes(ctx context.Context, userID uint, quizContext stri } for _, quiz := range quizzes { st := statBy[quiz.ID] - out = append(out, QuizSummary{Quiz: quiz, QuestionCount: countBy[quiz.ID], BestScorePercent: st.Best, AttemptsCount: st.N}) + types := make(map[string]int, len(models.QuestionTypes)) + for _, typ := range models.QuestionTypes { + types[typ] = 0 + } + total := 0 + for typ, n := range typesBy[quiz.ID] { + types[typ] += n + total += n + } + out = append(out, QuizSummary{ + Quiz: quiz, QuestionCount: total, BestScorePercent: st.Best, AttemptsCount: st.N, + QuestionTypes: types, AttemptsUsed: st.Used, + }) } return out, nil } @@ -123,13 +156,16 @@ type QuizDetail struct { // GetQuiz loads a published quiz by slug with its questions in sort order // (shuffled when the quiz says so) and each question's answers. A non-empty -// category keeps only that category's questions. Categories lists every -// category of the quiz. +// category keeps only that category's questions; a test ignores it, as it +// always covers the whole quiz. Categories lists every category of the quiz. func (s *Service) GetQuiz(ctx context.Context, slug, category string) (*QuizDetail, error) { quiz, err := s.publishedQuiz(ctx, slug) if err != nil { return nil, err } + if IsTest(quiz) { + category = "" + } questions, err := s.questions(ctx, quiz.ID, category) if err != nil { return nil, err @@ -179,38 +215,147 @@ func (s *Service) categories(ctx context.Context, quizID uint) ([]string, error) return cats, err } -// StartAttempt starts an attempt at a published quiz for the user. A -// non-empty category limits the attempt to that category's questions; a -// category without questions is a FieldError on category. +// Started is the attempt POST /quizzes/{slug}/attempts answers with. +type Started struct { + Attempt *models.Attempt + Quiz *models.Quiz + // Resumed is true when an open test attempt was returned instead of a + // new one. + Resumed bool + // AttemptsUsed counts the user's attempts on the quiz, this one + // included; it is only counted for tests. + AttemptsUsed int + // AnsweredCount is the number of questions a resumed attempt answered. + AnsweredCount int +} + +// StartAttempt starts an attempt at a published quiz for the user, like +// Start without the resume and limit details. func (s *Service) StartAttempt(ctx context.Context, userID uint, slug, category string) (*models.Attempt, *models.Quiz, error) { - quiz, err := s.publishedQuiz(ctx, slug) + st, err := s.Start(ctx, userID, slug, category) if err != nil { return nil, nil, err } + return st.Attempt, st.Quiz, nil +} + +// Start starts an attempt at a published quiz for the user. +// +// For a learning quiz a non-empty category limits the attempt to that +// category's questions; a category without questions is a FieldError on +// category. Every call creates a new attempt. +// +// A test always covers the whole quiz, so a category is a FieldError +// (category_not_allowed). Under an advisory lock per (quiz, user) it closes +// the user's open attempts past their deadline and grace at the deadline, +// resumes a remaining open attempt (Resumed) and otherwise refuses with +// ErrAttemptLimit once the user's attempts, open or completed and in any +// mode, reach max_attempts. +func (s *Service) Start(ctx context.Context, userID uint, slug, category string) (*Started, error) { + quiz, err := s.publishedQuiz(ctx, slug) + if err != nil { + return nil, err + } + if IsTest(quiz) { + if category != "" { + return nil, &FieldError{Field: "category", Key: "category_not_allowed"} + } + return s.startTest(ctx, userID, quiz) + } attempt := &models.Attempt{QuizID: quiz.ID, UserID: userID, StartedAt: s.now().UTC()} if category != "" { var n int64 if err := s.db(ctx).Model(&models.Question{}).Where("quiz_id = ? AND category = ?", quiz.ID, category).Count(&n).Error; err != nil { - return nil, nil, err + return nil, err } if n == 0 { - return nil, nil, &FieldError{Field: "category", Key: "unknown_category"} + return nil, &FieldError{Field: "category", Key: "unknown_category"} } attempt.Category = &category } - maxPoints, err := s.maxPoints(ctx, attempt) + maxPoints, err := s.maxPoints(ctx, s.DB, attempt) if err != nil { - return nil, nil, err + return nil, err } attempt.MaxPoints = maxPoints if err := s.db(ctx).Create(attempt).Error; err != nil { - return nil, nil, err + return nil, err } - return attempt, quiz, nil + return &Started{Attempt: attempt, Quiz: quiz}, nil } -func (s *Service) maxPoints(ctx context.Context, a *models.Attempt) (int, error) { - q := s.db(ctx).Model(&models.Question{}).Where("quiz_id = ?", a.QuizID) +func (s *Service) startTest(ctx context.Context, userID uint, quiz *models.Quiz) (*Started, error) { + var st *Started + err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, AttemptLockKey(quiz.ID, userID)).Error; err != nil { + return err + } + var open []models.Attempt + if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}). + Where("quiz_id = ? AND user_id = ? AND completed_at IS NULL", quiz.ID, userID). + Order("started_at ASC, id ASC").Find(&open).Error; err != nil { + return err + } + now := s.now() + var resume *models.Attempt + for i := range open { + a := &open[i] + a.Quiz = quiz + if Expired(a, now, s.Grace) { + if err := s.close(ctx, tx, a, *Deadline(a)); err != nil { + return err + } + continue + } + // An open attempt left from the quiz's learning days may cover + // one category; a test only resumes a whole-quiz attempt. + if resume == nil && a.Category == nil { + resume = a + } + } + var used int64 + if err := tx.Model(&models.Attempt{}).Where("quiz_id = ? AND user_id = ?", quiz.ID, userID).Count(&used).Error; err != nil { + return err + } + if resume != nil { + var answered int64 + if err := tx.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", resume.ID).Count(&answered).Error; err != nil { + return err + } + st = &Started{Attempt: resume, Quiz: quiz, Resumed: true, AttemptsUsed: int(used), AnsweredCount: int(answered)} + return nil + } + if left := AttemptsLeft(quiz, int(used)); left != nil && *left == 0 { + return ErrAttemptLimit + } + attempt := &models.Attempt{QuizID: quiz.ID, UserID: userID, StartedAt: now.UTC()} + maxPoints, err := s.maxPoints(ctx, tx, attempt) + if err != nil { + return err + } + attempt.MaxPoints = maxPoints + if err := tx.Create(attempt).Error; err != nil { + return err + } + st = &Started{Attempt: attempt, Quiz: quiz, AttemptsUsed: int(used) + 1} + return nil + }) + if err != nil { + return nil, err + } + return st, nil +} + +// AttemptsUsed counts the user's attempts on the quiz, open or completed. +func (s *Service) AttemptsUsed(ctx context.Context, quizID, userID uint) (int, error) { + var n int64 + err := s.db(ctx).Model(&models.Attempt{}).Where("quiz_id = ? AND user_id = ?", quizID, userID).Count(&n).Error + return int(n), err +} + +func (s *Service) maxPoints(ctx context.Context, db *gorm.DB, a *models.Attempt) (int, error) { + q := db.WithContext(ctx).Model(&models.Question{}).Where("quiz_id = ?", a.QuizID) if a.Category != nil { q = q.Where("category = ?", *a.Category) } @@ -267,9 +412,12 @@ type Feedback struct { // Answer records or overwrites the user's answer to one question of an // open attempt and returns the feedback. It checks, in order: the attempt // belongs to the user (ErrNotFound), is not finished (ErrCompleted), is -// within the time limit (ErrTimeLimit), the question belongs to the attempt -// (FieldError on question_id), and the answer fits the question type -// (FieldError on answer_ids or known). +// within the time limit, plus the grace of a test (ErrTimeLimit), the +// question belongs to the attempt (FieldError on question_id), a one_way +// test has no answer to the question yet (ErrAnswerLocked; the attempt row +// is locked, so the check is race-free), and the answer fits the question +// type (FieldError on answer_ids or known). The feedback of an open test +// attempt is Withheld. func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerInput) (*Feedback, error) { var fb *Feedback err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { @@ -280,7 +428,7 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI if attempt.CompletedAt != nil { return ErrCompleted } - if d := Deadline(attempt); d != nil && s.now().After(*d) { + if d := AnswerDeadline(attempt, s.Grace); d != nil && s.now().After(*d) { return ErrTimeLimit } var question models.Question @@ -296,6 +444,16 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI if err != nil { return err } + if OneWay(attempt.Quiz) { + var given int64 + if err := tx.WithContext(ctx).Model(&models.AttemptAnswer{}). + Where("attempt_id = ? AND question_id = ?", attempt.ID, question.ID).Count(&given).Error; err != nil { + return err + } + if given > 0 { + return ErrAnswerLocked + } + } row := models.AttemptAnswer{AttemptID: attempt.ID, QuestionID: question.ID} switch question.Type { @@ -372,24 +530,7 @@ func (s *Service) Finish(ctx context.Context, userID, attemptID uint) (*AttemptR if attempt.CompletedAt != nil { return ErrCompleted } - var points []float64 - if err := tx.WithContext(ctx).Model(&models.AttemptAnswer{}).Where("attempt_id = ?", attempt.ID). - Pluck("points", &points).Error; err != nil { - return err - } - maxPoints, err := s.maxPoints(ctx, attempt) - if err != nil { - return err - } - score, percent := Totals(points, maxPoints) - now := s.now().UTC() - return tx.WithContext(ctx).Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{ - "completed_at": now, - "score_points": score, - "max_points": maxPoints, - "score_percent": percent, - "updated_at": now, - }).Error + return s.close(ctx, tx, attempt, s.now()) }) if err != nil { return nil, err @@ -397,6 +538,31 @@ func (s *Service) Finish(ctx context.Context, userID, attemptID uint) (*AttemptR return s.Result(ctx, userID, attemptID) } +// close completes an attempt at the given time: it sums the recorded +// answers, floors the total at 0 and stores the score. Finish closes at the +// current time, an expired test closes at its deadline. +func (s *Service) close(ctx context.Context, tx *gorm.DB, attempt *models.Attempt, at time.Time) error { + tx = tx.WithContext(ctx) + var points []float64 + if err := tx.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", attempt.ID).Pluck("points", &points).Error; err != nil { + return err + } + maxPoints, err := s.maxPoints(ctx, tx, attempt) + if err != nil { + return err + } + score, percent := Totals(points, maxPoints) + at = at.UTC() + now := s.now().UTC() + return tx.Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{ + "completed_at": at, + "score_points": score, + "max_points": maxPoints, + "score_percent": percent, + "updated_at": now, + }).Error +} + // AttemptSummary is an attempt with its quiz and answered question count. type AttemptSummary struct { Attempt models.Attempt @@ -417,12 +583,33 @@ type AttemptResult struct { } // Result loads the user's attempt with every question it covers and the -// answers given. +// answers given. A test attempt past its deadline and grace is closed at +// its deadline first. The questions of a shuffled test come in an order +// seeded by the attempt, the same on every call; other attempts list them +// in sort order. func (s *Service) Result(ctx context.Context, userID, attemptID uint) (*AttemptResult, error) { attempt, err := s.ownedAttempt(ctx, s.DB, userID, attemptID, false) if err != nil { return nil, err } + if Expired(attempt, s.now(), s.Grace) { + err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + locked, err := s.ownedAttempt(ctx, tx, userID, attemptID, true) + if err != nil { + return err + } + if !Expired(locked, s.now(), s.Grace) { + return nil + } + return s.close(ctx, tx, locked, *Deadline(locked)) + }) + if err != nil { + return nil, err + } + if attempt, err = s.ownedAttempt(ctx, s.DB, userID, attemptID, false); err != nil { + return nil, err + } + } category := "" if attempt.Category != nil { category = *attempt.Category @@ -431,6 +618,10 @@ func (s *Service) Result(ctx context.Context, userID, attemptID uint) (*AttemptR if err != nil { return nil, err } + if IsTest(attempt.Quiz) && attempt.Quiz.ShuffleQuestions { + rand.New(rand.NewPCG(uint64(attempt.ID), uint64(attempt.QuizID))). + Shuffle(len(questions), func(i, j int) { questions[i], questions[j] = questions[j], questions[i] }) + } var answers []models.AttemptAnswer if err := s.db(ctx).Where("attempt_id = ?", attempt.ID).Find(&answers).Error; err != nil { return nil, err diff --git a/config/config.yaml b/config/config.yaml index 340e4bf..0c6546c 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -8,3 +8,7 @@ scoring: # Fraction of a question's points subtracted for a wrong answer (0 to 1). # An attempt's total never goes below 0. penalty_wrong: 0 +test: + # Seconds after a test's time limit during which an answer still in flight + # is accepted (0 to 120). Learning quizzes have no grace. + grace_seconds: 5 diff --git a/controllers/api/handlers.go b/controllers/api/handlers.go index 49fac7c..4ac209f 100644 --- a/controllers/api/handlers.go +++ b/controllers/api/handlers.go @@ -62,9 +62,10 @@ func (h *Handlers) ListQuizzes(w http.ResponseWriter, r *http.Request) { wire.WriteJSON(w, http.StatusOK, map[string]any{"data": out}) } -// ShowQuiz answers GET /quizzes/{slug}?category=. +// ShowQuiz answers GET /quizzes/{slug}?category=. A test ignores the +// category and carries attempts_left. func (h *Handlers) ShowQuiz(w http.ResponseWriter, r *http.Request) { - _, svc, ok := h.begin(w, r) + userID, svc, ok := h.begin(w, r) if !ok { return } @@ -73,11 +74,21 @@ func (h *Handlers) ShowQuiz(w http.ResponseWriter, r *http.Request) { h.writeError(w, r, err) return } - wire.WriteJSON(w, http.StatusOK, map[string]any{"data": quizDetail(detail)}) + out := quizDetail(detail) + if classes.IsTest(&detail.Quiz) { + used, err := svc.AttemptsUsed(r.Context(), detail.Quiz.ID, userID) + if err != nil { + wire.WriteOpaque500(w) + return + } + out.AttemptsLeft = classes.AttemptsLeft(&detail.Quiz, used) + } + wire.WriteJSON(w, http.StatusOK, map[string]any{"data": out}) } // StartAttempt answers POST /quizzes/{slug}/attempts with an optional -// {"category": "..."} body. +// {"category": "..."} body: 201 with a new attempt, or 200 when an open +// test attempt is resumed. func (h *Handlers) StartAttempt(w http.ResponseWriter, r *http.Request) { userID, ok := h.user(w, r) if !ok { @@ -92,13 +103,16 @@ func (h *Handlers) StartAttempt(w http.ResponseWriter, r *http.Request) { return } category, _ := input["category"].(string) - a, quiz, err := svc.StartAttempt(r.Context(), userID, r.PathValue("slug"), strings.TrimSpace(category)) + st, err := svc.Start(r.Context(), userID, r.PathValue("slug"), strings.TrimSpace(category)) if err != nil { h.writeError(w, r, err) return } - a.Quiz = quiz - wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": attempt(classes.AttemptSummary{Attempt: *a})}) + status := http.StatusCreated + if st.Resumed { + status = http.StatusOK + } + wire.WriteJSON(w, status, map[string]any{"data": start(st)}) } // Answer answers POST /attempts/{id}/answers with @@ -240,10 +254,12 @@ func (h *Handlers) service(w http.ResponseWriter) (*classes.Service, bool) { return h.Service(gdb), true } var scoring classes.ScoringConfig + grace := classes.DefaultTestGrace if h.App != nil { scoring = classes.ScoringConfigFrom(h.App.Config) + grace = classes.TestGraceFrom(h.App.Config) } - return &classes.Service{DB: gdb, Scoring: scoring}, true + return &classes.Service{DB: gdb, Scoring: scoring, Grace: grace}, true } // input decodes a JSON body (an empty body is {}) and validates it. It @@ -317,10 +333,32 @@ var apiMessages = map[string]string{ "known_required": "The known field is required for this question.", "unknown_category": "The quiz has no questions in this category.", "validation_failed": "The given data was invalid.", + "attempt_limit_reached": "All attempts at this test have been used.", + "answer_locked": "An answer in this test cannot be changed.", + "category_not_allowed": "A test covers the whole quiz; a category cannot be chosen.", } -// writeError maps a service error onto the API's status codes. +// conflicts are the service errors answered with 409 and the code (also +// the lang.api key of the message) the body carries. +var conflicts = []struct { + err error + code string +}{ + {classes.ErrCompleted, "attempt_completed"}, + {classes.ErrTimeLimit, "time_limit_exceeded"}, + {classes.ErrAttemptLimit, "attempt_limit_reached"}, + {classes.ErrAnswerLocked, "answer_locked"}, +} + +// writeError maps a service error onto the API's status codes. A 409 body +// is {"error": message, "code": key}. func (h *Handlers) writeError(w http.ResponseWriter, r *http.Request, err error) { + for _, c := range conflicts { + if errors.Is(err, c.err) { + wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), c.code), "code": c.code}) + return + } + } var fe *classes.FieldError switch { case errors.As(err, &fe): @@ -332,10 +370,6 @@ func (h *Handlers) writeError(w http.ResponseWriter, r *http.Request, err error) key = "quiz_not_found" } wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": h.message(r.Context(), key)}) - case errors.Is(err, classes.ErrCompleted): - wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "attempt_completed")}) - case errors.Is(err, classes.ErrTimeLimit): - wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": h.message(r.Context(), "time_limit_exceeded")}) default: wire.WriteOpaque500(w) } diff --git a/controllers/api/handlers_test.go b/controllers/api/handlers_test.go index 478d8bb..d3862d7 100644 --- a/controllers/api/handlers_test.go +++ b/controllers/api/handlers_test.go @@ -113,16 +113,21 @@ func TestErrorKeysInRuleOrder(t *testing.T) { func TestWriteErrorMapping(t *testing.T) { h := New(nil) cases := []struct { - err error - slug string - code int - msg string + err error + slug string + code int + msg string + errCode string + errField string }{ - {classes.ErrNotFound, "quiz", 404, "Quiz not found"}, - {classes.ErrNotFound, "", 404, "Attempt not found"}, - {classes.ErrCompleted, "", 409, apiMessages["attempt_completed"]}, - {classes.ErrTimeLimit, "", 409, apiMessages["time_limit_exceeded"]}, - {&classes.FieldError{Field: "question_id", Key: "question_not_in_quiz"}, "", 422, apiMessages["question_not_in_quiz"]}, + {classes.ErrNotFound, "quiz", 404, "Quiz not found", "", ""}, + {classes.ErrNotFound, "", 404, "Attempt not found", "", ""}, + {classes.ErrCompleted, "", 409, apiMessages["attempt_completed"], "attempt_completed", ""}, + {classes.ErrTimeLimit, "", 409, apiMessages["time_limit_exceeded"], "time_limit_exceeded", ""}, + {classes.ErrAttemptLimit, "quiz", 409, apiMessages["attempt_limit_reached"], "attempt_limit_reached", ""}, + {classes.ErrAnswerLocked, "", 409, apiMessages["answer_locked"], "answer_locked", ""}, + {&classes.FieldError{Field: "question_id", Key: "question_not_in_quiz"}, "", 422, apiMessages["question_not_in_quiz"], "", "question_id"}, + {&classes.FieldError{Field: "category", Key: "category_not_allowed"}, "quiz", 422, apiMessages["category_not_allowed"], "", "category"}, } for _, tc := range cases { req := httptest.NewRequest("GET", "/", nil) @@ -133,9 +138,17 @@ func TestWriteErrorMapping(t *testing.T) { h.writeError(rec, req, tc.err) var out map[string]any _ = json.Unmarshal(rec.Body.Bytes(), &out) - if rec.Code != tc.code || out["error"] != tc.msg { + if rec.Code != tc.code || out["error"] != tc.msg || tc.msg == "" { t.Errorf("%v = %d %v, want %d %q", tc.err, rec.Code, out, tc.code, tc.msg) } + if tc.errCode != "" && out["code"] != tc.errCode { + t.Errorf("%v code = %v, want %q", tc.err, out["code"], tc.errCode) + } + if tc.errField != "" { + if errs, _ := out["errors"].(map[string]any); errs[tc.errField] == nil { + t.Errorf("%v has no error on %s: %v", tc.err, tc.errField, out) + } + } } rec := httptest.NewRecorder() h.writeError(rec, httptest.NewRequest("GET", "/", nil), http.ErrBodyNotAllowed) diff --git a/controllers/api/json.go b/controllers/api/json.go index b7158a4..06788bc 100644 --- a/controllers/api/json.go +++ b/controllers/api/json.go @@ -28,11 +28,14 @@ type quizSummaryJSON struct { Mode string `json:"mode"` Navigation string `json:"navigation"` MaxAttempts *int `json:"max_attempts"` + // AttemptsLeft is null for a learning quiz or a test without a limit. + AttemptsLeft *int `json:"attempts_left"` + QuestionTypes map[string]int `json:"question_types"` } func quizSummary(s classes.QuizSummary) quizSummaryJSON { q := s.Quiz - return quizSummaryJSON{ + out := quizSummaryJSON{ ID: q.ID, Slug: q.Slug, Title: q.Title, @@ -48,7 +51,13 @@ func quizSummary(s classes.QuizSummary) quizSummaryJSON { Mode: quizMode(&q), Navigation: quizNavigation(&q), MaxAttempts: q.MaxAttempts, + AttemptsLeft: classes.AttemptsLeft(&q, s.AttemptsUsed), + QuestionTypes: make(map[string]int, len(models.QuestionTypes)), } + for _, typ := range models.QuestionTypes { + out.QuestionTypes[typ] = s.QuestionTypes[typ] + } + return out } // quizMode is the quiz's mode as the API reports it: test, or learning for @@ -88,6 +97,8 @@ type quizJSON struct { Mode string `json:"mode"` Navigation string `json:"navigation"` MaxAttempts *int `json:"max_attempts"` + // AttemptsLeft is set by ShowQuiz for a test with a limit. + AttemptsLeft *int `json:"attempts_left"` } // questionJSON is a question as the player sees it before answering: no @@ -177,8 +188,14 @@ type attemptJSON struct { // QuizMode and QuizNavigation tell the player which flow to run. QuizMode string `json:"quiz_mode"` QuizNavigation string `json:"quiz_navigation"` + // SecondsLeft is the time left of an open attempt with a limit, counted + // by the server. + SecondsLeft *int `json:"seconds_left"` } +// timeNow is the clock of seconds_left; tests pin it. +var timeNow = time.Now + func attempt(s classes.AttemptSummary) attemptJSON { a := s.Attempt out := attemptJSON{ @@ -200,6 +217,7 @@ func attempt(s classes.AttemptSummary) attemptJSON { t := wireTime(*d) out.ExpiresAt = &t } + out.SecondsLeft = classes.SecondsLeft(&a, timeNow()) if a.CompletedAt != nil { t := wireTime(*a.CompletedAt) out.CompletedAt = &t @@ -209,6 +227,25 @@ func attempt(s classes.AttemptSummary) attemptJSON { return out } +// startJSON is POST /quizzes/{slug}/attempts: the attempt, whether an open +// test attempt was resumed, and the attempts left after this one (null for +// learning or without a limit). +type startJSON struct { + attemptJSON + Resumed bool `json:"resumed"` + AttemptsLeft *int `json:"attempts_left"` +} + +func start(st *classes.Started) startJSON { + a := *st.Attempt + a.Quiz = st.Quiz + return startJSON{ + attemptJSON: attempt(classes.AttemptSummary{Attempt: a, AnsweredCount: st.AnsweredCount}), + Resumed: st.Resumed, + AttemptsLeft: classes.AttemptsLeft(st.Quiz, st.AttemptsUsed), + } +} + // resultJSON is GET /attempts/{id} and POST /attempts/{id}/finish. // SolutionsWithheld is true for an open test attempt (classes.Withheld). type resultJSON struct { diff --git a/controllers/api/json_test.go b/controllers/api/json_test.go index 6b178f5..afcfaf2 100644 --- a/controllers/api/json_test.go +++ b/controllers/api/json_test.go @@ -198,3 +198,46 @@ func TestTestQuizDetailHidesQuestions(t *testing.T) { t.Errorf("learning detail questions = %d, want 2", len(qs)) } } + +func TestSummaryAndAttemptFields(t *testing.T) { + two := 2 + test := models.Quiz{ID: 3, Slug: "t", Mode: models.ModeTest, MaxAttempts: &two} + m, _ := decode(t, quizSummary(classes.QuizSummary{Quiz: test, QuestionCount: 3, AttemptsUsed: 1, QuestionTypes: map[string]int{models.TypeSingle: 3}})) + if m["attempts_left"] != float64(1) || m["mode"] != models.ModeTest || m["max_attempts"] != float64(2) { + t.Errorf("test summary = %v", m) + } + types, _ := m["question_types"].(map[string]any) + if len(types) != 3 || types["single"] != float64(3) || types["multiple"] != float64(0) || types["self_check"] != float64(0) { + t.Errorf("question_types = %v, want all three keys", m["question_types"]) + } + m, _ = decode(t, quizSummary(classes.QuizSummary{Quiz: models.Quiz{ID: 4, Slug: "l"}, AttemptsUsed: 9})) + if v, ok := m["attempts_left"]; !ok || v != nil { + t.Errorf("learning attempts_left = %v (present %v), want null", v, ok) + } + if types, _ := m["question_types"].(map[string]any); len(types) != 3 { + t.Errorf("learning question_types = %v, want all three keys", m["question_types"]) + } + if m["mode"] != models.ModeLearning || m["navigation"] != models.NavigationFree { + t.Errorf("zero-value quiz mode / navigation = %v / %v", m["mode"], m["navigation"]) + } + + begin := time.Date(2026, 11, 9, 10, 0, 0, 0, time.UTC) + defer func(orig func() time.Time) { timeNow = orig }(timeNow) + timeNow = func() time.Time { return begin.Add(20 * time.Second) } + limit := 60 + timed := &models.Quiz{Slug: "t", Mode: models.ModeTest, Navigation: models.NavigationOneWay, TimeLimitSeconds: &limit} + m, _ = decode(t, attempt(classes.AttemptSummary{Attempt: models.Attempt{ID: 1, StartedAt: begin, Quiz: timed}})) + if m["seconds_left"] != float64(40) || m["quiz_mode"] != models.ModeTest || m["quiz_navigation"] != models.NavigationOneWay { + t.Errorf("open timed attempt = %v", m) + } + done := begin.Add(30 * time.Second) + m, _ = decode(t, attempt(classes.AttemptSummary{Attempt: models.Attempt{ID: 1, StartedAt: begin, CompletedAt: &done, Quiz: timed}})) + if v, ok := m["seconds_left"]; !ok || v != nil { + t.Errorf("completed seconds_left = %v (present %v), want null", v, ok) + } + + m, _ = decode(t, start(&classes.Started{Attempt: &models.Attempt{ID: 2, StartedAt: begin}, Quiz: &test, Resumed: true, AttemptsUsed: 2, AnsweredCount: 1})) + if m["resumed"] != true || m["attempts_left"] != float64(0) || m["answered_count"] != float64(1) || m["quiz_slug"] != "t" { + t.Errorf("start json = %v", m) + } +} diff --git a/lang/en/lang.yaml b/lang/en/lang.yaml index a7ac032..0dcd08c 100644 --- a/lang/en/lang.yaml +++ b/lang/en/lang.yaml @@ -12,6 +12,9 @@ api: single_one_answer: Choose exactly one answer for this question. known_required: The known field is required for this question. unknown_category: The quiz has no questions in this category. + attempt_limit_reached: All attempts at this test have been used. + answer_locked: An answer in this test cannot be changed. + category_not_allowed: A test covers the whole quiz; a category cannot be chosen. permissions: access_quizzes: Manage quizzes, questions and answers access_attempts: View quiz attempts diff --git a/lang/pl/lang.yaml b/lang/pl/lang.yaml index 1f2962c..5cef763 100644 --- a/lang/pl/lang.yaml +++ b/lang/pl/lang.yaml @@ -12,6 +12,9 @@ api: single_one_answer: Wybierz dokładnie jedną odpowiedź. known_required: Zaznacz, czy znasz odpowiedź. unknown_category: Quiz nie ma pytań w tej kategorii. + attempt_limit_reached: Wykorzystano już wszystkie podejścia do tego testu. + answer_locked: W tym teście nie można zmienić udzielonej odpowiedzi. + category_not_allowed: Test obejmuje cały quiz; nie można wybrać kategorii. permissions: access_quizzes: Zarządzanie quizami, pytaniami i odpowiedziami access_attempts: Podgląd podejść do quizów