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"` } func quizSummary(s classes.QuizSummary) quizSummaryJSON { q := s.Quiz return 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, } } // quizJSON is GET /quizzes/{slug}: the quiz without any solution. 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"` } // 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)), } 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"` } 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, } 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 } 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 } // resultJSON is GET /attempts/{id} and POST /attempts/{id}/finish. type resultJSON struct { attemptJSON 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. 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 { out := resultJSON{attemptJSON: attempt(r.AttemptSummary), 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 row.IsCorrect = a.IsCorrect row.PointsEarned = a.Points } reveal := 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. type feedbackJSON struct { QuestionID uint `json:"question_id"` 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, 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)), } 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}) } return out } func wireTime(t time.Time) wire.Time { return wire.Time{Time: t.UTC().Truncate(time.Second)} }