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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user