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:
Jakub Zych
2026-11-09 17:48:11 +01:00
parent ef38b81fd9
commit 297780cb8e
19 changed files with 893 additions and 48 deletions

View File

@@ -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)} }