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:
32
classes/app.go
Normal file
32
classes/app.go
Normal file
@@ -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
|
||||
}
|
||||
373
classes/grading.go
Normal file
373
classes/grading.go
Normal file
@@ -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
|
||||
}
|
||||
74
classes/grading_test.go
Normal file
74
classes/grading_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
7
classes/logger.go
Normal file
7
classes/logger.go
Normal file
@@ -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")
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
62
events/events.go
Normal file
62
events/events.go
Normal file
@@ -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
|
||||
}
|
||||
132
graders/registry.go
Normal file
132
graders/registry.go
Normal file
@@ -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
|
||||
}
|
||||
79
graders/registry_test.go
Normal file
79
graders/registry_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
65
graders_boot_test.go
Normal file
65
graders_boot_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
19
jobs/grade_answer.go
Normal file
19
jobs/grade_answer.go
Normal file
@@ -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 }
|
||||
69
plugin.go
69
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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user