feat: grade open answers through a grader registry in the background
- graders registry collected at boot from every HasGraders plugin, manual first; a duplicate or reserved code fails boot - grading job on queue quizzes_grading with three attempts; a stale or reviewed answer is never touched; the last failure marks it failed - learning answers enqueue at once, tests at close; a grade recalculates a finished attempt and fires AnswerGradedEvent after commit
This commit is contained in:
@@ -7,7 +7,9 @@ import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/festival"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -48,6 +50,15 @@ type Service struct {
|
||||
// Grace is how long after a test's deadline an answer is still
|
||||
// accepted; it never applies to learning quizzes. See TestGraceFrom.
|
||||
Grace time.Duration
|
||||
// Graders is the grader registry; nil knows only manual, so every open
|
||||
// answer waits for the owner.
|
||||
Graders *graders.Registry
|
||||
// Jobs enqueues grading jobs; nil enqueues nothing (tests and apps
|
||||
// without a queue keep working, the answers stay pending).
|
||||
Jobs Enqueuer
|
||||
// Events is the bus the grading and review events fire on; nil fires
|
||||
// nothing.
|
||||
Events *festival.Bus
|
||||
}
|
||||
|
||||
func (s *Service) now() time.Time {
|
||||
@@ -548,8 +559,9 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
|
||||
// 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.
|
||||
// row as pending with seq 1. In a learning quiz a new or changed text is
|
||||
// handed to enqueueGrading at once; a test enqueues its open answers when
|
||||
// it closes (UD-2).
|
||||
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()
|
||||
@@ -586,8 +598,10 @@ func (s *Service) answerOpen(ctx context.Context, tx *gorm.DB, attempt *models.A
|
||||
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
|
||||
if !IsTest(attempt.Quiz) {
|
||||
if err := s.enqueueGrading(ctx, tx, existing.ID, seq, attempt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &existing, nil
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
@@ -603,17 +617,14 @@ func (s *Service) answerOpen(ctx context.Context, tx *gorm.DB, attempt *models.A
|
||||
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
|
||||
if !IsTest(attempt.Quiz) {
|
||||
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 {
|
||||
@@ -649,7 +660,9 @@ func (s *Service) Finish(ctx context.Context, userID, attemptID uint) (*AttemptR
|
||||
|
||||
// close completes an attempt at the given time: it sums the recorded
|
||||
// answers, floors the total at 0 and stores the score. Finish closes at the
|
||||
// current time, an expired test closes at its deadline.
|
||||
// current time, an expired test closes at its deadline. A test then
|
||||
// enqueues one grading job per pending open answer (UD-2); every caller
|
||||
// sets attempt.Quiz.
|
||||
func (s *Service) close(ctx context.Context, tx *gorm.DB, attempt *models.Attempt, at time.Time) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
var points []float64
|
||||
@@ -663,13 +676,16 @@ func (s *Service) close(ctx context.Context, tx *gorm.DB, attempt *models.Attemp
|
||||
score, percent := Totals(points, maxPoints)
|
||||
at = at.UTC()
|
||||
now := s.now().UTC()
|
||||
return tx.Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{
|
||||
if err := tx.Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{
|
||||
"completed_at": at,
|
||||
"score_points": score,
|
||||
"max_points": maxPoints,
|
||||
"score_percent": percent,
|
||||
"updated_at": now,
|
||||
}).Error
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.enqueuePending(ctx, tx, attempt)
|
||||
}
|
||||
|
||||
// AttemptSummary is an attempt with its quiz and answered question count.
|
||||
|
||||
Reference in New Issue
Block a user