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:
@@ -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) {
|
||||
|
||||
@@ -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
45
models/question_hooks.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user