package classes // Owner-side management behind the manage routes under /api/v1/quizzes, // which the MCP quiz tools bind. There is no owner column: a user whose // groups grant golem15.quizzes.access_quizzes manages every quiz, as in the // admin (A7). Quizzes are addressed by slug. import ( "context" "errors" "fmt" "math" "slices" "strings" "time" "unicode/utf8" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" "gorm.io/gorm/clause" ) // ErrQuestionNotFound is a question id that is not a question of the quiz. // It is an ErrNotFound. var ErrQuestionNotFound = fmt.Errorf("%w: question", ErrNotFound) // ValidationError is manage input that breaks a rule (a 422). Problems // read like the importer's: "title is required". type ValidationError struct { Problems []string } func (e *ValidationError) Error() string { return "invalid quiz: " + strings.Join(e.Problems, "; ") } // invalid wraps problems in a ValidationError, or returns nil for none. func invalid(problems []string) error { if len(problems) == 0 { return nil } return &ValidationError{Problems: problems} } // QuizInput is the quiz fields of a create or a partial update; nil means // not sent. There is no IsPublished: a created quiz starts unpublished and // only SetPublished changes it. type QuizInput struct { Title *string `json:"title"` Slug *string `json:"slug"` Description *string `json:"description"` Context *string `json:"context"` Mode *string `json:"mode"` Navigation *string `json:"navigation"` MaxAttempts *int `json:"max_attempts"` TimeLimitSeconds *int `json:"time_limit_seconds"` PassingScore *int `json:"passing_score"` RoundSize *int `json:"round_size"` ShuffleQuestions *bool `json:"shuffle_questions"` Grader *string `json:"grader"` } // CheckQuizInput returns the rule problems of in. On create the title is // required and max_attempts, time_limit_seconds and round_size are at least // 1; on update 0 is allowed for them, as it clears the value. func CheckQuizInput(in QuizInput, create bool) []string { var problems []string switch { case in.Title == nil: if create { problems = append(problems, "title is required") } case strings.TrimSpace(*in.Title) == "": problems = append(problems, "title is required") case utf8.RuneCountInString(strings.TrimSpace(*in.Title)) > 255: problems = append(problems, "title must be at most 255 characters") } if create && in.Slug != nil && strings.TrimSpace(*in.Slug) != "" && models.Slugify(*in.Slug) == "" { problems = append(problems, "slug is empty after normalising it") } if create && (in.Slug == nil || strings.TrimSpace(*in.Slug) == "") && in.Title != nil && strings.TrimSpace(*in.Title) != "" && models.Slugify(*in.Title) == "" { problems = append(problems, "slug is empty after normalising the title") } if in.Context != nil && utf8.RuneCountInString(strings.TrimSpace(*in.Context)) > 255 { problems = append(problems, "context must be at most 255 characters") } if in.Mode != nil && !slices.Contains(models.QuizModes, *in.Mode) { problems = append(problems, "mode must be one of "+strings.Join(models.QuizModes, ", ")) } if in.Navigation != nil && !slices.Contains(models.QuizNavigations, *in.Navigation) { problems = append(problems, "navigation must be one of "+strings.Join(models.QuizNavigations, ", ")) } if in.PassingScore != nil && (*in.PassingScore < 0 || *in.PassingScore > 100) { problems = append(problems, "passing_score must be between 0 and 100") } problems = append(problems, checkGraderFormat(in.Grader)...) least, word := 1, "at least 1" if !create { least, word = 0, "0 (clears it) or more" } for _, f := range []struct { name string v *int }{{"max_attempts", in.MaxAttempts}, {"time_limit_seconds", in.TimeLimitSeconds}, {"round_size", in.RoundSize}} { if f.v != nil && *f.v < least { problems = append(problems, f.name+" must be "+word) } } return problems } // CheckGrader is the problem of a sent grader code that is not one of // known, the codes of the registry (manual first), naming the valid codes. // A nil code is fine. func CheckGrader(code *string, known []string) []string { if code == nil || slices.Contains(known, *code) { return nil } return []string{"grader must be one of " + strings.Join(known, ", ")} } // positive is v, or nil when v is nil or not positive (0 clears). func positive(v *int) *int { if v == nil || *v <= 0 { return nil } n := *v return &n } // CreateQuiz creates an unpublished quiz from in. The slug comes from Slug, // or from the title when Slug is empty; a taken slug is ErrQuizExists. The // passing score defaults to 50, mode and navigation to learning / free. func CreateQuiz(ctx context.Context, db *gorm.DB, in QuizInput) (*models.Quiz, error) { if err := invalid(CheckQuizInput(in, true)); err != nil { return nil, err } quiz := models.Quiz{Title: strings.TrimSpace(*in.Title), PassingScore: 50} if in.Slug != nil && strings.TrimSpace(*in.Slug) != "" { quiz.Slug = models.Slugify(*in.Slug) } else { quiz.Slug = models.Slugify(quiz.Title) } applyQuizInput(&quiz, in) err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) var n int64 if err := tx.Model(&models.Quiz{}).Where("slug = ?", quiz.Slug).Count(&n).Error; err != nil { return err } if n > 0 { return ErrQuizExists } return tx.Create(&quiz).Error }) if err != nil { return nil, err } return &quiz, nil } // applyQuizInput sets the sent fields of in on quiz, except the slug and // the title's presence check. An empty description or context is NULL, a // 0 limit is NULL. func applyQuizInput(quiz *models.Quiz, in QuizInput) { if in.Title != nil { quiz.Title = strings.TrimSpace(*in.Title) } if in.Description != nil { quiz.Description = nilIfBlank(*in.Description, false) } if in.Context != nil { quiz.Context = nilIfBlank(*in.Context, true) } if in.Mode != nil { quiz.Mode = *in.Mode } if in.Navigation != nil { quiz.Navigation = *in.Navigation } if in.MaxAttempts != nil { quiz.MaxAttempts = positive(in.MaxAttempts) } if in.TimeLimitSeconds != nil { quiz.TimeLimitSeconds = positive(in.TimeLimitSeconds) } if in.RoundSize != nil { quiz.RoundSize = positive(in.RoundSize) } if in.PassingScore != nil { quiz.PassingScore = *in.PassingScore } if in.ShuffleQuestions != nil { quiz.ShuffleQuestions = *in.ShuffleQuestions } if in.Grader != nil { quiz.Grader = *in.Grader } } // nilIfBlank is nil for a blank s, else s (trimmed when trim is set). func nilIfBlank(s string, trim bool) *string { if strings.TrimSpace(s) == "" { return nil } if trim { s = strings.TrimSpace(s) } return &s } // UpdateQuiz changes only the sent fields of the quiz with this slug: 0 // clears max_attempts, time_limit_seconds and round_size, an empty string // clears description and context. Slug is ignored, and it cannot publish // (A10). A missing slug is ErrNotFound. func UpdateQuiz(ctx context.Context, db *gorm.DB, slug string, in QuizInput) (*models.Quiz, error) { if err := invalid(CheckQuizInput(in, false)); err != nil { return nil, err } var quiz models.Quiz err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) if err := lockedQuiz(tx, slug, &quiz); err != nil { return err } applyQuizInput(&quiz, in) return tx.Omit(clause.Associations).Save(&quiz).Error }) if err != nil { return nil, err } return &quiz, nil } // lockedQuiz loads the quiz with this slug FOR UPDATE, or ErrNotFound. func lockedQuiz(tx *gorm.DB, slug string, quiz *models.Quiz) error { err := tx.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}). Where("slug = ?", slug).Take(quiz).Error if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound } return err } // ValidateDeleteConfirm refuses a delete whose confirm value is not the // quiz slug, so a stray DELETE cannot remove a quiz by accident. func ValidateDeleteConfirm(slug, confirm string) error { if strings.TrimSpace(confirm) == "" || confirm != slug { return &ValidationError{Problems: []string{"confirm must repeat the quiz slug"}} } return nil } // DeletedQuiz is the answer of a quiz delete: what was removed. type DeletedQuiz struct { ID uint Slug string Title string Questions int64 Attempts int64 } // DeleteQuiz removes the quiz with this slug for good. The foreign keys // cascade to its questions, answers, attempts and attempt answers; the // counts are taken first. A missing slug is ErrNotFound. func DeleteQuiz(ctx context.Context, db *gorm.DB, slug string) (*DeletedQuiz, error) { var out DeletedQuiz err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) var quiz models.Quiz if err := lockedQuiz(tx, slug, &quiz); err != nil { return err } out = DeletedQuiz{ID: quiz.ID, Slug: quiz.Slug, Title: quiz.Title} if err := tx.Model(&models.Question{}).Where("quiz_id = ?", quiz.ID).Count(&out.Questions).Error; err != nil { return err } if err := tx.Model(&models.Attempt{}).Where("quiz_id = ?", quiz.ID).Count(&out.Attempts).Error; err != nil { return err } return tx.Where("id = ?", quiz.ID).Delete(&models.Quiz{}).Error }) if err != nil { return nil, err } return &out, nil } // SetPublished publishes or unpublishes the quiz with this slug; doing it // twice is the same as once. A missing slug is ErrNotFound. func SetPublished(ctx context.Context, db *gorm.DB, slug string, published bool) (*models.Quiz, error) { var quiz models.Quiz err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) if err := lockedQuiz(tx, slug, &quiz); err != nil { return err } if quiz.IsPublished == published { return nil } quiz.IsPublished = published return tx.Model(&models.Quiz{}).Where("id = ?", quiz.ID). Updates(map[string]any{"is_published": published, "updated_at": time.Now().UTC()}).Error }) if err != nil { return nil, err } return &quiz, nil } // ManagedRow counts the questions and completed attempts of one quiz, for // the answers of the quiz writes. func ManagedRow(ctx context.Context, db *gorm.DB, quiz models.Quiz) (ManagedQuizRow, error) { row := ManagedQuizRow{Quiz: quiz} var n int64 if err := db.WithContext(ctx).Model(&models.Question{}).Where("quiz_id = ?", quiz.ID).Count(&n).Error; err != nil { return row, err } row.QuestionCount = int(n) attempts, err := CompletedAttemptCount(ctx, db, quiz.ID) row.AttemptsCount = attempts return row, err } // ManagedQuizRow is one quiz of the owner list with its question count and // the number of completed attempts (of every user). type ManagedQuizRow struct { Quiz models.Quiz QuestionCount int AttemptsCount int } // ListManagedQuizzes returns every quiz, published or not, optionally only // those whose context equals quizContext, ordered by context (quizzes // without one last), title and id. func ListManagedQuizzes(ctx context.Context, db *gorm.DB, quizContext string) ([]ManagedQuizRow, error) { db = db.WithContext(ctx) q := db.Model(&models.Quiz{}) if quizContext != "" { q = q.Where("context = ?", quizContext) } var quizzes []models.Quiz if err := q.Order("context ASC NULLS LAST, title ASC, id ASC").Find(&quizzes).Error; err != nil { return nil, err } out := make([]ManagedQuizRow, 0, len(quizzes)) if len(quizzes) == 0 { return out, nil } ids := make([]uint, len(quizzes)) for i, quiz := range quizzes { ids[i] = quiz.ID } type countRow struct { QuizID uint N int } var questions, attempts []countRow if err := db.Model(&models.Question{}).Select("quiz_id, COUNT(*) AS n"). Where("quiz_id IN ?", ids).Group("quiz_id").Scan(&questions).Error; err != nil { return nil, err } if err := db.Model(&models.Attempt{}).Select("quiz_id, COUNT(*) AS n"). Where("quiz_id IN ? AND completed_at IS NOT NULL", ids).Group("quiz_id").Scan(&attempts).Error; err != nil { return nil, err } questionsBy := map[uint]int{} for _, c := range questions { questionsBy[c.QuizID] = c.N } attemptsBy := map[uint]int{} for _, c := range attempts { attemptsBy[c.QuizID] = c.N } for _, quiz := range quizzes { out = append(out, ManagedQuizRow{Quiz: quiz, QuestionCount: questionsBy[quiz.ID], AttemptsCount: attemptsBy[quiz.ID]}) } return out, nil } // ManagedQuiz loads a quiz, published or not, with every question in sort // order and every answer of each, solutions included. A missing slug is // ErrNotFound. func ManagedQuiz(ctx context.Context, db *gorm.DB, slug string) (*models.Quiz, error) { var quiz models.Quiz err := db.WithContext(ctx). Preload("Questions", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }). Preload("Questions.Answers", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }). Where("slug = ?", slug).Take(&quiz).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } if err != nil { return nil, err } return &quiz, nil } // CompletedAttemptCount counts the completed attempts of every user on a // quiz. func CompletedAttemptCount(ctx context.Context, db *gorm.DB, quizID uint) (int, error) { var n int64 err := db.WithContext(ctx).Model(&models.Attempt{}). Where("quiz_id = ? AND completed_at IS NOT NULL", quizID).Count(&n).Error return int(n), err } // StudentStat is one student's results on a quiz, over their completed // whole-quiz attempts (category IS NULL, A9). type StudentStat struct { UserID uint Email string Name *string Surname *string Attempts int BestScorePercent *float64 LastScorePercent *float64 LastCompletedAt *time.Time // Passed is BestScorePercent >= the quiz's passing score. Passed bool } // WrongAnswerStat is the wrong answer chosen most often for a question. type WrongAnswerStat struct { AnswerID uint Text string Count int } // QuestionStat is one question's results over every completed attempt's // answers. CorrectPercent is nil when nobody answered it; MostChosenWrong // is nil for a self_check or open question and when no wrong answer was // chosen. Unreviewed counts the open answers the owner has not reviewed // yet (pending, graded or failed); AveragePoints is the mean score of an // open question's graded and reviewed answers, nil for other types and // before any grade. type QuestionStat struct { ID uint Text string Type string Answered int Correct int CorrectPercent *float64 MostChosenWrong *WrongAnswerStat Unreviewed int AveragePoints *float64 } // Stats is the owner's report on one quiz (D-15). type Stats struct { Quiz models.Quiz QuestionCount int Students []StudentStat Questions []QuestionStat // Unreviewed is the sum of the questions' Unreviewed. Unreviewed int } // StudentRow is one row of the per-student stats query. type StudentRow struct { UserID uint Email string Name *string Surname *string Attempts int BestScorePercent *float64 LastScorePercent *float64 LastCompletedAt *time.Time } // AnswerRow is one row of the per-question answered/correct query. type AnswerRow struct { QuestionID uint Answered int Correct int Unreviewed int AveragePoints *float64 } // WrongRow is one wrong answer chosen N times for a question. type WrongRow struct { QuestionID uint AnswerID uint Text string N int } const ( statsStudentsSQL = `SELECT a.user_id, u.email, u.name, u.surname, COUNT(*) AS attempts, MAX(a.score_percent) AS best_score_percent, (ARRAY_AGG(a.score_percent ORDER BY a.completed_at DESC, a.id DESC))[1] AS last_score_percent, MAX(a.completed_at) AS last_completed_at FROM golem15_quizzes_attempts a JOIN users u ON u.id = a.user_id WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL AND a.category IS NULL GROUP BY a.user_id, u.email, u.name, u.surname ORDER BY u.surname ASC NULLS LAST, u.name ASC NULLS LAST, u.email ASC, a.user_id ASC` // average_points counts only scored open answers: a pending or failed // answer holds 0 points until it is graded. statsAnswersSQL = `SELECT aa.question_id, COUNT(*) AS answered, COUNT(*) FILTER (WHERE aa.is_correct) AS correct, COUNT(*) FILTER (WHERE aa.grading_status IN ('pending', 'graded', 'failed')) AS unreviewed, AVG(aa.points) FILTER (WHERE aa.grading_status IN ('graded', 'reviewed')) AS average_points FROM golem15_quizzes_attempt_answers aa JOIN golem15_quizzes_attempts a ON a.id = aa.attempt_id WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL GROUP BY aa.question_id` // A stored answer_ids that is SQL NULL or not an array (self_check // rows) yields no elements instead of an error. statsWrongSQL = `SELECT aa.question_id, ans.id AS answer_id, ans.text, COUNT(*) AS n FROM golem15_quizzes_attempt_answers aa JOIN golem15_quizzes_attempts a ON a.id = aa.attempt_id CROSS JOIN LATERAL jsonb_array_elements_text(CASE WHEN jsonb_typeof(aa.answer_ids) = 'array' THEN aa.answer_ids ELSE '[]'::jsonb END) AS e(value) JOIN golem15_quizzes_answers ans ON ans.id = e.value::bigint AND ans.question_id = aa.question_id AND ans.is_correct = FALSE WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL GROUP BY aa.question_id, ans.id, ans.text ORDER BY aa.question_id, n DESC, ans.id` ) // QuizStats reports on the quiz with this slug. For a test it first closes // every expired open attempt at its deadline, through svc, so a student who // left a test counts with the answers given in time. Students are counted // over completed whole-quiz attempts, questions over the answers of every // completed attempt. A missing slug is ErrNotFound. func QuizStats(ctx context.Context, db *gorm.DB, slug string, svc *Service) (*Stats, error) { if svc == nil { svc = &Service{DB: db, Grace: DefaultTestGrace} } var quiz models.Quiz err := db.WithContext(ctx).Where("slug = ?", slug).Take(&quiz).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } if err != nil { return nil, err } if IsTest(&quiz) { if err := svc.closeExpired(ctx, db, &quiz); err != nil { return nil, err } } tx := db.WithContext(ctx) var questions []models.Question if err := tx.Where("quiz_id = ?", quiz.ID).Order("sort_order ASC, id ASC").Find(&questions).Error; err != nil { return nil, err } var students []StudentRow if err := tx.Raw(statsStudentsSQL, quiz.ID).Scan(&students).Error; err != nil { return nil, err } var answers []AnswerRow if err := tx.Raw(statsAnswersSQL, quiz.ID).Scan(&answers).Error; err != nil { return nil, err } var wrong []WrongRow if err := tx.Raw(statsWrongSQL, quiz.ID).Scan(&wrong).Error; err != nil { return nil, err } stats := BuildStats(quiz, questions, students, answers, wrong) return &stats, nil } // closeExpired closes every expired open attempt of a test at its // deadline, in one transaction, with the rows locked as Start locks them. func (s *Service) closeExpired(ctx context.Context, db *gorm.DB, quiz *models.Quiz) error { return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { var open []models.Attempt if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}). Where("quiz_id = ? AND completed_at IS NULL", quiz.ID).Order("id ASC").Find(&open).Error; err != nil { return err } now := s.now() for i := range open { a := &open[i] a.Quiz = quiz if Expired(a, now, s.Grace) { if err := s.close(ctx, tx, a, *Deadline(a)); err != nil { return err } } } return nil }) } // BuildStats assembles the report from the query rows. Every question of // questions appears, in that order, with zero counts when nobody answered // it; correct_percent is rounded to one decimal. func BuildStats(quiz models.Quiz, questions []models.Question, students []StudentRow, answers []AnswerRow, wrong []WrongRow) Stats { out := Stats{ Quiz: quiz, QuestionCount: len(questions), Students: make([]StudentStat, 0, len(students)), Questions: make([]QuestionStat, 0, len(questions)), } for _, r := range students { out.Students = append(out.Students, StudentStat{ UserID: r.UserID, Email: r.Email, Name: r.Name, Surname: r.Surname, Attempts: r.Attempts, BestScorePercent: r.BestScorePercent, LastScorePercent: r.LastScorePercent, LastCompletedAt: r.LastCompletedAt, Passed: r.BestScorePercent != nil && *r.BestScorePercent >= float64(quiz.PassingScore), }) } answeredBy := map[uint]AnswerRow{} for _, r := range answers { answeredBy[r.QuestionID] = r } wrongBy := map[uint]*WrongAnswerStat{} for _, r := range wrong { best := wrongBy[r.QuestionID] if best == nil || r.N > best.Count || (r.N == best.Count && r.AnswerID < best.AnswerID) { wrongBy[r.QuestionID] = &WrongAnswerStat{AnswerID: r.AnswerID, Text: r.Text, Count: r.N} } } for _, q := range questions { a := answeredBy[q.ID] st := QuestionStat{ID: q.ID, Text: q.Text, Type: q.Type, Answered: a.Answered, Correct: a.Correct} if a.Answered > 0 { pct := math.Round(float64(a.Correct)*1000/float64(a.Answered)) / 10 st.CorrectPercent = &pct } switch q.Type { case models.TypeSelfCheck: case models.TypeOpen: st.Unreviewed = a.Unreviewed if a.AveragePoints != nil { avg := math.Round(*a.AveragePoints*100) / 100 st.AveragePoints = &avg } default: st.MostChosenWrong = wrongBy[q.ID] } out.Unreviewed += st.Unreviewed out.Questions = append(out.Questions, st) } return out } // ManagedAnswerInput is one answer of a question write. An ID keeps and // updates that answer of the question; no ID creates one. Explanation nil // keeps a kept answer's explanation, "" clears it. type ManagedAnswerInput struct { ID *uint `json:"id"` Text string `json:"text"` IsCorrect bool `json:"is_correct"` Explanation *string `json:"explanation"` } // QuestionInput is the fields of a question create or partial update; nil // means not sent. Answers, when sent, is the question's full new answer // list in order. An empty string clears category, answer_text, // explanation and rubric; a max_length of 0 clears it (the default 2000 // applies). type QuestionInput 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"` SortOrder *int `json:"sort_order"` Rubric *string `json:"rubric"` MaxLength *int `json:"max_length"` Answers *[]ManagedAnswerInput `json:"answers"` } // MergeQuestion applies the sent fields of in, and its answers list when // sent, to current (a zero Question for a create) and returns the result // in the importer's shape, for CheckQuestion. An answer id that is not an // answer of current, or is listed twice, is a problem. func MergeQuestion(current models.Question, in QuestionInput) (ImportQuestion, []string) { q := ImportQuestion{ Text: current.Text, Type: current.Type, Category: current.Category, AnswerText: current.AnswerText, Explanation: current.Explanation, Rubric: current.Rubric, MaxLength: current.MaxLength, } points := current.Points if current.ID == 0 { points = 1 } if in.Text != nil { q.Text = strings.TrimSpace(*in.Text) } if in.Type != nil { q.Type = *in.Type } if in.Category != nil { q.Category = nilIfBlank(*in.Category, true) } if in.AnswerText != nil { q.AnswerText = nilIfBlank(*in.AnswerText, false) } if in.Explanation != nil { q.Explanation = nilIfBlank(*in.Explanation, false) } if in.Points != nil { points = *in.Points } if in.Rubric != nil { q.Rubric = nilIfBlank(*in.Rubric, false) } if in.MaxLength != nil { if *in.MaxLength == 0 { q.MaxLength = nil } else { n := *in.MaxLength q.MaxLength = &n } } q.Points = &points var problems []string if in.Category != nil && utf8.RuneCountInString(strings.TrimSpace(*in.Category)) > 255 { problems = append(problems, "category must be at most 255 characters") } if in.Answers == nil { for _, a := range current.Answers { q.Answers = append(q.Answers, ImportAnswer{Text: a.Text, IsCorrect: a.IsCorrect, Explanation: a.Explanation}) } return q, problems } byID := map[uint]models.Answer{} for _, a := range current.Answers { byID[a.ID] = a } seen := map[uint]bool{} q.Answers = make([]ImportAnswer, 0, len(*in.Answers)) for i, a := range *in.Answers { out := ImportAnswer{Text: strings.TrimSpace(a.Text), IsCorrect: a.IsCorrect, Explanation: a.Explanation} if a.ID != nil { kept, ok := byID[*a.ID] switch { case !ok: problems = append(problems, fmt.Sprintf("answers[%d].id does not belong to this question", i)) case seen[*a.ID]: problems = append(problems, fmt.Sprintf("answers[%d].id is listed twice", i)) case a.Explanation == nil: out.Explanation = kept.Explanation } seen[*a.ID] = true } if out.Explanation != nil { out.Explanation = nilIfBlank(*out.Explanation, false) } q.Answers = append(q.Answers, out) } return q, problems } // CheckNewQuestion is the rule check of a create, before any database // access: text and type are required, answers carry no id, and the merged // question passes CheckQuestion. func CheckNewQuestion(in QuestionInput) []string { var problems []string if in.Text == nil { problems = append(problems, "text is required") } if in.Type == nil { problems = append(problems, "type is required") } if len(problems) > 0 { return problems } merged, problems := MergeQuestion(models.Question{}, in) if in.SortOrder != nil && *in.SortOrder < 1 { problems = append(problems, "sort_order must be at least 1") } return append(problems, CheckQuestion("question", merged)...) } // managedQuizID loads the id of the quiz with this slug, or ErrNotFound. func managedQuizID(tx *gorm.DB, slug string) (uint, error) { var quiz models.Quiz if err := lockedQuiz(tx, slug, &quiz); err != nil { return 0, err } return quiz.ID, nil } // CreateQuestion adds a question with its answers to the quiz with this // slug: at sort_order when given, else after the last question. Points // default to 1; answers are ordered as sent. A missing slug is ErrNotFound. func CreateQuestion(ctx context.Context, db *gorm.DB, slug string, in QuestionInput) (*models.Question, error) { if err := invalid(CheckNewQuestion(in)); err != nil { return nil, err } merged, _ := MergeQuestion(models.Question{}, in) var q models.Question err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) quizID, err := managedQuizID(tx, slug) if err != nil { return err } order := 0 if in.SortOrder != nil { order = *in.SortOrder } else if err := tx.Model(&models.Question{}).Where("quiz_id = ?", quizID). Select("COALESCE(MAX(sort_order), 0) + 1").Scan(&order).Error; err != nil { return err } q = models.Question{ QuizID: quizID, Text: merged.Text, Type: merged.Type, Category: merged.Category, AnswerText: merged.AnswerText, Explanation: merged.Explanation, SortOrder: order, Points: *merged.Points, Rubric: merged.Rubric, MaxLength: merged.MaxLength, } if err := tx.Omit(clause.Associations).Create(&q).Error; err != nil { return err } for i, a := range merged.Answers { ans := models.Answer{QuestionID: q.ID, Text: a.Text, IsCorrect: a.IsCorrect, Explanation: a.Explanation, SortOrder: i + 1} if err := tx.Omit(clause.Associations).Create(&ans).Error; err != nil { return err } q.Answers = append(q.Answers, ans) } return nil }) if err != nil { return nil, err } return &q, nil } // UpdateQuestion changes only the sent fields of one question of the quiz // with this slug. When answers is sent it is the full new list: answers // with an id are updated in place (so the answer ids stored in past // attempts stay valid), answers without one are created, answers missing // from the list are deleted, and the order is the list order. The merged // question must pass CheckQuestion. Stored attempt answers and scores are // never touched (D-16). A missing quiz is ErrNotFound, a missing question // ErrQuestionNotFound. func UpdateQuestion(ctx context.Context, db *gorm.DB, slug string, id uint, in QuestionInput) (*models.Question, error) { if in.SortOrder != nil && *in.SortOrder < 1 { return nil, invalid([]string{"sort_order must be at least 1"}) } var q models.Question err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) quizID, err := managedQuizID(tx, slug) if err != nil { return err } err = tx.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}). Where("id = ? AND quiz_id = ?", id, quizID).Take(&q).Error if errors.Is(err, gorm.ErrRecordNotFound) { return ErrQuestionNotFound } if err != nil { return err } if err := tx.Where("question_id = ?", q.ID).Order("sort_order ASC, id ASC").Find(&q.Answers).Error; err != nil { return err } merged, problems := MergeQuestion(q, in) if err := invalid(append(problems, CheckQuestion("question", merged)...)); err != nil { return err } q.Text, q.Type, q.Category = merged.Text, merged.Type, merged.Category q.AnswerText, q.Explanation, q.Points = merged.AnswerText, merged.Explanation, *merged.Points q.Rubric, q.MaxLength = merged.Rubric, merged.MaxLength if in.SortOrder != nil { q.SortOrder = *in.SortOrder } if err := tx.Omit(clause.Associations).Save(&q).Error; err != nil { return err } if in.Answers == nil { return nil } current := map[uint]models.Answer{} for _, a := range q.Answers { current[a.ID] = a } answers := make([]models.Answer, 0, len(merged.Answers)) keep := map[uint]bool{} for i, a := range merged.Answers { ans := models.Answer{QuestionID: q.ID} if sent := (*in.Answers)[i]; sent.ID != nil { ans = current[*sent.ID] keep[ans.ID] = true } ans.Text, ans.IsCorrect, ans.Explanation, ans.SortOrder = a.Text, a.IsCorrect, a.Explanation, i+1 if err := tx.Omit(clause.Associations).Save(&ans).Error; err != nil { return err } answers = append(answers, ans) } for _, a := range q.Answers { if !keep[a.ID] { if err := tx.Where("id = ?", a.ID).Delete(&models.Answer{}).Error; err != nil { return err } } } q.Answers = answers return nil }) if err != nil { return nil, err } return &q, nil } // DeleteQuestion removes one question of the quiz with this slug. The // foreign keys cascade to its answers and the attempt answers given to // it; finished attempts keep their stored scores (D-16). func DeleteQuestion(ctx context.Context, db *gorm.DB, slug string, id uint) error { return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) quizID, err := managedQuizID(tx, slug) if err != nil { return err } res := tx.Where("id = ? AND quiz_id = ?", id, quizID).Delete(&models.Question{}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return ErrQuestionNotFound } return nil }) } // CheckQuestionOrder is the check of a reorder list before any database // access: not empty, no id twice. func CheckQuestionOrder(ids []uint) []string { seen := map[uint]bool{} for _, id := range ids { if seen[id] { return []string{"question_ids must list every question of the quiz exactly once"} } seen[id] = true } if len(ids) == 0 { return []string{"question_ids must list every question of the quiz exactly once"} } return nil } // ReorderQuestions sets sort_order = index + 1 for ids, which must be // exactly the quiz's question ids, and returns the managed quiz. func ReorderQuestions(ctx context.Context, db *gorm.DB, slug string, ids []uint) (*models.Quiz, error) { if err := invalid(CheckQuestionOrder(ids)); err != nil { return nil, err } err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) quizID, err := managedQuizID(tx, slug) if err != nil { return err } var have []uint if err := tx.Model(&models.Question{}).Where("quiz_id = ?", quizID).Pluck("id", &have).Error; err != nil { return err } sorted := slices.Clone(ids) slices.Sort(sorted) slices.Sort(have) if !slices.Equal(sorted, have) { return invalid([]string{"question_ids must list every question of the quiz exactly once"}) } now := time.Now().UTC() for i, id := range ids { if err := tx.Model(&models.Question{}).Where("id = ?", id). Updates(map[string]any{"sort_order": i + 1, "updated_at": now}).Error; err != nil { return err } } return nil }) if err != nil { return nil, err } return ManagedQuiz(ctx, db, slug) }