- 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
133 lines
3.6 KiB
Go
133 lines
3.6 KiB
Go
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
|
|
}
|