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:
@@ -8,8 +8,31 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
)
|
||||
|
||||
// Grading states of an open answer (AttemptAnswer.GradingStatus).
|
||||
const (
|
||||
// GradingPending waits for a grader or for the owner.
|
||||
GradingPending = "pending"
|
||||
// GradingGraded holds a grader's score the owner has not confirmed.
|
||||
GradingGraded = "graded"
|
||||
// GradingFailed is an answer the grader could not score; the owner
|
||||
// grades it.
|
||||
GradingFailed = "failed"
|
||||
// GradingReviewed is final: the owner confirmed or set the score.
|
||||
GradingReviewed = "reviewed"
|
||||
)
|
||||
|
||||
// GradingStatuses lists the valid values of AttemptAnswer.GradingStatus.
|
||||
var GradingStatuses = []string{GradingPending, GradingGraded, GradingFailed, GradingReviewed}
|
||||
|
||||
// AttemptAnswer is the user's answer to one question of an attempt. There is
|
||||
// one row per attempt and question; answering again overwrites it.
|
||||
//
|
||||
// TextAnswer and the grading fields belong to open questions only:
|
||||
// GradingStatus is NULL for every other row. GradingSeq grows with every new
|
||||
// text, so a grading job for an older text is ignored; GradingAttempts counts
|
||||
// the grader runs for the current text. The review fields record the owner's
|
||||
// decision: ReviewedByUserID for a frontend user (MCP), ReviewedByAdminID for
|
||||
// a backend administrator.
|
||||
type AttemptAnswer struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
AttemptID uint `gorm:"column:attempt_id"`
|
||||
@@ -20,6 +43,20 @@ type AttemptAnswer struct {
|
||||
Points float64 `gorm:"column:points"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
|
||||
TextAnswer *string `gorm:"column:text_answer"`
|
||||
GradingStatus *string `gorm:"column:grading_status"`
|
||||
GradingSeq int `gorm:"column:grading_seq"`
|
||||
GradingAttempts int `gorm:"column:grading_attempts"`
|
||||
Grader *string `gorm:"column:grader"`
|
||||
GraderPoints *float64 `gorm:"column:grader_points"`
|
||||
GraderFeedback *string `gorm:"column:grader_feedback"`
|
||||
GradingError *string `gorm:"column:grading_error"`
|
||||
GradedAt *time.Time `gorm:"column:graded_at"`
|
||||
ReviewComment *string `gorm:"column:review_comment"`
|
||||
ReviewedAt *time.Time `gorm:"column:reviewed_at"`
|
||||
ReviewedByUserID *uint `gorm:"column:reviewed_by_user_id"`
|
||||
ReviewedByAdminID *uint `gorm:"column:reviewed_by_admin_id"`
|
||||
}
|
||||
|
||||
// TableName is the plugin-prefixed table.
|
||||
|
||||
@@ -9,13 +9,25 @@ const (
|
||||
TypeSingle = "single"
|
||||
TypeMultiple = "multiple"
|
||||
TypeSelfCheck = "self_check"
|
||||
TypeOpen = "open"
|
||||
)
|
||||
|
||||
// QuestionTypes lists the valid values of Question.Type.
|
||||
var QuestionTypes = []string{TypeSingle, TypeMultiple, TypeSelfCheck}
|
||||
var QuestionTypes = []string{TypeSingle, TypeMultiple, TypeSelfCheck, TypeOpen}
|
||||
|
||||
// Limits of the answer length of an open question, in characters.
|
||||
const (
|
||||
// DefaultMaxLength applies when MaxLength is nil.
|
||||
DefaultMaxLength = 2000
|
||||
// MaxMaxLength is the largest MaxLength the column accepts.
|
||||
MaxMaxLength = 20000
|
||||
)
|
||||
|
||||
// Question is one question of a quiz. A self_check question is a flashcard:
|
||||
// the user reveals AnswerText and marks whether they knew it.
|
||||
// the user reveals AnswerText and marks whether they knew it. An open
|
||||
// question is answered in free text and graded against AnswerText (the
|
||||
// model answer) and the owner-only Rubric; MaxLength caps the answer
|
||||
// (DefaultMaxLength when nil).
|
||||
type Question struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
QuizID uint `gorm:"column:quiz_id"`
|
||||
@@ -24,6 +36,8 @@ type Question struct {
|
||||
Category *string `gorm:"column:category"`
|
||||
AnswerText *string `gorm:"column:answer_text"`
|
||||
Explanation *string `gorm:"column:explanation"`
|
||||
Rubric *string `gorm:"column:rubric"`
|
||||
MaxLength *int `gorm:"column:max_length"`
|
||||
SortOrder int `gorm:"column:sort_order"`
|
||||
Points int `gorm:"column:points"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
@@ -37,7 +51,7 @@ func (Question) TableName() string { return "golem15_quizzes_questions" }
|
||||
|
||||
// Fillable lists the columns mass assignment may set.
|
||||
func (Question) Fillable() []string {
|
||||
return []string{"quiz_id", "text", "type", "category", "answer_text", "explanation", "sort_order", "points"}
|
||||
return []string{"quiz_id", "text", "type", "category", "answer_text", "explanation", "sort_order", "points", "rubric", "max_length"}
|
||||
}
|
||||
|
||||
// Rules are checked by the admin API on every save.
|
||||
@@ -45,9 +59,10 @@ func (Question) Rules() map[string]string {
|
||||
return map[string]string{
|
||||
"quiz_id": "required|integer|min:1",
|
||||
"text": "required",
|
||||
"type": "required|in:single,multiple,self_check",
|
||||
"type": "required|in:single,multiple,self_check,open",
|
||||
"category": "nullable|max:255",
|
||||
"sort_order": "nullable|integer",
|
||||
"points": "required|integer|min:0",
|
||||
"max_length": "nullable|integer|between:1,20000",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -32,6 +33,8 @@ var QuizNavigations = []string{NavigationFree, NavigationOneWay}
|
||||
// free-form link to a record of another plugin, such as "acme.shop:course:12".
|
||||
// Mode test withholds solutions until the attempt is finished; Navigation
|
||||
// (free or one_way) and MaxAttempts (nil is unlimited) only apply to tests.
|
||||
// Grader is the code of the grader of its open answers (graders.Manual: the
|
||||
// owner grades them); an unknown code behaves as manual.
|
||||
type Quiz struct {
|
||||
ID uint `gorm:"column:id;primaryKey"`
|
||||
Title string `gorm:"column:title"`
|
||||
@@ -46,6 +49,7 @@ type Quiz struct {
|
||||
Mode string `gorm:"column:mode"`
|
||||
Navigation string `gorm:"column:navigation"`
|
||||
MaxAttempts *int `gorm:"column:max_attempts"`
|
||||
Grader string `gorm:"column:grader"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||
Questions []Question `gorm:"foreignKey:QuizID"`
|
||||
@@ -59,7 +63,7 @@ func (Quiz) Fillable() []string {
|
||||
return []string{
|
||||
"title", "slug", "description", "context", "is_published",
|
||||
"time_limit_seconds", "passing_score", "shuffle_questions", "round_size",
|
||||
"mode", "navigation", "max_attempts",
|
||||
"mode", "navigation", "max_attempts", "grader",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +80,7 @@ func (Quiz) Rules() map[string]string {
|
||||
"mode": "nullable|in:learning,test",
|
||||
"navigation": "nullable|in:free,one_way",
|
||||
"max_attempts": "nullable|integer|min:1",
|
||||
"grader": "nullable|max:64",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,8 +99,8 @@ func (q *Quiz) BeforeSave(*gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillDefaults stores an empty mode or navigation as learning / free and a
|
||||
// non-positive attempt limit as unlimited. GORM writes Go zero values, so
|
||||
// fillDefaults stores an empty mode or navigation as learning / free, an
|
||||
// empty grader as manual and a non-positive attempt limit as unlimited. GORM writes Go zero values, so
|
||||
// without it a models.Quiz{} would hit the columns' CHECK constraints.
|
||||
func (q *Quiz) fillDefaults() {
|
||||
if q.Mode == "" {
|
||||
@@ -104,6 +109,9 @@ func (q *Quiz) fillDefaults() {
|
||||
if q.Navigation == "" {
|
||||
q.Navigation = NavigationFree
|
||||
}
|
||||
if q.Grader == "" {
|
||||
q.Grader = graders.Manual
|
||||
}
|
||||
if q.MaxAttempts != nil && *q.MaxAttempts <= 0 {
|
||||
q.MaxAttempts = nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
|
||||
)
|
||||
|
||||
func TestQuizDefaults(t *testing.T) {
|
||||
zero, negative := 0, -1
|
||||
@@ -16,14 +22,52 @@ func TestQuizDefaults(t *testing.T) {
|
||||
if q.Mode != ModeLearning || q.Navigation != NavigationFree || q.MaxAttempts != nil {
|
||||
t.Errorf("%s with max_attempts %d = %q %q %v, want learning free nil", name, *limit, q.Mode, q.Navigation, q.MaxAttempts)
|
||||
}
|
||||
if q.Grader != graders.Manual {
|
||||
t.Errorf("%s left grader %q, want %q", name, q.Grader, graders.Manual)
|
||||
}
|
||||
}
|
||||
two := 2
|
||||
q := &Quiz{Title: "Q", Mode: ModeTest, Navigation: NavigationOneWay, MaxAttempts: &two}
|
||||
q := &Quiz{Title: "Q", Mode: ModeTest, Navigation: NavigationOneWay, MaxAttempts: &two, Grader: "ai"}
|
||||
if err := hook(q); err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if q.Mode != ModeTest || q.Navigation != NavigationOneWay || q.MaxAttempts == nil || *q.MaxAttempts != 2 {
|
||||
t.Errorf("%s changed a test quiz: %q %q %v", name, q.Mode, q.Navigation, q.MaxAttempts)
|
||||
}
|
||||
if q.Grader != "ai" {
|
||||
t.Errorf("%s changed an explicit grader to %q", name, q.Grader)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenQuestionModel(t *testing.T) {
|
||||
if !slices.Contains(QuestionTypes, TypeOpen) || len(QuestionTypes) != 4 {
|
||||
t.Errorf("QuestionTypes = %v, want the open type appended", QuestionTypes)
|
||||
}
|
||||
if QuestionTypes[0] != TypeSingle || QuestionTypes[1] != TypeMultiple || QuestionTypes[2] != TypeSelfCheck {
|
||||
t.Errorf("QuestionTypes reordered the existing types: %v", QuestionTypes)
|
||||
}
|
||||
rules := Question{}.Rules()
|
||||
if !strings.Contains(rules["type"], "open") || !strings.Contains(rules["type"], "self_check") {
|
||||
t.Errorf("type rule = %q, want every type including open", rules["type"])
|
||||
}
|
||||
if rules["max_length"] != "nullable|integer|between:1,20000" {
|
||||
t.Errorf("max_length rule = %q", rules["max_length"])
|
||||
}
|
||||
fill := Question{}.Fillable()
|
||||
if !slices.Contains(fill, "rubric") || !slices.Contains(fill, "max_length") {
|
||||
t.Errorf("Fillable = %v, want rubric and max_length", fill)
|
||||
}
|
||||
if DefaultMaxLength != 2000 || MaxMaxLength != 20000 {
|
||||
t.Errorf("max length limits = %d / %d", DefaultMaxLength, MaxMaxLength)
|
||||
}
|
||||
if len(GradingStatuses) != 4 || GradingStatuses[0] != GradingPending {
|
||||
t.Errorf("GradingStatuses = %v", GradingStatuses)
|
||||
}
|
||||
if !slices.Contains(Quiz{}.Fillable(), "grader") {
|
||||
t.Error("Quiz.Fillable lacks grader")
|
||||
}
|
||||
if len(AttemptAnswer{}.Fillable()) != 0 {
|
||||
t.Error("AttemptAnswer.Fillable must stay empty")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user