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

@@ -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.

View File

@@ -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",
}
}

View File

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

View File

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