feat: add test mode to quizzes and withhold its solutions
- mode, navigation and max_attempts columns with Go-side defaults - classes.Withheld gates every solution field of the answer feedback and the attempt result while a test attempt is open - a test's quiz detail lists no questions before the attempt starts - admin fields and lang keys for the three settings
This commit is contained in:
@@ -25,6 +25,9 @@ type quizSummaryJSON struct {
|
||||
ShuffleQuestions bool `json:"shuffle_questions"`
|
||||
BestScorePercent *float64 `json:"best_score_percent"`
|
||||
AttemptsCount int `json:"attempts_count"`
|
||||
Mode string `json:"mode"`
|
||||
Navigation string `json:"navigation"`
|
||||
MaxAttempts *int `json:"max_attempts"`
|
||||
}
|
||||
|
||||
func quizSummary(s classes.QuizSummary) quizSummaryJSON {
|
||||
@@ -42,10 +45,33 @@ func quizSummary(s classes.QuizSummary) quizSummaryJSON {
|
||||
ShuffleQuestions: q.ShuffleQuestions,
|
||||
BestScorePercent: s.BestScorePercent,
|
||||
AttemptsCount: s.AttemptsCount,
|
||||
Mode: quizMode(&q),
|
||||
Navigation: quizNavigation(&q),
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
// quizJSON is GET /quizzes/{slug}: the quiz without any solution.
|
||||
// quizMode is the quiz's mode as the API reports it: test, or learning for
|
||||
// a zero or unknown value (the classes.IsTest rule).
|
||||
func quizMode(q *models.Quiz) string {
|
||||
if classes.IsTest(q) {
|
||||
return models.ModeTest
|
||||
}
|
||||
return models.ModeLearning
|
||||
}
|
||||
|
||||
// quizNavigation is one_way when the quiz says so and free otherwise.
|
||||
func quizNavigation(q *models.Quiz) string {
|
||||
if q != nil && q.Navigation == models.NavigationOneWay {
|
||||
return models.NavigationOneWay
|
||||
}
|
||||
return models.NavigationFree
|
||||
}
|
||||
|
||||
// quizJSON is GET /quizzes/{slug}: the quiz without any solution. A test
|
||||
// lists no questions (QuestionCount still counts them): the test player
|
||||
// reads them from its attempt, so a timed test cannot be read before its
|
||||
// clock starts.
|
||||
type quizJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
@@ -59,6 +85,9 @@ type quizJSON struct {
|
||||
QuestionCount int `json:"question_count"`
|
||||
Categories []string `json:"categories"`
|
||||
Questions []questionJSON `json:"questions"`
|
||||
Mode string `json:"mode"`
|
||||
Navigation string `json:"navigation"`
|
||||
MaxAttempts *int `json:"max_attempts"`
|
||||
}
|
||||
|
||||
// questionJSON is a question as the player sees it before answering: no
|
||||
@@ -96,6 +125,12 @@ func quizDetail(d *classes.QuizDetail) quizJSON {
|
||||
QuestionCount: len(d.Questions),
|
||||
Categories: wire.Slice(d.Categories),
|
||||
Questions: make([]questionJSON, 0, len(d.Questions)),
|
||||
Mode: quizMode(&q),
|
||||
Navigation: quizNavigation(&q),
|
||||
MaxAttempts: q.MaxAttempts,
|
||||
}
|
||||
if classes.IsTest(&q) {
|
||||
return out
|
||||
}
|
||||
for _, qu := range d.Questions {
|
||||
out.Questions = append(out.Questions, question(qu))
|
||||
@@ -139,19 +174,24 @@ type attemptJSON struct {
|
||||
ScorePercent *float64 `json:"score_percent"`
|
||||
PassingScore int `json:"passing_score"`
|
||||
Passed *bool `json:"passed"`
|
||||
// QuizMode and QuizNavigation tell the player which flow to run.
|
||||
QuizMode string `json:"quiz_mode"`
|
||||
QuizNavigation string `json:"quiz_navigation"`
|
||||
}
|
||||
|
||||
func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
a := s.Attempt
|
||||
out := attemptJSON{
|
||||
ID: a.ID,
|
||||
QuizID: a.QuizID,
|
||||
Category: a.Category,
|
||||
StartedAt: wireTime(a.StartedAt),
|
||||
AnsweredCount: s.AnsweredCount,
|
||||
ScorePoints: a.ScorePoints,
|
||||
MaxPoints: a.MaxPoints,
|
||||
ScorePercent: a.ScorePercent,
|
||||
ID: a.ID,
|
||||
QuizID: a.QuizID,
|
||||
Category: a.Category,
|
||||
StartedAt: wireTime(a.StartedAt),
|
||||
AnsweredCount: s.AnsweredCount,
|
||||
ScorePoints: a.ScorePoints,
|
||||
MaxPoints: a.MaxPoints,
|
||||
ScorePercent: a.ScorePercent,
|
||||
QuizMode: quizMode(a.Quiz),
|
||||
QuizNavigation: quizNavigation(a.Quiz),
|
||||
}
|
||||
if a.Quiz != nil {
|
||||
out.QuizSlug, out.QuizTitle, out.PassingScore = a.Quiz.Slug, a.Quiz.Title, a.Quiz.PassingScore
|
||||
@@ -170,15 +210,20 @@ func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
}
|
||||
|
||||
// resultJSON is GET /attempts/{id} and POST /attempts/{id}/finish.
|
||||
// SolutionsWithheld is true for an open test attempt (classes.Withheld).
|
||||
type resultJSON struct {
|
||||
attemptJSON
|
||||
Questions []questionResultJSON `json:"questions"`
|
||||
SolutionsWithheld bool `json:"solutions_withheld"`
|
||||
Questions []questionResultJSON `json:"questions"`
|
||||
}
|
||||
|
||||
// questionResultJSON is one question of a result. The solution fields
|
||||
// (is_correct of the answers, correct_answer_ids, explanation, answer_text)
|
||||
// are revealed for an answered question and for every question once the
|
||||
// attempt is completed; otherwise they are null.
|
||||
// attempt is completed; otherwise they are null. While the solutions are
|
||||
// withheld (an open test attempt) every solution field, is_correct and
|
||||
// points_earned included, is null for every question; answered, answer_ids
|
||||
// and known (the student's own input) stay.
|
||||
type questionResultJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
@@ -188,8 +233,8 @@ type questionResultJSON struct {
|
||||
Answered bool `json:"answered"`
|
||||
AnswerIDs []uint `json:"answer_ids"`
|
||||
Known *bool `json:"known"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
PointsEarned float64 `json:"points_earned"`
|
||||
IsCorrect *bool `json:"is_correct"`
|
||||
PointsEarned *float64 `json:"points_earned"`
|
||||
CorrectAnswerIDs []uint `json:"correct_answer_ids"`
|
||||
Explanation *string `json:"explanation"`
|
||||
AnswerText *string `json:"answer_text"`
|
||||
@@ -204,7 +249,12 @@ type resultAnswerJSON struct {
|
||||
}
|
||||
|
||||
func result(r *classes.AttemptResult) resultJSON {
|
||||
out := resultJSON{attemptJSON: attempt(r.AttemptSummary), Questions: make([]questionResultJSON, 0, len(r.Questions))}
|
||||
withheld := classes.Withheld(&r.Attempt)
|
||||
out := resultJSON{
|
||||
attemptJSON: attempt(r.AttemptSummary),
|
||||
SolutionsWithheld: withheld,
|
||||
Questions: make([]questionResultJSON, 0, len(r.Questions)),
|
||||
}
|
||||
completed := r.Attempt.CompletedAt != nil
|
||||
for _, qr := range r.Questions {
|
||||
q := qr.Question
|
||||
@@ -221,10 +271,16 @@ func result(r *classes.AttemptResult) resultJSON {
|
||||
row.Answered = true
|
||||
row.AnswerIDs = wire.Slice(a.AnswerIDs.Data)
|
||||
row.Known = a.SelfCheckKnown
|
||||
row.IsCorrect = a.IsCorrect
|
||||
row.PointsEarned = a.Points
|
||||
if !withheld {
|
||||
correct, points := a.IsCorrect, a.Points
|
||||
row.IsCorrect, row.PointsEarned = &correct, &points
|
||||
}
|
||||
} else if !withheld {
|
||||
// Learning output keeps false and 0 for an unanswered question.
|
||||
correct, points := false, 0.0
|
||||
row.IsCorrect, row.PointsEarned = &correct, &points
|
||||
}
|
||||
reveal := completed || row.Answered
|
||||
reveal := !withheld && (completed || row.Answered)
|
||||
if reveal {
|
||||
row.CorrectAnswerIDs = classes.CorrectAnswerIDs(q)
|
||||
row.Explanation = q.Explanation
|
||||
@@ -244,11 +300,14 @@ func result(r *classes.AttemptResult) resultJSON {
|
||||
return out
|
||||
}
|
||||
|
||||
// feedbackJSON is POST /attempts/{id}/answers.
|
||||
// feedbackJSON is POST /attempts/{id}/answers. When Withheld (an answer to
|
||||
// an open test attempt) every solution field is null and the answers carry
|
||||
// only their id and text.
|
||||
type feedbackJSON struct {
|
||||
QuestionID uint `json:"question_id"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
Points float64 `json:"points"`
|
||||
Withheld bool `json:"withheld"`
|
||||
IsCorrect *bool `json:"is_correct"`
|
||||
Points *float64 `json:"points"`
|
||||
CorrectAnswerIDs []uint `json:"correct_answer_ids"`
|
||||
Explanation *string `json:"explanation"`
|
||||
AnswerText *string `json:"answer_text"`
|
||||
@@ -257,18 +316,20 @@ type feedbackJSON struct {
|
||||
|
||||
func feedback(fb *classes.Feedback) feedbackJSON {
|
||||
q := fb.Question
|
||||
out := feedbackJSON{
|
||||
QuestionID: q.ID,
|
||||
IsCorrect: fb.Result.IsCorrect,
|
||||
Points: fb.Result.Points,
|
||||
CorrectAnswerIDs: wire.Slice(fb.CorrectAnswerIDs),
|
||||
Explanation: q.Explanation,
|
||||
AnswerText: q.AnswerText,
|
||||
Answers: make([]resultAnswerJSON, 0, len(q.Answers)),
|
||||
out := feedbackJSON{QuestionID: q.ID, Withheld: fb.Withheld, Answers: make([]resultAnswerJSON, 0, len(q.Answers))}
|
||||
if fb.Withheld {
|
||||
for _, a := range q.Answers {
|
||||
out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text})
|
||||
}
|
||||
return out
|
||||
}
|
||||
correct, points := fb.Result.IsCorrect, fb.Result.Points
|
||||
out.IsCorrect, out.Points = &correct, &points
|
||||
out.CorrectAnswerIDs = wire.Slice(fb.CorrectAnswerIDs)
|
||||
out.Explanation, out.AnswerText = q.Explanation, q.AnswerText
|
||||
for _, a := range q.Answers {
|
||||
correct := a.IsCorrect
|
||||
out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text, IsCorrect: &correct, Explanation: a.Explanation})
|
||||
c := a.IsCorrect
|
||||
out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text, IsCorrect: &c, Explanation: a.Explanation})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user