- migration: open question type, rubric, max_length, quiz grader and the grading and review columns of attempt answers - open answers are checked, stored as pending and never scored on answer; a changed text resets the grading state - student JSON gains text, grading_status, grader_feedback, review_comment, max_length, needs_review and open_answers; the rubric is never serialized
462 lines
16 KiB
Go
462 lines
16 KiB
Go
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. An open question carries its
|
|
// max_length instead (never its rubric or model answer).
|
|
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"`
|
|
// MaxLength is the longest answer of an open question, in characters.
|
|
MaxLength *int `json:"max_length,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
|
|
}
|
|
if q.Type == models.TypeOpen {
|
|
n := classes.MaxLength(q)
|
|
out.MaxLength = &n
|
|
}
|
|
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"`
|
|
// NeedsReview is true while an open answer of the attempt waits for
|
|
// the owner (pending, graded or failed); OpenAnswers counts its open
|
|
// answers.
|
|
NeedsReview bool `json:"needs_review"`
|
|
OpenAnswers int `json:"open_answers"`
|
|
}
|
|
|
|
// 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),
|
|
NeedsReview: s.Unreviewed > 0,
|
|
OpenAnswers: s.OpenAnswers,
|
|
}
|
|
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, OpenAnswers: st.OpenAnswers, Unreviewed: st.Unreviewed,
|
|
}),
|
|
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.
|
|
//
|
|
// An open question adds the student's text_answer (always: it is their own
|
|
// input), max_length and, unless withheld, grading_status, grader_feedback
|
|
// and review_comment; its is_correct and points_earned stay null until the
|
|
// answer is graded or reviewed. The rubric is never part of it.
|
|
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"`
|
|
// TextAnswer is the student's text of an open question. It is not
|
|
// "text" as in the feedback: text is already the question's text here.
|
|
TextAnswer *string `json:"text_answer"`
|
|
GradingStatus *string `json:"grading_status"`
|
|
GraderFeedback *string `json:"grader_feedback"`
|
|
ReviewComment *string `json:"review_comment"`
|
|
// MaxLength is the longest answer of an open question.
|
|
MaxLength *int `json:"max_length,omitempty"`
|
|
}
|
|
|
|
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 q.Type == models.TypeOpen {
|
|
n := classes.MaxLength(q)
|
|
row.MaxLength = &n
|
|
}
|
|
if a := qr.Answer; a != nil {
|
|
row.Answered = true
|
|
row.AnswerIDs = wire.Slice(a.AnswerIDs.Data)
|
|
row.Known = a.SelfCheckKnown
|
|
if q.Type == models.TypeOpen {
|
|
row.TextAnswer = a.TextAnswer
|
|
if !withheld {
|
|
row.GradingStatus, row.GraderFeedback, row.ReviewComment = a.GradingStatus, a.GraderFeedback, a.ReviewComment
|
|
if openScored(a) {
|
|
correct, points := a.IsCorrect, a.Points
|
|
row.IsCorrect, row.PointsEarned = &correct, &points
|
|
}
|
|
}
|
|
} else 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.
|
|
//
|
|
// The feedback of an open question carries the student's text and, unless
|
|
// withheld, the model answer (answer_text), the explanation, grading_status,
|
|
// grader_feedback and review_comment; is_correct and points stay null until
|
|
// the answer is graded or reviewed. These four keys are null for every other
|
|
// type. The rubric is never part of it.
|
|
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"`
|
|
Text *string `json:"text"`
|
|
GradingStatus *string `json:"grading_status"`
|
|
GraderFeedback *string `json:"grader_feedback"`
|
|
ReviewComment *string `json:"review_comment"`
|
|
}
|
|
|
|
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 q.Type == models.TypeOpen {
|
|
return openFeedback(fb, out)
|
|
}
|
|
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
|
|
}
|
|
|
|
// openFeedback fills the feedback of an open question from its stored row.
|
|
func openFeedback(fb *classes.Feedback, out feedbackJSON) feedbackJSON {
|
|
q, a := fb.Question, fb.Answer
|
|
if a != nil {
|
|
out.Text = a.TextAnswer
|
|
}
|
|
if fb.Withheld {
|
|
return out
|
|
}
|
|
out.CorrectAnswerIDs = []uint{}
|
|
out.Explanation, out.AnswerText = q.Explanation, q.AnswerText
|
|
if a == nil {
|
|
return out
|
|
}
|
|
out.GradingStatus, out.GraderFeedback, out.ReviewComment = a.GradingStatus, a.GraderFeedback, a.ReviewComment
|
|
if openScored(a) {
|
|
correct, points := a.IsCorrect, a.Points
|
|
out.IsCorrect, out.Points = &correct, &points
|
|
}
|
|
return out
|
|
}
|
|
|
|
// openScored reports whether an open answer has a score to show: graded
|
|
// by a grader or reviewed by the owner. Pending and failed answers have
|
|
// none yet.
|
|
func openScored(a *models.AttemptAnswer) bool {
|
|
if a == nil || a.GradingStatus == nil {
|
|
return false
|
|
}
|
|
return *a.GradingStatus == models.GradingGraded || *a.GradingStatus == models.GradingReviewed
|
|
}
|
|
|
|
func wireTime(t time.Time) wire.Time { return wire.Time{Time: t.UTC().Truncate(time.Second)} }
|