feat: add the quizzes:import command for JSON quiz files

This commit is contained in:
Jakub Zych
2026-10-02 14:20:33 +02:00
parent 793cc4aac0
commit d05bee4e50
9 changed files with 551 additions and 1 deletions

215
classes/importer.go Normal file
View 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
View 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
View 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
View 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
View 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)
}
}

View File

@@ -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 }

View File

@@ -25,6 +25,7 @@ func generatedMigrations() []*gormigrate.Migration {
return []*gormigrate.Migration{
updates.CreateQuizzesTables(),
updates.AddCategoryToAttempts(),
updates.AddCompletedAttemptsIndex(),
}
}

37
testdata/quiz.json vendored Normal file
View 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}
]
}
]
}

View 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
},
}
}