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 }