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

@@ -93,7 +93,7 @@ func TestCheckQuestion(t *testing.T) {
want []string
}{
"no text": {classes.ImportQuestion{Type: "self_check"}, []string{"questions[0].text is required"}},
"bad type": {classes.ImportQuestion{Text: "Q", Type: "essay"}, []string{"questions[0].type must be one of single, multiple, self_check"}},
"bad type": {classes.ImportQuestion{Text: "Q", Type: "essay"}, []string{"questions[0].type must be one of single, multiple, self_check, open"}},
"negative points": {classes.ImportQuestion{Text: "Q", Type: "self_check", Points: &neg}, []string{"questions[0].points must not be negative"}},
"single two right": {classes.ImportQuestion{Text: "Q", Type: "single", Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}, {Text: "b", IsCorrect: true}}}, []string{"questions[0]: a single question needs at least two answers and exactly one correct"}},
"multiple one item": {classes.ImportQuestion{Text: "Q", Type: "multiple", Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}}}, []string{"questions[0]: a multiple question needs at least two answers and one or more correct"}},

33
classes/open.go Normal file
View File

@@ -0,0 +1,33 @@
package classes
import (
"strings"
"unicode/utf8"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
)
// MaxLength is the longest answer an open question accepts, in characters:
// its MaxLength when set and positive, else models.DefaultMaxLength.
func MaxLength(q models.Question) int {
if q.MaxLength != nil && *q.MaxLength > 0 {
return *q.MaxLength
}
return models.DefaultMaxLength
}
// CheckOpenText cleans a student's answer to an open question (D-04): it
// removes every NUL character (Postgres text refuses them) and the
// surrounding whitespace. A blank text is a FieldError text_required and a
// text longer than MaxLength(q) characters (runes, not bytes) is a
// FieldError text_too_long.
func CheckOpenText(text string, q models.Question) (string, error) {
text = strings.TrimSpace(strings.ReplaceAll(text, "\x00", ""))
if text == "" {
return "", &FieldError{Field: "text", Key: "text_required"}
}
if utf8.RuneCountInString(text) > MaxLength(q) {
return "", &FieldError{Field: "text", Key: "text_too_long"}
}
return text, nil
}

59
classes/open_test.go Normal file
View File

@@ -0,0 +1,59 @@
package classes_test
import (
"errors"
"strings"
"testing"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
)
func TestCheckOpenText(t *testing.T) {
q := models.Question{Type: models.TypeOpen}
long := 3000
wide := models.Question{Type: models.TypeOpen, MaxLength: &long}
fieldKey := func(err error) string {
var fe *classes.FieldError
if errors.As(err, &fe) && fe.Field == "text" {
return fe.Key
}
return ""
}
if _, err := classes.CheckOpenText(" \n\t ", q); fieldKey(err) != "text_required" {
t.Errorf("blank text: err = %v, want text_required", err)
}
if _, err := classes.CheckOpenText("\x00 \x00", q); fieldKey(err) != "text_required" {
t.Errorf("NUL-only text: err = %v, want text_required", err)
}
over := strings.Repeat("a", 2001)
if _, err := classes.CheckOpenText(over, q); fieldKey(err) != "text_too_long" {
t.Errorf("2001 runes with default max: err = %v, want text_too_long", err)
}
if got, err := classes.CheckOpenText(over, wide); err != nil || got != over {
t.Errorf("2001 runes with max 3000: %q, %v", got[:10], err)
}
if got, err := classes.CheckOpenText("a\x00b", q); err != nil || got != "ab" {
t.Errorf("NUL stripped: %q, %v; want ab", got, err)
}
if got, err := classes.CheckOpenText(" moja odpowiedź \n", q); err != nil || got != "moja odpowiedź" {
t.Errorf("trimmed: %q, %v", got, err)
}
polish := strings.Repeat("ż", 2000)
if got, err := classes.CheckOpenText(polish, q); err != nil || got != polish {
t.Errorf("2000 runes of ż (4000 bytes) refused: %v", err)
}
five := 5
if _, err := classes.CheckOpenText("abcdef", models.Question{MaxLength: &five}); fieldKey(err) != "text_too_long" {
t.Errorf("6 runes with max 5: err = %v, want text_too_long", err)
}
if classes.MaxLength(q) != models.DefaultMaxLength || classes.MaxLength(wide) != 3000 {
t.Errorf("MaxLength = %d / %d", classes.MaxLength(q), classes.MaxLength(wide))
}
zero := 0
if classes.MaxLength(models.Question{MaxLength: &zero}) != models.DefaultMaxLength {
t.Error("MaxLength of a non-positive value should fall back to the default")
}
}

