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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user