Files
sm-quizzes-plugin/plugin.go
Jakub Zych 2ad4e8b1ec feat: review open answers in the admin and pick a grader per quiz
- Do oceny admin screen (golem15.quizzes.reviews) over the open answers,
  with a read-only context partial, score and comment virtual fields saved
  through SetScore, confirm and regrade record actions, bulk confirm
- open question type, rubric and max_length in the question form; grader
  dropdown on the quiz form from the grader registry
- Review model with quiz, status and student filters
2026-11-09 18:31:19 +01:00

271 lines
9.4 KiB
Go

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/controllers"
"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/cabana"
"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"
"git.golem15.com/golem15/summercms/modules/surf"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var (
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasLang = (*Plugin)(nil)
_ pact.HasMailTemplates = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasCommands = (*Plugin)(nil)
_ pact.HasJobs = (*Plugin)(nil)
_ pact.HasAdminControllers = (*Plugin)(nil)
_ pact.AdminAssets = (*Plugin)(nil)
_ pact.HasPermissions = (*Plugin)(nil)
_ pact.HasNavigation = (*Plugin)(nil)
_ pact.HasFrontendPermissions = (*Plugin)(nil)
_ surf.BucketProvider = (*Plugin)(nil)
)
//go:embed config
var configFS embed.FS
//go:embed lang
var langFS embed.FS
//go:embed views/mail
var mailFS embed.FS
//go:embed controllers/*/*.yaml controllers/*/*.htm models/*/*.yaml
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" }
// Requires golem15.user: the API runs behind its jwt.auth middleware and
// attempts reference its users table.
func (p *Plugin) Requires() []string { return []string{"golem15.user"} }
// Register keeps the application, so route handlers and commands can reach
// its services, such as the database, when they run.
func (p *Plugin) Register(app *backpack.App) error {
p.app = app
return nil
}
// Boot keeps the application, collects the grader registry and points the
// admin hooks at it: the filter choices and the review screen at its
// database, the quiz grader dropdown at the registry.
//
// 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
}
gdb, _ := app.Lookup[*gorm.DB]()
return gdb
}
controllers.GraderOptions = p.graders.Options
controllers.Reviews = func(ctx context.Context) *classes.Service {
return reviewService(ctx, app)
}
return nil
}
// reviewService is the admin review screen's service: over the admin
// request's write transaction when there is one, so a review nests in the
// save, else over the application's database.
func reviewService(ctx context.Context, app *backpack.App) *classes.Service {
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
return classes.ForApp(app, tx)
}
if app == nil {
return nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return nil
}
return classes.ForApp(app, gdb)
}
// 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 }
func (p *Plugin) MailTemplatesFS() fs.FS { return mailFS }
func (p *Plugin) MailTemplates() []string { return nil }
func (p *Plugin) MailLayouts() map[string]string { return nil }
func (p *Plugin) Models() []any { return generatedModels() }
func (p *Plugin) Migrations() []*gormigrate.Migration { return generatedMigrations() }
// 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()
}
// Commands returns the generated commands plus quizzes:import, which needs
// the database and so is built here with the plugin's withDB.
func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(), console.ImportCommand(p.withDB))
}
// withDB runs fn with the application's database: the one the serve command
// published, or, when a console command runs, one opened from the config for
// the duration of fn.
func (p *Plugin) withDB(ctx context.Context, fn func(*gorm.DB) error) error {
if gdb, ok := p.app.Lookup[*gorm.DB](); ok && gdb != nil {
return fn(gdb)
}
sqlDB, gdb, err := lagoon.OpenFromApp(ctx, p.app)
if err != nil {
return err
}
defer sqlDB.Close()
return fn(gdb)
}
// AdminFS is the admin YAML the controllers read.
func (p *Plugin) AdminFS() fs.FS { return adminFS }
// Permissions declares the admin permissions.
func (p *Plugin) Permissions() []pact.Permission {
return []pact.Permission{
{Code: permQuizzes, Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_quizzes"},
{Code: "golem15.quizzes.access_attempts", Tab: "golem15.quizzes::lang.plugin.name", Label: "golem15.quizzes::lang.permissions.access_attempts"},
}
}
// FrontendPermissions declares the code the owner manage routes check (the
// MCP quiz tools), so golem15.user's User Groups editor can grant it.
// Declaring it grants nothing by itself.
func (p *Plugin) FrontendPermissions() []pact.FrontendPermission {
return []pact.FrontendPermission{{
Code: permQuizzes,
Label: "golem15.quizzes::lang.permissions.access_quizzes",
Tab: "golem15.quizzes::lang.plugin.name",
}}
}
// Buckets declares the per-user rate limit of the owner manage routes:
// building a quiz question by question is chatty, so 120 calls a minute.
// A request without a user (never after user.api_token) keys by client IP.
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p != nil && p.app != nil {
trusted = surf.TrustedProxies(p.app.Config)
}
byUser := func(prefix string) func(*http.Request) string {
return func(r *http.Request) string {
if u, ok := bouncer.User(r.Context()); ok && u.ID != 0 {
return prefix + "|u:" + strconv.FormatUint(uint64(u.ID), 10)
}
return prefix + "|ip:" + surf.ClientIP(r, trusted)
}
}
return map[string]surf.Bucket{
bucketManage: {Max: 120, Decay: time.Minute, Key: byUser(bucketManage)},
}
}
// Navigation declares the "Quizzes" admin menu with its side menu.
func (p *Plugin) Navigation() []pact.NavigationItem {
return []pact.NavigationItem{{
Code: "quizzes",
Label: "golem15.quizzes::lang.plugin.name",
Icon: "icon-question-circle",
Permissions: []string{"golem15.quizzes.access_quizzes", "golem15.quizzes.access_attempts"},
Controller: "golem15.quizzes.quizzes",
SideMenu: []pact.NavigationItem{
{Code: "quizzes", Label: "golem15.quizzes::lang.quizzes.title", Icon: "icon-list", Controller: "golem15.quizzes.quizzes", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "questions", Label: "golem15.quizzes::lang.questions.title", Icon: "icon-question", Controller: "golem15.quizzes.questions", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "answers", Label: "golem15.quizzes::lang.answers.title", Icon: "icon-check-square", Controller: "golem15.quizzes.answers", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "reviews", Label: "golem15.quizzes::lang.reviews.title", Icon: "icon-check", Controller: "golem15.quizzes.reviews", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "attempts", Label: "golem15.quizzes::lang.attempts.title", Icon: "icon-bar-chart", Controller: "golem15.quizzes.attempts", Permissions: []string{"golem15.quizzes.access_attempts"}},
},
}}
}
func init() {
party.Register(&Plugin{})
}