View File

@@ -152,6 +152,27 @@ func Totals(points []float64, maxPoints int) (score, percent float64) {
return score, round(score / float64(maxPoints) * 100)
}
// ClampOpenPoints keeps a grader's score of an open question in
// 0..points (a negative points value counts as 0), rounded to the nearest
// 0.5 (D-02). NaN and ±Inf are not a score: it returns 0, false.
func ClampOpenPoints(p float64, points int) (float64, bool) {
if math.IsNaN(p) || math.IsInf(p, 0) {
return 0, false
}
full := float64(max(points, 0))
p = math.Round(math.Min(math.Max(p, 0), full)*2) / 2
if p == 0 {
return 0, true // no negative zero in JSON
}
return p, true
}
// OpenCorrect reports whether an open answer scored p earns full marks: a
// question with points earns is_correct only with all of them (D-02).
func OpenCorrect(p float64, points int) bool {
return points > 0 && p == float64(points)
}
// round keeps two decimals.
func round(f float64) float64 {
r := math.Round(f*100) / 100

View File

@@ -1,6 +1,7 @@
package classes_test
import (
"math"
"os"
"path/filepath"
"testing"
@@ -130,3 +131,37 @@ func TestScoringConfigFrom(t *testing.T) {
}
}
}
func TestClampOpenPoints(t *testing.T) {
cases := []struct {
p float64
points int
want float64
ok bool
}{
{1.26, 2, 1.5, true},
{-3, 2, 0, true},
{7, 2, 2, true},
{0.74, 3, 0.5, true},
{0.25, 3, 0.5, true},
{0.2, 3, 0, true},
{2, 2, 2, true},
{1, 0, 0, true},
{5, -1, 0, true},
{math.NaN(), 2, 0, false},
{math.Inf(1), 2, 0, false},
{math.Inf(-1), 2, 0, false},
}
for _, tc := range cases {
got, ok := classes.ClampOpenPoints(tc.p, tc.points)
if got != tc.want || ok != tc.ok {
t.Errorf("ClampOpenPoints(%v, %d) = %v, %v; want %v, %v", tc.p, tc.points, got, ok, tc.want, tc.ok)
}
if math.Signbit(got) {
t.Errorf("ClampOpenPoints(%v, %d) is negative zero", tc.p, tc.points)
}
}
if !classes.OpenCorrect(2, 2) || classes.OpenCorrect(1.5, 2) || classes.OpenCorrect(0, 0) {
t.Error("OpenCorrect: want true only for full points of a question worth points")
}
}

View File

