216 lines
6.7 KiB
Go
216 lines
6.7 KiB
Go
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
|
|
}
|