feat: add the quizzes:import command for JSON quiz files
This commit is contained in:
215
classes/importer.go
Normal file
215
classes/importer.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ImportQuiz is the JSON document quizzes:import reads.
|
||||
type ImportQuiz struct {
|
||||
Title string `json:"title"`
|
||||
Slug string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Context *string `json:"context"`
|
||||
IsPublished *bool `json:"is_published"`
|
||||
TimeLimitSeconds *int `json:"time_limit_seconds"`
|
||||
PassingScore *int `json:"passing_score"`
|
||||
ShuffleQuestions *bool `json:"shuffle_questions"`
|
||||
RoundSize *int `json:"round_size"`
|
||||
Questions []ImportQuestion `json:"questions"`
|
||||
}
|
||||
|
||||
// ImportQuestion is one question of an ImportQuiz.
|
||||
type ImportQuestion struct {
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Category *string `json:"category"`
|
||||
AnswerText *string `json:"answer_text"`
|
||||
Explanation *string `json:"explanation"`
|
||||
Points *int `json:"points"`
|
||||
Answers []ImportAnswer `json:"answers"`
|
||||
}
|
||||
|
||||
// ImportAnswer is one answer of an ImportQuestion.
|
||||
type ImportAnswer struct {
|
||||
Text string `json:"text"`
|
||||
IsCorrect bool `json:"is_correct"`
|
||||
Explanation *string `json:"explanation"`
|
||||
}
|
||||
|
||||
// ErrQuizExists is an import of an existing slug without replace.
|
||||
var ErrQuizExists = errors.New("quizzes: a quiz with this slug already exists")
|
||||
|
||||
// ParseImport decodes and checks an import document. Unknown keys are an
|
||||
// error, so a typo never drops data silently. The returned slug is the one
|
||||
// the quiz will have.
|
||||
func ParseImport(raw []byte) (*ImportQuiz, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var doc ImportQuiz
|
||||
if err := dec.Decode(&doc); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON: %w", err)
|
||||
}
|
||||
var problems []string
|
||||
doc.Title = strings.TrimSpace(doc.Title)
|
||||
if doc.Title == "" {
|
||||
problems = append(problems, "title is required")
|
||||
}
|
||||
doc.Slug = models.Slugify(doc.Slug)
|
||||
if doc.Slug == "" {
|
||||
doc.Slug = models.Slugify(doc.Title)
|
||||
}
|
||||
if doc.Slug == "" && doc.Title != "" {
|
||||
problems = append(problems, "slug is empty after normalising the title")
|
||||
}
|
||||
if doc.PassingScore != nil && (*doc.PassingScore < 0 || *doc.PassingScore > 100) {
|
||||
problems = append(problems, "passing_score must be between 0 and 100")
|
||||
}
|
||||
if doc.RoundSize != nil && *doc.RoundSize < 1 {
|
||||
problems = append(problems, "round_size must be at least 1")
|
||||
}
|
||||
if doc.TimeLimitSeconds != nil && *doc.TimeLimitSeconds < 1 {
|
||||
problems = append(problems, "time_limit_seconds must be at least 1")
|
||||
}
|
||||
if len(doc.Questions) == 0 {
|
||||
problems = append(problems, "questions must not be empty")
|
||||
}
|
||||
for i, q := range doc.Questions {
|
||||
at := fmt.Sprintf("questions[%d]", i)
|
||||
if strings.TrimSpace(q.Text) == "" {
|
||||
problems = append(problems, at+".text is required")
|
||||
}
|
||||
if !slices.Contains(models.QuestionTypes, q.Type) {
|
||||
problems = append(problems, fmt.Sprintf("%s.type must be one of %s", at, strings.Join(models.QuestionTypes, ", ")))
|
||||
continue
|
||||
}
|
||||
if q.Points != nil && *q.Points < 0 {
|
||||
problems = append(problems, at+".points must not be negative")
|
||||
}
|
||||
correct := 0
|
||||
for j, a := range q.Answers {
|
||||
if strings.TrimSpace(a.Text) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s.answers[%d].text is required", at, j))
|
||||
}
|
||||
if a.IsCorrect {
|
||||
correct++
|
||||
}
|
||||
}
|
||||
switch q.Type {
|
||||
case models.TypeSingle:
|
||||
if len(q.Answers) < 2 || correct != 1 {
|
||||
problems = append(problems, at+": a single question needs at least two answers and exactly one correct")
|
||||
}
|
||||
case models.TypeMultiple:
|
||||
if len(q.Answers) < 2 || correct < 1 {
|
||||
problems = append(problems, at+": a multiple question needs at least two answers and one or more correct")
|
||||
}
|
||||
case models.TypeSelfCheck:
|
||||
if len(q.Answers) > 0 {
|
||||
problems = append(problems, at+": a self_check question has no answers; use answer_text")
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, fmt.Errorf("invalid quiz: %s", strings.Join(problems, "; "))
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
// ImportResult reports what an import wrote.
|
||||
type ImportResult struct {
|
||||
Quiz models.Quiz
|
||||
Created bool
|
||||
Questions int
|
||||
Answers int
|
||||
}
|
||||
|
||||
// Import writes doc in one transaction. A new slug creates the quiz. An
|
||||
// existing slug is ErrQuizExists unless replace is true: then the quiz's
|
||||
// fields are updated and its questions (with their answers and the attempt
|
||||
// answers given to them) are deleted and recreated. Questions keep the
|
||||
// document order as sort_order; points default to 1.
|
||||
func Import(ctx context.Context, db *gorm.DB, doc *ImportQuiz, replace bool) (*ImportResult, error) {
|
||||
res := &ImportResult{}
|
||||
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
tx = tx.WithContext(ctx)
|
||||
var quiz models.Quiz
|
||||
err := tx.Where("slug = ?", doc.Slug).Take(&quiz).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
res.Created = true
|
||||
quiz = models.Quiz{PassingScore: 50}
|
||||
case err != nil:
|
||||
return err
|
||||
case !replace:
|
||||
return ErrQuizExists
|
||||
}
|
||||
quiz.Title, quiz.Slug = doc.Title, doc.Slug
|
||||
quiz.Description, quiz.Context = doc.Description, doc.Context
|
||||
quiz.TimeLimitSeconds, quiz.RoundSize = doc.TimeLimitSeconds, doc.RoundSize
|
||||
if doc.IsPublished != nil {
|
||||
quiz.IsPublished = *doc.IsPublished
|
||||
}
|
||||
if doc.PassingScore != nil {
|
||||
quiz.PassingScore = *doc.PassingScore
|
||||
}
|
||||
if doc.ShuffleQuestions != nil {
|
||||
quiz.ShuffleQuestions = *doc.ShuffleQuestions
|
||||
}
|
||||
if err := tx.Save(&quiz).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if !res.Created {
|
||||
// The foreign keys cascade to answers and attempt answers.
|
||||
if err := tx.Where("quiz_id = ?", quiz.ID).Delete(&models.Question{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for i, in := range doc.Questions {
|
||||
points := 1
|
||||
if in.Points != nil {
|
||||
points = *in.Points
|
||||
}
|
||||
q := models.Question{
|
||||
QuizID: quiz.ID, Text: strings.TrimSpace(in.Text), Type: in.Type, Category: trimmed(in.Category),
|
||||
AnswerText: in.AnswerText, Explanation: in.Explanation, SortOrder: i + 1, Points: points,
|
||||
}
|
||||
if err := tx.Create(&q).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for j, a := range in.Answers {
|
||||
ans := models.Answer{QuestionID: q.ID, Text: strings.TrimSpace(a.Text), IsCorrect: a.IsCorrect, Explanation: a.Explanation, SortOrder: j + 1}
|
||||
if err := tx.Create(&ans).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
res.Answers++
|
||||
}
|
||||
res.Questions++
|
||||
}
|
||||
res.Quiz = quiz
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func trimmed(s *string) *string {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
t := strings.TrimSpace(*s)
|
||||
if t == "" {
|
||||
return nil
|
||||
}
|
||||
return &t
|
||||
}
|
||||
57
classes/importer_test.go
Normal file
57
classes/importer_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package classes_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
)
|
||||
|
||||
func TestParseImportSample(t *testing.T) {
|
||||
raw, err := os.ReadFile("../testdata/quiz.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, err := classes.ParseImport(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseImport: %v", err)
|
||||
}
|
||||
if doc.Slug != "bezpieczenstwo-pojecia" {
|
||||
t.Errorf("slug = %q, want it built from the title", doc.Slug)
|
||||
}
|
||||
if len(doc.Questions) != 3 || *doc.RoundSize != 5 || *doc.PassingScore != 60 {
|
||||
t.Errorf("doc = %+v", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseImportRejects(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`not json`: "invalid JSON",
|
||||
`{"title":"A","questions":[{"text":"Q","type":"self_check"}],"extra":1}`: "unknown field",
|
||||
`{"questions":[{"text":"Q","type":"self_check"}]}`: "title is required",
|
||||
`{"title":"A","questions":[]}`: "questions must not be empty",
|
||||
`{"title":"A","passing_score":101,"questions":[{"text":"Q","type":"self_check"}]}`: "passing_score",
|
||||
`{"title":"A","round_size":0,"questions":[{"text":"Q","type":"self_check"}]}`: "round_size",
|
||||
`{"title":"A","time_limit_seconds":0,"questions":[{"text":"Q","type":"self_check"}]}`: "time_limit_seconds",
|
||||
`{"title":"A","questions":[{"text":"","type":"self_check"}]}`: "questions[0].text",
|
||||
`{"title":"A","questions":[{"text":"Q","type":"essay"}]}`: "questions[0].type",
|
||||
`{"title":"A","questions":[{"text":"Q","type":"self_check","points":-1}]}`: "points",
|
||||
`{"title":"A","questions":[{"text":"Q","type":"self_check","answers":[{"text":"x"}]}]}`: "self_check question has no answers",
|
||||
`{"title":"A","questions":[{"text":"Q","type":"single","answers":[{"text":"x","is_correct":true}]}]}`: "single question",
|
||||
`{"title":"A","questions":[{"text":"Q","type":"single","answers":[{"text":"x"},{"text":"y"}]}]}`: "single question",
|
||||
`{"title":"A","questions":[{"text":"Q","type":"multiple","answers":[{"text":"x"},{"text":"y"}]}]}`: "multiple question",
|
||||
`{"title":"A","questions":[{"text":"Q","type":"multiple","answers":[{"text":"","is_correct":true},{"text":"y"}]}]}`: "answers[0].text",
|
||||
`{"title":"!!!","questions":[{"text":"Q","type":"self_check"}]}`: "slug is empty",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
_, err := classes.ParseImport([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("ParseImport(%s) = %v, want an error with %q", raw, err, want)
|
||||
}
|
||||
}
|
||||
doc, err := classes.ParseImport([]byte(`{"title":"Ignored","slug":"Mój Slug","questions":[{"text":"Q","type":"self_check"}]}`))
|
||||
if err != nil || doc.Slug != "moj-slug" {
|
||||
t.Errorf("explicit slug = %v %v, want moj-slug", doc, err)
|
||||
}
|
||||
}
|
||||
57
console/import.go
Normal file
57
console/import.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package console
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ImportCommand returns the quizzes:import console command. withDB runs the
|
||||
// command's work with the application's database; the plugin supplies it.
|
||||
func ImportCommand(withDB func(ctx context.Context, fn func(*gorm.DB) error) error) bonfire.Command {
|
||||
return bonfire.Command{
|
||||
Name: "quizzes:import",
|
||||
Description: "Import one quiz from a JSON file (see the plugin README for the format)",
|
||||
Args: []bonfire.Arg{{Name: "file", Description: "Path to the quiz JSON file", Required: true}},
|
||||
Flags: []bonfire.Flag{
|
||||
{Name: "replace", Description: "Replace the questions of an existing quiz with the same slug", Bare: true},
|
||||
},
|
||||
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
|
||||
file, _ := in.Argument("file")
|
||||
if file == "" {
|
||||
return fmt.Errorf("quizzes:import: a file is required")
|
||||
}
|
||||
raw, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("quizzes:import: %w", err)
|
||||
}
|
||||
doc, err := classes.ParseImport(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("quizzes:import: %s: %w", file, err)
|
||||
}
|
||||
replace, _ := in.Flag("replace")
|
||||
return withDB(ctx, func(db *gorm.DB) error {
|
||||
res, err := classes.Import(ctx, db, doc, replace == "true")
|
||||
if errors.Is(err, classes.ErrQuizExists) {
|
||||
return fmt.Errorf("quizzes:import: a quiz with the slug %q already exists; pass --replace to replace its questions", doc.Slug)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("quizzes:import: %w", err)
|
||||
}
|
||||
verb := "replaced"
|
||||
if res.Created {
|
||||
verb = "created"
|
||||
}
|
||||
out.Printf("%s quiz %s (id %d): %d questions, %d answers\n", verb, res.Quiz.Slug, res.Quiz.ID, res.Questions, res.Answers)
|
||||
return nil
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
37
console/import_test.go
Normal file
37
console/import_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package console_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/console"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestImportCommandChecksTheFileFirst(t *testing.T) {
|
||||
opened := false
|
||||
cmd := console.ImportCommand(func(ctx context.Context, fn func(*gorm.DB) error) error {
|
||||
opened = true
|
||||
return errors.New("no database in unit tests")
|
||||
})
|
||||
if cmd.Name != "quizzes:import" || len(cmd.Args) != 1 || !cmd.Args[0].Required || cmd.Flags[0].Name != "replace" || !cmd.Flags[0].Bare {
|
||||
t.Fatalf("command = %+v", cmd)
|
||||
}
|
||||
call := func(args ...string) error {
|
||||
var out bytes.Buffer
|
||||
return bonfire.Call(context.Background(), []bonfire.Command{cmd}, "quizzes:import", args, &out)
|
||||
}
|
||||
if err := call("../testdata/missing.json"); err == nil || opened {
|
||||
t.Errorf("missing file = %v (database opened: %v)", err, opened)
|
||||
}
|
||||
if err := call("../go.mod"); err == nil || !strings.Contains(err.Error(), "invalid JSON") || opened {
|
||||
t.Errorf("non-JSON file = %v (database opened: %v)", err, opened)
|
||||
}
|
||||
if err := call("../testdata/quiz.json"); err == nil || !opened {
|
||||
t.Errorf("valid file = %v, want the database opened", err)
|
||||
}
|
||||
}
|
||||
100
import_postgres_test.go
Normal file
100
import_postgres_test.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package quizzes_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
)
|
||||
|
||||
func TestImportCommand(t *testing.T) {
|
||||
_, plugins, gdb := migrated(t)
|
||||
cmds := quizzesPlugin(t, plugins).(pact.HasCommands).Commands()
|
||||
run := func(args ...string) (string, error) {
|
||||
var out bytes.Buffer
|
||||
err := bonfire.Call(t.Context(), cmds, "quizzes:import", args, &out)
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
out, err := run("testdata/quiz.json")
|
||||
if err != nil {
|
||||
t.Fatalf("import: %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(out, "created quiz bezpieczenstwo-pojecia") || !strings.Contains(out, "3 questions, 5 answers") {
|
||||
t.Errorf("output = %q", out)
|
||||
}
|
||||
var quiz models.Quiz
|
||||
must(t, gdb.Preload("Questions.Answers").Where("slug = ?", "bezpieczenstwo-pojecia").Take(&quiz).Error)
|
||||
if !quiz.IsPublished || quiz.PassingScore != 60 || quiz.RoundSize == nil || *quiz.RoundSize != 5 || *quiz.Context != "acme.courses:course:12" {
|
||||
t.Errorf("quiz = %+v", quiz)
|
||||
}
|
||||
if len(quiz.Questions) != 3 {
|
||||
t.Fatalf("questions = %d", len(quiz.Questions))
|
||||
}
|
||||
byOrder := map[int]models.Question{}
|
||||
for _, q := range quiz.Questions {
|
||||
byOrder[q.SortOrder] = q
|
||||
}
|
||||
if q := byOrder[1]; q.Type != models.TypeSelfCheck || *q.Category != "Teoria" || q.AnswerText == nil || q.Points != 1 {
|
||||
t.Errorf("first question = %+v", q)
|
||||
}
|
||||
if q := byOrder[3]; q.Points != 2 || len(q.Answers) != 3 || *q.Category != "Globalizacja" {
|
||||
t.Errorf("third question = %+v", q)
|
||||
}
|
||||
|
||||
// Without --replace an existing slug is refused and nothing changes.
|
||||
if _, err := run("testdata/quiz.json"); err == nil || !strings.Contains(err.Error(), "--replace") {
|
||||
t.Errorf("second import without --replace = %v", err)
|
||||
}
|
||||
|
||||
// --replace recreates the questions; attempt answers to the old ones go
|
||||
// with them.
|
||||
u, _ := newUser(t, gdb, "ala@example.com")
|
||||
a := models.Attempt{QuizID: quiz.ID, UserID: u.ID, StartedAt: time.Now()}
|
||||
must(t, gdb.Create(&a).Error)
|
||||
must(t, gdb.Create(&models.AttemptAnswer{AttemptID: a.ID, QuestionID: byOrder[1].ID}).Error)
|
||||
|
||||
changed := filepath.Join(t.TempDir(), "quiz.json")
|
||||
raw, _ := os.ReadFile("testdata/quiz.json")
|
||||
raw = bytes.Replace(raw, []byte(`"passing_score": 60`), []byte(`"slug": "bezpieczenstwo-pojecia", "passing_score": 70`), 1)
|
||||
raw = bytes.Replace(raw, []byte(`"Czym jest odporność społeczna?"`), []byte(`"Czym jest obrona totalna?"`), 1)
|
||||
must(t, os.WriteFile(changed, raw, 0o644))
|
||||
out, err = run(changed, "--replace")
|
||||
if err != nil || !strings.Contains(out, "replaced quiz bezpieczenstwo-pojecia (id ") {
|
||||
t.Fatalf("replace = %v %q", err, out)
|
||||
}
|
||||
var again models.Quiz
|
||||
must(t, gdb.Preload("Questions").Where("slug = ?", "bezpieczenstwo-pojecia").Take(&again).Error)
|
||||
if again.ID != quiz.ID || again.PassingScore != 70 || len(again.Questions) != 3 {
|
||||
t.Errorf("replaced quiz = %+v", again)
|
||||
}
|
||||
var n int64
|
||||
must(t, gdb.Model(&models.Question{}).Where("text = ?", "Czym jest obrona totalna?").Count(&n).Error)
|
||||
if n != 1 {
|
||||
t.Errorf("new question rows = %d", n)
|
||||
}
|
||||
must(t, gdb.Model(&models.AttemptAnswer{}).Count(&n).Error)
|
||||
if n != 0 {
|
||||
t.Errorf("attempt answers after replace = %d", n)
|
||||
}
|
||||
must(t, gdb.Model(&models.Attempt{}).Count(&n).Error)
|
||||
if n != 1 {
|
||||
t.Errorf("attempts after replace = %d, want the attempt kept", n)
|
||||
}
|
||||
|
||||
// Errors.
|
||||
if _, err := run("testdata/missing.json"); err == nil {
|
||||
t.Error("a missing file imported")
|
||||
}
|
||||
bad := filepath.Join(t.TempDir(), "bad.json")
|
||||
must(t, os.WriteFile(bad, []byte(`{"title":"X","questions":[]}`), 0o644))
|
||||
if _, err := run(bad); err == nil || !strings.Contains(err.Error(), "questions must not be empty") {
|
||||
t.Errorf("invalid file = %v", err)
|
||||
}
|
||||
}
|
||||
25
plugin.go
25
plugin.go
@@ -1,12 +1,15 @@
|
||||
package quizzes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/console"
|
||||
"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/lagoon"
|
||||
"git.golem15.com/golem15/summercms/modules/pact"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
@@ -80,12 +83,32 @@ 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) Commands() []bonfire.Command { return generatedCommands() }
|
||||
func (p *Plugin) Jobs() []pact.Job { return generatedJobs() }
|
||||
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 }
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ func generatedMigrations() []*gormigrate.Migration {
|
||||
return []*gormigrate.Migration{
|
||||
updates.CreateQuizzesTables(),
|
||||
updates.AddCategoryToAttempts(),
|
||||
updates.AddCompletedAttemptsIndex(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
testdata/quiz.json
vendored
Normal file
37
testdata/quiz.json
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"title": "Bezpieczeństwo – pojęcia",
|
||||
"description": "Fiszki i pytania kontrolne.",
|
||||
"context": "acme.courses:course:12",
|
||||
"is_published": true,
|
||||
"round_size": 5,
|
||||
"passing_score": 60,
|
||||
"questions": [
|
||||
{
|
||||
"text": "Czym jest odporność społeczna?",
|
||||
"type": "self_check",
|
||||
"category": "Teoria",
|
||||
"answer_text": "Zdolność społeczeństwa do przetrwania kryzysu i szybkiego powrotu do normalnego funkcjonowania."
|
||||
},
|
||||
{
|
||||
"text": "Który dokument jest strategią bezpieczeństwa?",
|
||||
"type": "single",
|
||||
"category": "Dokumenty",
|
||||
"explanation": "Strategia określa cele i sposoby ich osiągania.",
|
||||
"answers": [
|
||||
{"text": "Strategia Bezpieczeństwa Narodowego", "is_correct": true},
|
||||
{"text": "Kodeks drogowy", "is_correct": false, "explanation": "To akt prawny o ruchu drogowym."}
|
||||
]
|
||||
},
|
||||
{
|
||||
"text": "Które państwa należą do NATO?",
|
||||
"type": "multiple",
|
||||
"category": "Globalizacja",
|
||||
"points": 2,
|
||||
"answers": [
|
||||
{"text": "Polska", "is_correct": true},
|
||||
{"text": "Szwecja", "is_correct": true},
|
||||
{"text": "Szwajcaria", "is_correct": false}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
23
updates/20261002121917_add_completed_attempts_index.go
Normal file
23
updates/20261002121917_add_completed_attempts_index.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Code generated by summer make. DO NOT EDIT.
|
||||
|
||||
package updates
|
||||
|
||||
import (
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AddCompletedAttemptsIndex returns the 20261002121917_add_completed_attempts_index gormigrate entry.
|
||||
// The partial index serves the per-user statistics of the quiz list (count
|
||||
// and best score of completed attempts).
|
||||
func AddCompletedAttemptsIndex() *gormigrate.Migration {
|
||||
return &gormigrate.Migration{
|
||||
ID: "20261002121917_add_completed_attempts_index",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return tx.Exec("CREATE INDEX golem15_quizzes_attempts_completed_index ON golem15_quizzes_attempts (user_id, quiz_id, score_percent) WHERE completed_at IS NOT NULL").Error
|
||||
},
|
||||
Rollback: func(tx *gorm.DB) error {
|
||||
return tx.Exec("DROP INDEX IF EXISTS golem15_quizzes_attempts_completed_index").Error
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user