@@ -227,6 +227,10 @@ type Started struct {
AttemptsUsed int
// AnsweredCount is the number of questions a resumed attempt answered.
AnsweredCount int
// OpenAnswers and Unreviewed count the open answers of a resumed
// attempt, as in AttemptSummary.
OpenAnswers int
Unreviewed int
}
// StartAttempt starts an attempt at a published quiz for the user, like
@@ -322,11 +326,15 @@ func (s *Service) startTest(ctx context.Context, userID uint, quiz *models.Quiz)
return err
}
if resume != nil {
var answered int64
if err := tx.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", resume.ID).Count(&answered).Error; err != nil {
counts, err := answerCounts(ctx, tx, []uint{resume.ID})
if err != nil {
return err
}
st = &Started{Attempt: resume, Quiz: quiz, Resumed: true, AttemptsUsed: int(used), AnsweredCount: int(answered)}
c := counts[resume.ID]
st = &Started{
Attempt: resume, Quiz: quiz, Resumed: true, AttemptsUsed: int(used),
AnsweredCount: c.N, OpenAnswers: c.OpenN, Unreviewed: c.UnreviewedN,
}
return nil
}
if left := AttemptsLeft(quiz, int(used)); left != nil && *left == 0 {
@@ -403,6 +411,8 @@ type AnswerInput struct {
QuestionID uint
AnswerIDs []uint
Known *bool
// Text is the answer to an open question.
Text *string
}
// Feedback is what the user learns after answering one question.
@@ -414,6 +424,9 @@ type Feedback struct {
// and the serializer must drop Result, CorrectAnswerIDs and every
// solution field of Question.
Withheld bool
// Answer is the stored row of an open question, with its text and
// grading state; it is nil for every other type.
Answer *models.AttemptAnswer
}
// Answer records or overwrites the user's answer to one question of an
@@ -423,8 +436,11 @@ type Feedback struct {
// question belongs to the attempt (FieldError on question_id), a one_way
// test has no answer to the question yet (ErrAnswerLocked; the attempt row
// is locked, so the check is race-free), and the answer fits the question
// type (FieldError on answer_ids or known). The feedback of an open test
// attempt is Withheld.
// type (FieldError on answer_ids, known or text). The feedback of an open
// test attempt is Withheld.
//
// An open question stores its text as pending (see answerOpen) and is never
// scored here: a grader or the owner scores it later.
func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerInput) (*Feedback, error) {
var fb *Feedback
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
@@ -464,6 +480,20 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
row := models.AttemptAnswer{AttemptID: attempt.ID, QuestionID: question.ID}
switch question.Type {
case models.TypeOpen:
if in.Text == nil {
return &FieldError{Field: "text", Key: "text_required"}
}
text, err := CheckOpenText(*in.Text, question)
if err != nil {
return err
}
stored, err := s.answerOpen(ctx, tx, attempt, question, text)
if err != nil {
return err
}
fb = &Feedback{Question: question, Answer: stored, Withheld: Withheld(attempt)}
return nil
case models.TypeSelfCheck:
if in.Known == nil {
return &FieldError{Field: "known", Key: "known_required"}
@@ -512,6 +542,78 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
return fb, nil
}
// answerOpen stores a cleaned open answer in a transaction that holds the
// attempt row lock, so reading the existing row first is race-free. The
// same text again changes nothing. Another text is a new answer (Pitfall 1):
// the row goes back to pending with the next grading_seq, and every grade,
// error, review and point of the old text is cleared, so a learning
// re-answer replaces an earlier grade or review. A first answer creates the
// row as pending with seq 1. A new or changed text is handed to
// enqueueGrading.
func (s *Service) answerOpen(ctx context.Context, tx *gorm.DB, attempt *models.Attempt, question models.Question, text string) (*models.AttemptAnswer, error) {
tx = tx.WithContext(ctx)
now := s.now().UTC()
var existing models.AttemptAnswer
err := tx.Where("attempt_id = ? AND question_id = ?", attempt.ID, question.ID).Take(&existing).Error
switch {
case err == nil:
if existing.TextAnswer != nil && *existing.TextAnswer == text && existing.GradingStatus != nil {
return &existing, nil
}
seq := existing.GradingSeq + 1
if err := tx.Model(&models.AttemptAnswer{}).Where("id = ?", existing.ID).Updates(map[string]any{
"text_answer": text,
"answer_ids": nil,
"self_check_known": nil,
"grading_status": models.GradingPending,
"grading_seq": seq,
"grading_attempts": 0,
"grader": nil,
"grader_points": nil,
"grader_feedback": nil,
"grading_error": nil,
"graded_at": nil,
"review_comment": nil,
"reviewed_at": nil,
"reviewed_by_user_id": nil,
"reviewed_by_admin_id": nil,
"points": 0,
"is_correct": false,
"updated_at": now,
}).Error; err != nil {
return nil, err
}
if err := tx.Where("id = ?", existing.ID).Take(&existing).Error; err != nil {
return nil, err
}
if err := s.enqueueGrading(ctx, tx, existing.ID, seq, attempt); err != nil {
return nil, err
}
return &existing, nil
case errors.Is(err, gorm.ErrRecordNotFound):
default:
return nil, err
}
pending := models.GradingPending
row := &models.AttemptAnswer{
AttemptID: attempt.ID, QuestionID: question.ID,
TextAnswer: &text, GradingStatus: &pending, GradingSeq: 1,
CreatedAt: now, UpdatedAt: now,
}
if err := tx.Create(row).Error; err != nil {
return nil, err
}
if err := s.enqueueGrading(ctx, tx, row.ID, row.GradingSeq, attempt); err != nil {
return nil, err
}
return row, nil
}
// enqueueGrading hands a new or changed open answer to the quiz's grader.
func (s *Service) enqueueGrading(ctx context.Context, tx *gorm.DB, answerID uint, seq int, attempt *models.Attempt) error {
return nil
}
// CorrectAnswerIDs lists the IDs of a question's correct answers in sort
// order; it is empty for a self_check question.
func CorrectAnswerIDs(q models.Question) []uint {
@@ -574,6 +676,50 @@ func (s *Service) close(ctx context.Context, tx *gorm.DB, attempt *models.Attemp
type AttemptSummary struct {
Attempt models.Attempt
AnsweredCount int
// OpenAnswers counts the attempt's answers to open questions (rows
// with a grading status).
OpenAnswers int
// Unreviewed counts the open answers the owner has not reviewed yet:
// pending, graded or failed.
Unreviewed int
}
// answerCountRow is the per-attempt answer counts answerCounts reads.
type answerCountRow struct {
AttemptID uint
N int
OpenN int
UnreviewedN int
}
// answerCounts counts, per attempt, the answers, the open answers and the
// open answers not reviewed yet.
func answerCounts(ctx context.Context, db *gorm.DB, attemptIDs []uint) (map[uint]answerCountRow, error) {
var rows []answerCountRow
if err := db.WithContext(ctx).Model(&models.AttemptAnswer{}).
Select("attempt_id, COUNT(*) AS n, "+
"COUNT(*) FILTER (WHERE grading_status IS NOT NULL) AS open_n, "+
"COUNT(*) FILTER (WHERE grading_status IN ('pending', 'graded', 'failed')) AS unreviewed_n").
Where("attempt_id IN ?", attemptIDs).Group("attempt_id").Scan(&rows).Error; err != nil {
return nil, err
}
out := make(map[uint]answerCountRow, len(rows))
for _, r := range rows {
out[r.AttemptID] = r
}
return out, nil
}
// unreviewed reports whether an open answer still waits for the owner.
func unreviewed(a *models.AttemptAnswer) bool {
if a == nil || a.GradingStatus == nil {
return false
}
switch *a.GradingStatus {
case models.GradingPending, models.GradingGraded, models.GradingFailed:
return true
}
return false
}
// QuestionResult is one question of an attempt with the user's answer.
@@ -638,6 +784,14 @@ func (s *Service) Result(ctx context.Context, userID, attemptID uint) (*AttemptR
byQuestion[answers[i].QuestionID] = &answers[i]
}
res := &AttemptResult{AttemptSummary: AttemptSummary{Attempt: *attempt, AnsweredCount: len(answers)}}
for i := range answers {
if answers[i].GradingStatus != nil {
res.OpenAnswers++
}
if unreviewed(&answers[i]) {
res.Unreviewed++
}
}
for _, q := range questions {
res.Questions = append(res.Questions, QuestionResult{Question: q, Answer: byQuestion[q.ID]})
}
@@ -668,21 +822,13 @@ func (s *Service) History(ctx context.Context, userID uint, quizSlug string, pag
for i, a := range attempts {
ids[i] = a.ID
}
type countRow struct {
AttemptID uint
N int
}
var counts []countRow
if err := s.db(ctx).Model(&models.AttemptAnswer{}).Select("attempt_id, COUNT(*) AS n").
Where("attempt_id IN ?", ids).Group("attempt_id").Scan(&counts).Error; err != nil {
countBy, err := answerCounts(ctx, s.DB, ids)
if err != nil {
return nil, 0, err
}
countBy := map[uint]int{}
for _, c := range counts {
countBy[c.AttemptID] = c.N
}
for _, a := range attempts {
out = append(out, AttemptSummary{Attempt: a, AnsweredCount: countBy[a.ID]})
c := countBy[a.ID]
out = append(out, AttemptSummary{Attempt: a, AnsweredCount: c.N, OpenAnswers: c.OpenN, Unreviewed: c.UnreviewedN})
}
return out, total, nil
}