From d05bee4e503cff2583ec3343d702257a687c8b82 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 2 Oct 2026 14:20:33 +0200 Subject: [PATCH] feat: add the quizzes:import command for JSON quiz files --- classes/importer.go | 215 ++++++++++++++++++ classes/importer_test.go | 57 +++++ console/import.go | 57 +++++ console/import_test.go | 37 +++ import_postgres_test.go | 100 ++++++++ plugin.go | 25 +- registry.gen.go | 1 + testdata/quiz.json | 37 +++ ...1002121917_add_completed_attempts_index.go | 23 ++ 9 files changed, 551 insertions(+), 1 deletion(-) create mode 100644 classes/importer.go create mode 100644 classes/importer_test.go create mode 100644 console/import.go create mode 100644 console/import_test.go create mode 100644 import_postgres_test.go create mode 100644 testdata/quiz.json create mode 100644 updates/20261002121917_add_completed_attempts_index.go diff --git a/classes/importer.go b/classes/importer.go new file mode 100644 index 0000000..fbd3756 --- /dev/null +++ b/classes/importer.go @@ -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 +} diff --git a/classes/importer_test.go b/classes/importer_test.go new file mode 100644 index 0000000..f24b530 --- /dev/null +++ b/classes/importer_test.go @@ -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) + } +} diff --git a/console/import.go b/console/import.go new file mode 100644 index 0000000..0d87040 --- /dev/null +++ b/console/import.go @@ -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 + }) + }, + } +} diff --git a/console/import_test.go b/console/import_test.go new file mode 100644 index 0000000..ef3bddb --- /dev/null +++ b/console/import_test.go @@ -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) + } +} diff --git a/import_postgres_test.go b/import_postgres_test.go new file mode 100644 index 0000000..3fb9e74 --- /dev/null +++ b/import_postgres_test.go @@ -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) + } +} diff --git a/plugin.go b/plugin.go index 73f60fb..f505894 100644 --- a/plugin.go +++ b/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 } diff --git a/registry.gen.go b/registry.gen.go index a126a5e..2742e74 100644 --- a/registry.gen.go +++ b/registry.gen.go @@ -25,6 +25,7 @@ func generatedMigrations() []*gormigrate.Migration { return []*gormigrate.Migration{ updates.CreateQuizzesTables(), updates.AddCategoryToAttempts(), + updates.AddCompletedAttemptsIndex(), } } diff --git a/testdata/quiz.json b/testdata/quiz.json new file mode 100644 index 0000000..14a4493 --- /dev/null +++ b/testdata/quiz.json @@ -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} + ] + } + ] +} diff --git a/updates/20261002121917_add_completed_attempts_index.go b/updates/20261002121917_add_completed_attempts_index.go new file mode 100644 index 0000000..c65f715 --- /dev/null +++ b/updates/20261002121917_add_completed_attempts_index.go @@ -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 + }, + } +}