diff --git a/classes/app.go b/classes/app.go new file mode 100644 index 0000000..8221c55 --- /dev/null +++ b/classes/app.go @@ -0,0 +1,32 @@ +package classes + +import ( + "git.golem15.com/golem15/sm-quizzes-plugin/graders" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/conga" + "gorm.io/gorm" +) + +// ForApp builds the service the application runs on db: the scoring and +// test grace from its config, the grader registry golem15.quizzes +// published at boot, its event bus and its job queue. Each part that is +// missing stays nil and the service works without it (no graders, no +// events, no jobs). A nil app gives a service with the default grace only. +func ForApp(app *backpack.App, db *gorm.DB) *Service { + if app == nil { + return &Service{DB: db, Grace: DefaultTestGrace} + } + s := &Service{ + DB: db, + Scoring: ScoringConfigFrom(app.Config), + Grace: TestGraceFrom(app.Config), + Events: app.Events, + } + if reg, ok := app.Lookup[*graders.Registry](); ok { + s.Graders = reg + } + if m, err := conga.From(app); err == nil && m != nil { + s.Jobs = m + } + return s +} diff --git a/classes/grading.go b/classes/grading.go new file mode 100644 index 0000000..4d0de23 --- /dev/null +++ b/classes/grading.go @@ -0,0 +1,373 @@ +package classes + +import ( + "context" + "errors" + "strings" + "time" + "unicode/utf8" + + "git.golem15.com/golem15/sm-quizzes-plugin/events" + "git.golem15.com/golem15/sm-quizzes-plugin/graders" + "git.golem15.com/golem15/sm-quizzes-plugin/jobs" + "git.golem15.com/golem15/sm-quizzes-plugin/models" + "git.golem15.com/golem15/summercms/modules/conga" + "git.golem15.com/golem15/summercms/modules/lagoon" + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// GradeMaxAttempts is how many times one open answer is handed to its +// grader before it is marked failed (D-17). The grading job's River attempt +// limit is the same number. +const GradeMaxAttempts = 3 + +// GradeTimeout is the deadline of one grading job attempt. +const GradeTimeout = 3 * time.Minute + +// Text limits of what a grading writes. +const ( + maxGraderFeedback = 2000 + maxGradingError = 500 +) + +// errInvalidScore is a grader result ClampOpenPoints refuses; it counts as +// a failed attempt. +var errInvalidScore = errors.New("quizzes: grader returned an invalid score") + +// Enqueuer inserts a background job, joining db's transaction when there is +// one. *conga.Manager implements it; tests record the jobs instead. +type Enqueuer interface { + Enqueue(ctx context.Context, db *gorm.DB, args pact.JobArgs, o conga.EnqueueOpts) error +} + +// GraderFor returns the grader of a quiz: false for a nil registry or quiz, +// for manual and for a code no activated plugin registered (D-05). An +// answer without a grader waits for the owner. +func GraderFor(reg *graders.Registry, quiz *models.Quiz) (graders.Grader, bool) { + if quiz == nil { + return nil, false + } + return reg.Get(quiz.Grader) +} + +// GradingStep is what a grading job does with an answer. +type GradingStep int + +// Grading steps. +const ( + // GradingSkip leaves the answer alone: it is no longer pending or it + // has a newer text than the job. + GradingSkip GradingStep = iota + // GradingRetry returns the grader's error so River retries the job. + GradingRetry + // GradingFail marks the answer failed after the last attempt. + GradingFail + // GradingStore stores the grader's score. + GradingStore +) + +// NextGrading decides a grading job's step from the answer's status and +// grading_seq, the job's seq, the attempts used for this text (the current +// one included) and the grader's error. It is pure. +func NextGrading(status *string, rowSeq, jobSeq, attempts int, gradeErr error) GradingStep { + if status == nil || *status != models.GradingPending || rowSeq != jobSeq { + return GradingSkip + } + if gradeErr == nil { + return GradingStore + } + if attempts >= GradeMaxAttempts { + return GradingFail + } + return GradingRetry +} + +// enqueueGrading hands a pending open answer to the quiz's grader as a +// background job inside tx, so the job exists only if the answer commits. +// Without a job queue or a registered grader (manual, unknown) it does +// nothing: the answer waits for the owner. +func (s *Service) enqueueGrading(ctx context.Context, tx *gorm.DB, answerID uint, seq int, attempt *models.Attempt) error { + if s.Jobs == nil || attempt == nil { + return nil + } + if _, ok := GraderFor(s.Graders, attempt.Quiz); !ok { + return nil + } + return s.Jobs.Enqueue(ctx, tx, jobs.GradeAnswerArgs{AnswerID: answerID, Seq: seq}, conga.EnqueueOpts{ + Queue: jobs.Queue, + MaxAttempts: GradeMaxAttempts, + }) +} + +// enqueuePending enqueues every pending open answer of a test attempt that +// is being closed, one job per answer (UD-2): answers given during a test +// are graded only once it ends. +func (s *Service) enqueuePending(ctx context.Context, tx *gorm.DB, attempt *models.Attempt) error { + if s.Jobs == nil || !IsTest(attempt.Quiz) { + return nil + } + if _, ok := GraderFor(s.Graders, attempt.Quiz); !ok { + return nil + } + var rows []models.AttemptAnswer + if err := tx.WithContext(ctx).Where("attempt_id = ? AND grading_status = ?", attempt.ID, models.GradingPending). + Order("id ASC").Find(&rows).Error; err != nil { + return err + } + for _, r := range rows { + if err := s.enqueueGrading(ctx, tx, r.ID, r.GradingSeq, attempt); err != nil { + return err + } + } + return nil +} + +// lockAttempt locks the attempt row FOR UPDATE, so a grade, a review and +// Finish of one attempt never interleave. A missing attempt is +// gorm.ErrRecordNotFound. +func lockAttempt(ctx context.Context, tx *gorm.DB, attemptID uint) (*models.Attempt, error) { + var a models.Attempt + err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}). + Where("id = ?", attemptID).Take(&a).Error + if err != nil { + return nil, err + } + return &a, nil +} + +// recalcAttempt recomputes the score of a completed attempt from its +// answers after a grade or a review (Pitfall 2), under the attempt row +// lock. The percentage is taken against the attempt's stored max_points, +// so a later question edit does not move an old attempt's maximum. An open +// or missing attempt is left alone: close scores it. +func (s *Service) recalcAttempt(ctx context.Context, tx *gorm.DB, attemptID uint) error { + tx = tx.WithContext(ctx) + a, err := lockAttempt(ctx, tx, attemptID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + if err != nil { + return err + } + if a.CompletedAt == nil { + return nil + } + var points []float64 + if err := tx.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", attemptID).Pluck("points", &points).Error; err != nil { + return err + } + score, percent := Totals(points, a.MaxPoints) + return tx.Model(&models.Attempt{}).Where("id = ?", attemptID).Updates(map[string]any{ + "score_points": score, + "score_percent": percent, + "updated_at": s.now().UTC(), + }).Error +} + +// fireAfterCommit fires event on s.Events once tx commits (never when it +// rolls back). A listener error is logged with the event name and never +// returned: the write it announces stays (D-14). +func fireAfterCommit[T any](ctx context.Context, s *Service, tx *gorm.DB, name string, event T) { + if s == nil || s.Events == nil { + return + } + bus := s.Events + lagoon.AfterCommit(ctx, tx, func(ctx context.Context, _ *gorm.DB) { + if err := bus.Fire(ctx, event); err != nil { + pluginLog.Warn("quizzes: event listener failed", "event", name, "error", err) + } + }) +} + +// GradeAnswer is the body of the grading job: it asks the quiz's grader to +// score one pending open answer (D-06). The grader runs with no transaction +// 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 +// 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 +// returns nil (D-17). A score is stored as graded with points clamped to +// 0..question points in steps of 0.5, the attempt's score is recalculated +// when it is completed, and AnswerGradedEvent fires after the commit. +func (s *Service) GradeAnswer(ctx context.Context, answerID uint, seq int) error { + db := s.db(ctx) + var row models.AttemptAnswer + if err := db.Where("id = ?", answerID).Take(&row).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if NextGrading(row.GradingStatus, row.GradingSeq, seq, 0, nil) == GradingSkip { + return nil + } + var question models.Question + if err := db.Where("id = ?", row.QuestionID).Take(&question).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + var attempt models.Attempt + if err := db.Preload("Quiz").Where("id = ?", row.AttemptID).Take(&attempt).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + grader, ok := GraderFor(s.Graders, attempt.Quiz) + if !ok { + return nil + } + + var attempts int + if err := db.Raw(`UPDATE golem15_quizzes_attempt_answers SET grading_attempts = grading_attempts + 1 +WHERE id = ? AND grading_status = ? AND grading_seq = ? RETURNING grading_attempts`, + answerID, models.GradingPending, seq).Scan(&attempts).Error; err != nil { + return err + } + if attempts == 0 { + return nil + } + + res, gradeErr := grader.Grade(ctx, graders.Input{ + QuizID: attempt.QuizID, + QuestionID: question.ID, + AttemptID: attempt.ID, + AnswerID: row.ID, + UserID: attempt.UserID, + Question: question.Text, + ModelAnswer: deref(question.AnswerText), + Rubric: deref(question.Rubric), + Answer: deref(row.TextAnswer), + Points: question.Points, + }) + var points float64 + if gradeErr == nil { + p, ok := ClampOpenPoints(res.Points, question.Points) + if !ok { + gradeErr = errInvalidScore + } + points = p + } + pending := models.GradingPending + switch NextGrading(&pending, seq, seq, attempts, gradeErr) { + case GradingRetry: + return gradeErr + case GradingFail: + return s.failGrading(ctx, row.ID, attempt.ID, seq, grader.Code(), gradeErr) + case GradingStore: + return s.storeGrade(ctx, row.ID, seq, &attempt, question, grader.Code(), points, res.Feedback) + } + return nil +} + +// stillPending re-reads an answer inside the grading write's transaction +// (the attempt row is locked) and reports whether the job's seq still +// applies. +func stillPending(ctx context.Context, tx *gorm.DB, answerID uint, seq int) (bool, error) { + var cur models.AttemptAnswer + err := tx.WithContext(ctx).Where("id = ?", answerID).Take(&cur).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return NextGrading(cur.GradingStatus, cur.GradingSeq, seq, 0, nil) != GradingSkip, nil +} + +// failGrading marks an answer failed after its last grading attempt. +func (s *Service) failGrading(ctx context.Context, answerID, attemptID uint, seq int, code string, gradeErr error) error { + return lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if _, err := lockAttempt(ctx, tx, attemptID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if ok, err := stillPending(ctx, tx, answerID, seq); err != nil || !ok { + return err + } + return tx.Model(&models.AttemptAnswer{}).Where("id = ?", answerID).Updates(map[string]any{ + "grading_status": models.GradingFailed, + "grader": code, + "grading_error": cutRunes(gradeErr.Error(), maxGradingError), + "updated_at": s.now().UTC(), + }).Error + }) +} + +// storeGrade stores a grader's clamped score as graded, recalculates a +// completed attempt and fires AnswerGradedEvent after the commit. +func (s *Service) storeGrade(ctx context.Context, answerID uint, seq int, attempt *models.Attempt, question models.Question, code string, points float64, feedback string) error { + return lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if _, err := lockAttempt(ctx, tx, attempt.ID); err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if ok, err := stillPending(ctx, tx, answerID, seq); err != nil || !ok { + return err + } + feedback = cutRunes(strings.TrimSpace(feedback), maxGraderFeedback) + var fb any + if feedback != "" { + fb = feedback + } + now := s.now().UTC() + if err := tx.Model(&models.AttemptAnswer{}).Where("id = ?", answerID).Updates(map[string]any{ + "grading_status": models.GradingGraded, + "grader": code, + "grader_points": points, + "points": points, + "is_correct": OpenCorrect(points, question.Points), + "grader_feedback": fb, + "grading_error": nil, + "graded_at": now, + "updated_at": now, + }).Error; err != nil { + return err + } + if err := s.recalcAttempt(ctx, tx, attempt.ID); err != nil { + return err + } + fireAfterCommit(ctx, s, tx, events.AnswerGradedEventName, &events.AnswerGradedEvent{ + QuizID: attempt.QuizID, + QuestionID: question.ID, + AttemptID: attempt.ID, + AnswerID: answerID, + UserID: attempt.UserID, + Grader: code, + Points: points, + MaxPoints: question.Points, + Feedback: feedback, + }) + return nil + }) +} + +// cutRunes keeps at most n runes of s. +func cutRunes(s string, n int) string { + if utf8.RuneCountInString(s) <= n { + return s + } + r := []rune(s) + return string(r[:n]) +} + +func deref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/classes/grading_test.go b/classes/grading_test.go new file mode 100644 index 0000000..23d426a --- /dev/null +++ b/classes/grading_test.go @@ -0,0 +1,74 @@ +package classes_test + +import ( + "context" + "errors" + "testing" + + "git.golem15.com/golem15/sm-quizzes-plugin/classes" + "git.golem15.com/golem15/sm-quizzes-plugin/graders" + "git.golem15.com/golem15/sm-quizzes-plugin/models" +) + +type stubGrader struct{ code string } + +func (g stubGrader) Code() string { return g.code } +func (g stubGrader) Label() string { return g.code } +func (g stubGrader) Grade(context.Context, graders.Input) (graders.Result, error) { + return graders.Result{}, nil +} + +func TestGraderFor(t *testing.T) { + reg := graders.NewRegistry() + if err := reg.Add("acme.x", stubGrader{"x"}); err != nil { + t.Fatal(err) + } + if g, ok := classes.GraderFor(reg, &models.Quiz{Grader: "x"}); !ok || g.Code() != "x" { + t.Errorf("GraderFor(x) = %v, %v", g, ok) + } + for _, code := range []string{graders.Manual, "unknown", ""} { + if _, ok := classes.GraderFor(reg, &models.Quiz{Grader: code}); ok { + t.Errorf("GraderFor(%q) = true, want false", code) + } + } + if _, ok := classes.GraderFor(nil, &models.Quiz{Grader: "x"}); ok { + t.Error("GraderFor(nil registry) = true") + } + if _, ok := classes.GraderFor(reg, nil); ok { + t.Error("GraderFor(nil quiz) = true") + } +} + +func TestNextGrading(t *testing.T) { + str := func(s string) *string { return &s } + boom := errors.New("boom") + cases := []struct { + name string + status *string + rowSeq int + jobSeq int + attempts int + err error + want classes.GradingStep + }{ + {"nil status", nil, 1, 1, 1, nil, classes.GradingSkip}, + {"graded", str(models.GradingGraded), 1, 1, 1, nil, classes.GradingSkip}, + {"failed", str(models.GradingFailed), 1, 1, 1, nil, classes.GradingSkip}, + {"reviewed", str(models.GradingReviewed), 1, 1, 1, nil, classes.GradingSkip}, + {"stale seq", str(models.GradingPending), 2, 1, 1, nil, classes.GradingSkip}, + {"stale seq with error", str(models.GradingPending), 2, 1, 3, boom, classes.GradingSkip}, + {"store", str(models.GradingPending), 1, 1, 1, nil, classes.GradingStore}, + {"retry 1", str(models.GradingPending), 1, 1, 1, boom, classes.GradingRetry}, + {"retry 2", str(models.GradingPending), 1, 1, 2, boom, classes.GradingRetry}, + {"fail at max", str(models.GradingPending), 1, 1, classes.GradeMaxAttempts, boom, classes.GradingFail}, + {"fail past max", str(models.GradingPending), 1, 1, classes.GradeMaxAttempts + 1, boom, classes.GradingFail}, + } + for _, tc := range cases { + if got := classes.NextGrading(tc.status, tc.rowSeq, tc.jobSeq, tc.attempts, tc.err); got != tc.want { + t.Errorf("%s: NextGrading = %v, want %v", tc.name, got, tc.want) + } + } + if classes.GradeMaxAttempts != 3 { + t.Errorf("GradeMaxAttempts = %d, want 3", classes.GradeMaxAttempts) + } +} diff --git a/classes/logger.go b/classes/logger.go new file mode 100644 index 0000000..7157bb0 --- /dev/null +++ b/classes/logger.go @@ -0,0 +1,7 @@ +package classes + +import "git.golem15.com/golem15/summercms/modules/diary" + +// pluginLog is the plugin's logger: its records carry component=quizzes, so +// config/log.yaml can raise or lower them (log.components.quizzes). +var pluginLog = diary.For("quizzes") diff --git a/classes/service.go b/classes/service.go index 32dc54a..a21d7e7 100644 --- a/classes/service.go +++ b/classes/service.go @@ -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. diff --git a/controllers/api/handlers.go b/controllers/api/handlers.go index ce73521..3354de7 100644 --- a/controllers/api/handlers.go +++ b/controllers/api/handlers.go @@ -259,13 +259,9 @@ func (h *Handlers) service(w http.ResponseWriter) (*classes.Service, bool) { if h.Service != nil { return h.Service(gdb), true } - var scoring classes.ScoringConfig - grace := classes.DefaultTestGrace - if h.App != nil { - scoring = classes.ScoringConfigFrom(h.App.Config) - grace = classes.TestGraceFrom(h.App.Config) - } - return &classes.Service{DB: gdb, Scoring: scoring, Grace: grace}, true + // The scoring and grace come from the config as before; ForApp adds + // the grader registry, the job queue and the event bus. + return classes.ForApp(h.App, gdb), true } // input decodes a JSON body (an empty body is {}) and validates it. It diff --git a/events/events.go b/events/events.go new file mode 100644 index 0000000..8a7647f --- /dev/null +++ b/events/events.go @@ -0,0 +1,62 @@ +// Package events holds the plugin events of golem15.quizzes that other +// plugins listen to with app.Events.Listen. Every event fires after the +// write it announces has committed, so a listener never sees a score that +// was rolled back; a listener error is logged and never undoes the write. +// The package imports nothing from golem15.quizzes, so a listener can +// depend on it alone. +package events + +// Event names, for logs and documentation; the bus itself is keyed by type. +const ( + AnswerGradedEventName = "quizzes.AnswerGraded" + AnswerReviewedEventName = "quizzes.AnswerReviewed" + AttemptReviewedEventName = "quizzes.AttemptReviewed" +) + +// AnswerGradedEvent is fired as *AnswerGradedEvent after a grader's score +// of an open answer is stored (grading_status graded). The owner has not +// confirmed it yet. MaxPoints is the question's points. +type AnswerGradedEvent struct { + QuizID uint + QuestionID uint + AttemptID uint + AnswerID uint + UserID uint + Grader string + Points float64 + MaxPoints int + Feedback string +} + +// AnswerReviewedEvent is fired as *AnswerReviewedEvent after the owner +// confirms or sets the score of an open answer (grading_status reviewed). +// GraderPoints is the grader's score when there was one; Comment is the +// owner's comment to the student. The reviewer is a frontend user +// (ReviewerUserID, over MCP) or a backend administrator (ReviewerAdminID). +type AnswerReviewedEvent struct { + QuizID uint + QuestionID uint + AttemptID uint + AnswerID uint + UserID uint + Points float64 + MaxPoints int + GraderPoints *float64 + Comment *string + ReviewerUserID *uint + ReviewerAdminID *uint +} + +// AttemptReviewedEvent is fired as *AttemptReviewedEvent once every open +// answer of a completed attempt is reviewed, with the attempt's final +// score. MaxPoints is the attempt's stored maximum; Passed compares +// ScorePercent with the quiz's passing score. +type AttemptReviewedEvent struct { + QuizID uint + AttemptID uint + UserID uint + ScorePoints float64 + MaxPoints int + ScorePercent *float64 + Passed bool +} diff --git a/graders/registry.go b/graders/registry.go new file mode 100644 index 0000000..7c40405 --- /dev/null +++ b/graders/registry.go @@ -0,0 +1,132 @@ +package graders + +import ( + "errors" + "fmt" + "regexp" + "sync" +) + +// ErrDuplicateCode reports two graders published with the same code. +var ErrDuplicateCode = errors.New("quizzes: duplicate grader code") + +// ErrReservedCode reports a grader that claims the built-in Manual code. +var ErrReservedCode = errors.New("quizzes: grader code manual is built in") + +// codePattern is the shape of a grader code: it is stored in the quiz's +// grader column (VARCHAR(64)). +var codePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// Option is one choice of the admin grader dropdown. +type Option struct { + Code string + Label string +} + +// Registry is the ordered set of graders collected at boot: Manual first, +// then every activated plugin's graders in activation order. golem15.quizzes +// publishes it on the app as *graders.Registry. Every method is safe on a +// nil *Registry, which knows only Manual. +type Registry struct { + mu sync.RWMutex + order []string + byCode map[string]entry +} + +type entry struct { + grader Grader + pluginID string +} + +// NewRegistry returns a registry that knows only Manual. +func NewRegistry() *Registry { + return &Registry{byCode: map[string]entry{}} +} + +// Add registers g as published by pluginID. A nil grader, an empty or +// malformed code, Manual (ErrReservedCode) and a code already registered +// (ErrDuplicateCode, naming both plugins) are errors. +func (r *Registry) Add(pluginID string, g Grader) error { + if r == nil { + return fmt.Errorf("quizzes: grader registry is nil") + } + if g == nil { + return fmt.Errorf("quizzes: plugin %s published a nil grader", pluginID) + } + code := g.Code() + switch { + case code == "": + return fmt.Errorf("quizzes: plugin %s published a grader without a code", pluginID) + case code == Manual: + return fmt.Errorf("%w: published by %s", ErrReservedCode, pluginID) + case !codePattern.MatchString(code): + return fmt.Errorf("quizzes: plugin %s published grader code %q; want lowercase letters, digits and _ (at most 64)", pluginID, code) + } + r.mu.Lock() + defer r.mu.Unlock() + if r.byCode == nil { + r.byCode = map[string]entry{} + } + if existing, ok := r.byCode[code]; ok { + return fmt.Errorf("%w: %q is published by %s and %s", ErrDuplicateCode, code, existing.pluginID, pluginID) + } + r.byCode[code] = entry{grader: g, pluginID: pluginID} + r.order = append(r.order, code) + return nil +} + +// Get returns the grader with code. Manual and unknown codes are false: +// there is nothing to call, the owner grades. +func (r *Registry) Get(code string) (Grader, bool) { + if r == nil || code == Manual { + return nil, false + } + r.mu.RLock() + defer r.mu.RUnlock() + e, ok := r.byCode[code] + if !ok { + return nil, false + } + return e.grader, true +} + +// Codes lists Manual and then every registered code in registration order. +func (r *Registry) Codes() []string { + out := []string{Manual} + if r == nil { + return out + } + r.mu.RLock() + defer r.mu.RUnlock() + return append(out, r.order...) +} + +// Options lists the admin dropdown choices: Manual with ManualLabel, then +// every registered grader with its Label. +func (r *Registry) Options() []Option { + out := []Option{{Code: Manual, Label: ManualLabel}} + if r == nil { + return out + } + r.mu.RLock() + defer r.mu.RUnlock() + for _, code := range r.order { + out = append(out, Option{Code: code, Label: r.byCode[code].grader.Label()}) + } + return out +} + +// PluginID returns the id of the plugin that published code. Manual is +// golem15.quizzes' own. +func (r *Registry) PluginID(code string) (string, bool) { + if code == Manual { + return "golem15.quizzes", true + } + if r == nil { + return "", false + } + r.mu.RLock() + defer r.mu.RUnlock() + e, ok := r.byCode[code] + return e.pluginID, ok +} diff --git a/graders/registry_test.go b/graders/registry_test.go new file mode 100644 index 0000000..ed08ab8 --- /dev/null +++ b/graders/registry_test.go @@ -0,0 +1,79 @@ +package graders_test + +import ( + "context" + "errors" + "slices" + "strings" + "testing" + + "git.golem15.com/golem15/sm-quizzes-plugin/graders" +) + +type fake struct{ code, label string } + +func (f fake) Code() string { return f.code } +func (f fake) Label() string { return f.label } +func (f fake) Grade(context.Context, graders.Input) (graders.Result, error) { + return graders.Result{}, nil +} + +func TestGradersRegistry(t *testing.T) { + reg := graders.NewRegistry() + if got := reg.Codes(); !slices.Equal(got, []string{graders.Manual}) { + t.Fatalf("empty Codes = %v, want [manual]", got) + } + if err := reg.Add("acme.one", fake{"x", "X grader"}); err != nil { + t.Fatal(err) + } + if got := reg.Codes(); !slices.Equal(got, []string{graders.Manual, "x"}) { + t.Errorf("Codes = %v, want [manual x]", got) + } + if g, ok := reg.Get("x"); !ok || g.Code() != "x" { + t.Errorf("Get(x) = %v, %v", g, ok) + } + for _, code := range []string{graders.Manual, "zzz", ""} { + if _, ok := reg.Get(code); ok { + t.Errorf("Get(%q) = true, want false", code) + } + } + if id, ok := reg.PluginID("x"); !ok || id != "acme.one" { + t.Errorf("PluginID(x) = %q, %v", id, ok) + } + + err := reg.Add("acme.two", fake{"x", "Other"}) + if !errors.Is(err, graders.ErrDuplicateCode) || !strings.Contains(err.Error(), "acme.one") || !strings.Contains(err.Error(), "acme.two") { + t.Errorf("duplicate Add = %v, want ErrDuplicateCode naming both plugins", err) + } + if err := reg.Add("acme.two", fake{graders.Manual, "M"}); !errors.Is(err, graders.ErrReservedCode) { + t.Errorf("manual Add = %v, want ErrReservedCode", err) + } + for _, g := range []graders.Grader{nil, fake{"", "E"}, fake{"Bad Code", "B"}, fake{"9lives", "N"}, fake{strings.Repeat("a", 65), "L"}} { + if err := reg.Add("acme.two", g); err == nil { + t.Errorf("Add(%v) = nil, want an error", g) + } + } + if got := reg.Codes(); !slices.Equal(got, []string{graders.Manual, "x"}) { + t.Errorf("rejected Adds changed Codes: %v", got) + } + + opts := reg.Options() + want := []graders.Option{{Code: graders.Manual, Label: graders.ManualLabel}, {Code: "x", Label: "X grader"}} + if !slices.Equal(opts, want) { + t.Errorf("Options = %v, want %v", opts, want) + } + + var none *graders.Registry + if got := none.Codes(); !slices.Equal(got, []string{graders.Manual}) { + t.Errorf("nil Codes = %v", got) + } + if _, ok := none.Get("x"); ok { + t.Error("nil Get = true") + } + if got := none.Options(); len(got) != 1 || got[0].Code != graders.Manual { + t.Errorf("nil Options = %v", got) + } + if err := none.Add("acme.one", fake{"y", "Y"}); err == nil { + t.Error("nil Add = nil, want an error") + } +} diff --git a/graders_boot_test.go b/graders_boot_test.go new file mode 100644 index 0000000..4e245cb --- /dev/null +++ b/graders_boot_test.go @@ -0,0 +1,65 @@ +package quizzes_test + +import ( + "context" + "errors" + "slices" + "testing" + + "git.golem15.com/golem15/sm-quizzes-plugin/graders" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/party" +) + +// fixtureGrader is the grader both fixture plugins publish, under the same +// code. +type fixtureGrader struct{} + +func (fixtureGrader) Code() string { return "fixture" } +func (fixtureGrader) Label() string { return "Fixture" } +func (fixtureGrader) Grade(context.Context, graders.Input) (graders.Result, error) { + return graders.Result{}, nil +} + +// graderPlugin is a test-only plugin that provides fixtureGrader. It is +// registered here and activated only by the tests that name it. +type graderPlugin struct{ id string } + +func (p *graderPlugin) ID() string { return p.id } +func (p *graderPlugin) Requires() []string { return []string{"golem15.quizzes"} } +func (p *graderPlugin) Register(*backpack.App) error { return nil } +func (p *graderPlugin) Boot(*backpack.App) error { return nil } +func (p *graderPlugin) QuizGraders() []graders.Grader { return []graders.Grader{fixtureGrader{}} } + +func init() { + party.Register(&graderPlugin{id: "acme.gradersone"}) + party.Register(&graderPlugin{id: "acme.graderstwo"}) +} + +func TestGradersCollectedAtBoot(t *testing.T) { + app := backpack.New(testConfig(t)) + if _, err := party.Activate(app, []string{"golem15.user", "golem15.quizzes", "acme.gradersone"}); err != nil { + t.Fatalf("Activate: %v", err) + } + reg, ok := app.Lookup[*graders.Registry]() + if !ok || reg == nil { + t.Fatal("no *graders.Registry published") + } + if got := reg.Codes(); !slices.Equal(got, []string{graders.Manual, "fixture"}) { + t.Errorf("Codes = %v, want [manual fixture]", got) + } + if id, _ := reg.PluginID("fixture"); id != "acme.gradersone" { + t.Errorf("PluginID(fixture) = %q", id) + } + + plain, _ := activate(t, nil, nil) + if reg, ok := plain.Lookup[*graders.Registry](); !ok || !slices.Equal(reg.Codes(), []string{graders.Manual}) { + t.Errorf("without grading plugins the registry should hold manual only") + } + + dup := backpack.New(testConfig(t)) + _, err := party.Activate(dup, []string{"golem15.user", "golem15.quizzes", "acme.gradersone", "acme.graderstwo"}) + if !errors.Is(err, graders.ErrDuplicateCode) { + t.Errorf("Activate with two fixture plugins = %v, want ErrDuplicateCode", err) + } +} diff --git a/jobs/grade_answer.go b/jobs/grade_answer.go new file mode 100644 index 0000000..da220c8 --- /dev/null +++ b/jobs/grade_answer.go @@ -0,0 +1,19 @@ +package jobs + +// Queue is the River queue of the grading jobs, so an app can cap how many +// answers are graded at once (config/queue.yaml). +const Queue = "quizzes_grading" + +// Kind is the River kind of GradeAnswerArgs. +const Kind = "golem15.quizzes.grade_answer" + +// GradeAnswerArgs asks the quiz's grader to score one open answer. Seq is +// the answer's grading_seq when the job was enqueued: a job whose Seq no +// longer matches, or whose answer is no longer pending, does nothing. +type GradeAnswerArgs struct { + AnswerID uint `json:"answer_id"` + Seq int `json:"seq"` +} + +// Kind implements pact.JobArgs. +func (GradeAnswerArgs) Kind() string { return Kind } diff --git a/plugin.go b/plugin.go index afb15bc..9fcb5ce 100644 --- a/plugin.go +++ b/plugin.go @@ -3,16 +3,21 @@ package quizzes import ( "context" "embed" + "errors" "io/fs" "net/http" "strconv" "time" + "git.golem15.com/golem15/sm-quizzes-plugin/classes" "git.golem15.com/golem15/sm-quizzes-plugin/console" + "git.golem15.com/golem15/sm-quizzes-plugin/graders" + "git.golem15.com/golem15/sm-quizzes-plugin/jobs" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bonfire" "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/conga" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/pact" "git.golem15.com/golem15/summercms/modules/party" @@ -53,6 +58,8 @@ var adminFS embed.FS // Plugin is the golem15.quizzes plugin. type Plugin struct { app *backpack.App + // graders is the grader registry built at Boot. + graders *graders.Registry } func (p *Plugin) ID() string { return "golem15.quizzes" } @@ -68,10 +75,17 @@ func (p *Plugin) Register(app *backpack.App) error { return nil } -// Boot keeps the application and points the admin filter choices at its -// database. +// Boot keeps the application, collects the grader registry and points the +// admin filter choices at its database. +// +// Grading plugins never register themselves: golem15.quizzes pulls their +// graders through graders.HasGraders, as golem15.paymentgateway pulls +// payment operators. func (p *Plugin) Boot(app *backpack.App) error { p.app = app + if err := bootGraders(p, app); err != nil { + return err + } models.FilterDB = func() *gorm.DB { if app == nil { return nil @@ -82,6 +96,35 @@ func (p *Plugin) Boot(app *backpack.App) error { return nil } +// bootGraders builds the grader registry: manual first, then the graders of +// every other activated plugin that implements graders.HasGraders, in +// activation order. A duplicate or reserved code fails boot. The registry +// is kept on p and published on the app as *graders.Registry. +func bootGraders(p *Plugin, app *backpack.App) error { + reg := graders.NewRegistry() + if app == nil { + p.graders = reg + return nil + } + activated, _ := app.Lookup[party.Activated]() + for _, plugin := range activated { + if plugin == nil || plugin.ID() == p.ID() { + continue + } + hg, ok := plugin.(graders.HasGraders) + if !ok { + continue + } + for _, g := range hg.QuizGraders() { + if err := reg.Add(plugin.ID(), g); err != nil { + return err + } + } + } + p.graders = reg + return app.Publish(reg) +} + func (p *Plugin) ConfigFS() fs.FS { return configFS } func (p *Plugin) LangFS() fs.FS { return langFS } @@ -91,7 +134,27 @@ func (p *Plugin) MailLayouts() map[string]string { return nil } func (p *Plugin) Models() []any { return generatedModels() } func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() } -func (p *Plugin) Jobs() []pact.Job { return generatedJobs() } + +// Jobs returns the generated jobs plus the grading job: one open answer per +// job on its own queue, GradeMaxAttempts attempts, each with GradeTimeout. +func (p *Plugin) Jobs() []pact.Job { + return append(generatedJobs(), conga.Job(p.gradeAnswer, + conga.OnQueue(jobs.Queue), + conga.MaxAttempts(classes.GradeMaxAttempts), + conga.Timeout(classes.GradeTimeout))) +} + +// gradeAnswer runs one grading job on the application's database. +func (p *Plugin) gradeAnswer(ctx context.Context, args jobs.GradeAnswerArgs) error { + var gdb *gorm.DB + if p.app != nil { + gdb, _ = p.app.Lookup[*gorm.DB]() + } + if gdb == nil { + return errors.New("quizzes: database unavailable") + } + return classes.ForApp(p.app, gdb).GradeAnswer(ctx, args.AnswerID, args.Seq) +} func (p *Plugin) AdminControllers() []pact.AdminController { return generatedAdminControllers() }