feat: serve the quiz API under /_quizzes/api/v1 behind jwt.auth
This commit is contained in:
270
controllers/api/json.go
Normal file
270
controllers/api/json.go
Normal file
@@ -0,0 +1,270 @@
|
||||
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, explanation or answer_text.
|
||||
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"`
|
||||
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)),
|
||||
}
|
||||
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)} }
|
||||
Reference in New Issue
Block a user