package api import ( "time" "git.golem15.com/golem15/sm-quizzes-plugin/classes" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/wire" ) // The response shapes are built field by field, so a column added to a // model never leaks into the API. // quizSummaryJSON is one row of GET /quizzes. type quizSummaryJSON struct { ID uint `json:"id"` Slug string `json:"slug"` Title string `json:"title"` Description *string `json:"description"` Context *string `json:"context"` QuestionCount int `json:"question_count"` TimeLimitSeconds *int `json:"time_limit_seconds"` PassingScore int `json:"passing_score"` RoundSize *int `json:"round_size"` 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"` // 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 out := quizSummaryJSON{ ID: q.ID, Slug: q.Slug, Title: q.Title, Description: q.Description, Context: q.Context, QuestionCount: s.QuestionCount, TimeLimitSeconds: q.TimeLimitSeconds, PassingScore: q.PassingScore, RoundSize: q.RoundSize, ShuffleQuestions: q.ShuffleQuestions, BestScorePercent: s.BestScorePercent, AttemptsCount: s.AttemptsCount, 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 // 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"` Title string `json:"title"` Description *string `json:"description"` Context *string `json:"context"` TimeLimitSeconds *int `json:"time_limit_seconds"` PassingScore int `json:"passing_score"` RoundSize *int `json:"round_size"` ShuffleQuestions bool `json:"shuffle_questions"` 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"` // 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 // is_correct or explanation, and no answer_text except for self_check // flashcards, which have no solution to hide. type questionJSON struct { ID uint `json:"id"` Text string `json:"text"` Type string `json:"type"` Category *string `json:"category"` Points int `json:"points"` SortOrder int `json:"sort_order"` // AnswerText is the model answer of a self_check question only. AnswerText *string `json:"answer_text,omitempty"` Answers []answerJSON `json:"answers"` } type answerJSON struct { ID uint `json:"id"` Text string `json:"text"` } func quizDetail(d *classes.QuizDetail) quizJSON { q := d.Quiz out := quizJSON{ ID: q.ID, Slug: q.Slug, Title: q.Title, Description: q.Description, Context: q.Context, TimeLimitSeconds: q.TimeLimitSeconds, PassingScore: q.PassingScore, RoundSize: q.RoundSize, ShuffleQuestions: q.ShuffleQuestions, 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)) } return out } func question(q models.Question) questionJSON { out := questionJSON{ ID: q.ID, Text: q.Text, Type: q.Type, Category: q.Category, Points: q.Points, SortOrder: q.SortOrder, Answers: make([]answerJSON, 0, len(q.Answers)), } if q.Type == models.TypeSelfCheck { out.AnswerText = q.AnswerText } for _, a := range q.Answers { out.Answers = append(out.Answers, answerJSON{ID: a.ID, Text: a.Text}) } return out } // attemptJSON is an attempt without its questions: POST // /quizzes/{slug}/attempts and each row of GET /attempts. type attemptJSON struct { ID uint `json:"id"` QuizID uint `json:"quiz_id"` QuizSlug string `json:"quiz_slug"` QuizTitle string `json:"quiz_title"` Category *string `json:"category"` StartedAt wire.Time `json:"started_at"` ExpiresAt *wire.Time `json:"expires_at"` CompletedAt *wire.Time `json:"completed_at"` AnsweredCount int `json:"answered_count"` ScorePoints float64 `json:"score_points"` MaxPoints int `json:"max_points"` 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"` // 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{ 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 } if d := classes.Deadline(&a); d != nil { t := wireTime(*d) out.ExpiresAt = &t } out.SecondsLeft = classes.SecondsLeft(&a, timeNow()) if a.CompletedAt != nil { t := wireTime(*a.CompletedAt) out.CompletedAt = &t passed := a.ScorePercent != nil && *a.ScorePercent >= float64(out.PassingScore) out.Passed = &passed } 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 { attemptJSON 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. 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"` Type string `json:"type"` Category *string `json:"category"` Points int `json:"points"` Answered bool `json:"answered"` AnswerIDs []uint `json:"answer_ids"` Known *bool `json:"known"` 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"` Answers []resultAnswerJSON `json:"answers"` } type resultAnswerJSON struct { ID uint `json:"id"` Text string `json:"text"` IsCorrect *bool `json:"is_correct"` Explanation *string `json:"explanation"` } func result(r *classes.AttemptResult) resultJSON { 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 row := questionResultJSON{ ID: q.ID, Text: q.Text, Type: q.Type, Category: q.Category, Points: q.Points, AnswerIDs: []uint{}, Answers: make([]resultAnswerJSON, 0, len(q.Answers)), } if a := qr.Answer; a != nil { row.Answered = true row.AnswerIDs = wire.Slice(a.AnswerIDs.Data) row.Known = a.SelfCheckKnown 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 := !withheld && (completed || row.Answered) if reveal { row.CorrectAnswerIDs = classes.CorrectAnswerIDs(q) row.Explanation = q.Explanation row.AnswerText = q.AnswerText } for _, ans := range q.Answers { ra := resultAnswerJSON{ID: ans.ID, Text: ans.Text} if reveal { correct := ans.IsCorrect ra.IsCorrect = &correct ra.Explanation = ans.Explanation } row.Answers = append(row.Answers, ra) } out.Questions = append(out.Questions, row) } return out } // 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"` 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"` Answers []resultAnswerJSON `json:"answers"` } func feedback(fb *classes.Feedback) feedbackJSON { q := fb.Question 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 { c := a.IsCorrect out.Answers = append(out.Answers, resultAnswerJSON{ID: a.ID, Text: a.Text, IsCorrect: &c, Explanation: a.Explanation}) } return out } func wireTime(t time.Time) wire.Time { return wire.Time{Time: t.UTC().Truncate(time.Second)} }