feat: add open questions with a pending grading state
- 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
This commit is contained in:
@@ -103,7 +103,8 @@ type quizJSON struct {
|
||||
|
||||
// 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.
|
||||
// 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"`
|
||||
@@ -112,8 +113,10 @@ type questionJSON struct {
|
||||
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"`
|
||||
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 {
|
||||
@@ -162,6 +165,10 @@ func question(q models.Question) questionJSON {
|
||||
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})
|
||||
}
|
||||
@@ -191,6 +198,11 @@ type attemptJSON struct {
|
||||
// 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.
|
||||
@@ -209,6 +221,8 @@ func attempt(s classes.AttemptSummary) attemptJSON {
|
||||
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
|
||||
@@ -240,7 +254,9 @@ func start(st *classes.Started) startJSON {
|
||||
a := *st.Attempt
|
||||
a.Quiz = st.Quiz
|
||||
return startJSON{
|
||||
attemptJSON: attempt(classes.AttemptSummary{Attempt: a, AnsweredCount: st.AnsweredCount}),
|
||||
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),
|
||||
}
|
||||
@@ -261,6 +277,11 @@ type resultJSON struct {
|
||||
// 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"`
|
||||
@@ -276,6 +297,14 @@ type questionResultJSON struct {
|
||||
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 {
|
||||
@@ -304,11 +333,24 @@ func result(r *classes.AttemptResult) resultJSON {
|
||||
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 !withheld {
|
||||
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
|
||||
}
|
||||
@@ -340,6 +382,12 @@ func result(r *classes.AttemptResult) resultJSON {
|
||||
// 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"`
|
||||
@@ -349,11 +397,18 @@ type feedbackJSON struct {
|
||||
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})
|
||||
@@ -371,4 +426,36 @@ func feedback(fb *classes.Feedback) feedbackJSON {
|
||||
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)} }
|
||||
|
||||
Reference in New Issue
Block a user