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{}) }