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
This commit is contained in:
Jakub Zych
2026-11-09 14:07:37 +01:00
parent 6c6ecae9d8
commit 487d573208
12 changed files with 669 additions and 72 deletions

View File

@@ -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 {