fix: end grading when a question stops being open

WR-06: a Question save hook clears grading_status on the answers of a
question that changed type away from open, in the save's transaction,
so they leave the review queue and needs_review. GradeAnswer ignores a
question that is no longer open, and a choice or self-check re-answer
clears the text, grade and review columns of its row.
This commit is contained in:
Jakub Zych
2026-11-09 22:52:15 +01:00
parent 09cf61e9e0
commit 9ac53579e4
3 changed files with 62 additions and 5 deletions

View File

@@ -187,9 +187,9 @@ func fireAfterCommit[T any](ctx context.Context, s *Service, tx *gorm.DB, name s
// open and no row lock held, so a slow grader blocks no student.
//
// A missing answer, one that is no longer pending (graded, failed or
// reviewed by the owner) and one whose grading_seq differs from seq (the
// student wrote a newer text) are left alone, as is an answer whose quiz
// has no registered grader. Each run counts an attempt for the current
// reviewed by the owner), one whose grading_seq differs from seq (the
// student wrote a newer text) and one whose question is no longer open are
// left alone, as is an answer whose quiz has no registered grader. Each run counts an attempt for the current
// text first. A grader error, or a score ClampOpenPoints refuses, is
// returned for River to retry while fewer than GradeMaxAttempts attempts
// were made; the last one marks the answer failed with the error and
@@ -215,6 +215,10 @@ func (s *Service) GradeAnswer(ctx context.Context, answerID uint, seq int) error
}
return err
}
if question.Type != models.TypeOpen {
// The question stopped being open after the job was queued.
return nil
}
var attempt models.Attempt
if err := db.Preload("Quiz").Where("id = ?", row.AttemptID).Take(&attempt).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@@ -538,9 +538,17 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
row.IsCorrect, row.Points = res.IsCorrect, res.Points
now := s.now().UTC()
row.CreatedAt, row.UpdatedAt = now, now
// A row left by the question's open past loses its text, grade
// and review: a choice or self-check answer has none (its
// grading_status NULL keeps a queued grading job from acting).
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "attempt_id"}, {Name: "question_id"}},
DoUpdates: clause.AssignmentColumns([]string{"answer_ids", "self_check_known", "is_correct", "points", "updated_at"}),
Columns: []clause.Column{{Name: "attempt_id"}, {Name: "question_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"answer_ids", "self_check_known", "is_correct", "points", "updated_at",
"text_answer", "grading_status", "grading_attempts", "grader", "grader_points",
"grader_feedback", "grading_error", "graded_at", "review_comment", "reviewed_at",
"reviewed_by_user_id", "reviewed_by_admin_id",
}),
}).Create(&row).Error; err != nil {
return err
}

45
models/question_hooks.go Normal file
View File

@@ -0,0 +1,45 @@
package models
import (
"time"
"gorm.io/gorm"
)
// wasOpenKey is the statement setting by which BeforeSave tells AfterSave
// that the saved question was open before.
const wasOpenKey = "golem15.quizzes:question_was_open"
// BeforeSave notes whether a stored question that is saved as another
// type was open, for AfterSave. Every write path (the admin form and
// relation manager, the manage API and MCP, the importer) saves through
// GORM, so the hooks cover them all.
func (q *Question) BeforeSave(tx *gorm.DB) error {
if q.ID == 0 || q.Type == TypeOpen {
return nil
}
var stored string
err := tx.Session(&gorm.Session{NewDB: true}).Model(&Question{}).
Where("id = ?", q.ID).Limit(1).Pluck("type", &stored).Error
if err != nil {
return err
}
if stored == TypeOpen {
tx.InstanceSet(wasOpenKey, true)
}
return nil
}
// AfterSave ends the grading of the answers to a question that stopped
// being open, in the same transaction as the save: their grading_status
// becomes NULL, so they leave the review queue and needs_review, and a
// grading job still queued for them does nothing. Their text, grades and
// points stay for the record (stored scores are never touched).
func (q *Question) AfterSave(tx *gorm.DB) error {
if was, ok := tx.InstanceGet(wasOpenKey); !ok || was != true {
return nil
}
return tx.Session(&gorm.Session{NewDB: true}).Model(&AttemptAnswer{}).
Where("question_id = ? AND grading_status IS NOT NULL", q.ID).
Updates(map[string]any{"grading_status": nil, "updated_at": time.Now().UTC()}).Error
}