diff --git a/classes/grading.go b/classes/grading.go index f9a89e7..9a9dc99 100644 --- a/classes/grading.go +++ b/classes/grading.go @@ -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) { diff --git a/classes/service.go b/classes/service.go index 670b192..8ef782b 100644 --- a/classes/service.go +++ b/classes/service.go @@ -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 } diff --git a/models/question_hooks.go b/models/question_hooks.go new file mode 100644 index 0000000..a3c3b9a --- /dev/null +++ b/models/question_hooks.go @@ -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 +}