package classes import ( "context" "errors" "math/rand/v2" "slices" "time" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" "gorm.io/gorm/clause" ) // Errors the service returns for the API to map onto status codes. var ( // ErrNotFound is a missing quiz or an attempt the caller does not own. ErrNotFound = errors.New("quizzes: not found") // ErrCompleted is a write to a finished attempt. ErrCompleted = errors.New("quizzes: attempt already completed") // ErrTimeLimit is an answer after the quiz's time limit ran out (plus // the grace of a test). ErrTimeLimit = errors.New("quizzes: time limit exceeded") // ErrAttemptLimit is a test start after the user used every attempt. ErrAttemptLimit = errors.New("quizzes: attempt limit reached") // ErrAnswerLocked is a changed answer in a one_way test. ErrAnswerLocked = errors.New("quizzes: answer locked") ) // FieldError is an input error on one request field (a 422). type FieldError struct { Field string // Key is the golem15.quizzes::lang.api.* phrase of the message. Key string } func (e *FieldError) Error() string { return "quizzes: invalid " + e.Field + ": " + e.Key } // Service runs the quiz API on a database. Now defaults to time.Now. type Service struct { DB *gorm.DB Scoring ScoringConfig Now func() time.Time // Shuffle reorders questions of a quiz with shuffle_questions; it // defaults to math/rand. Shuffle func(n int, swap func(i, j int)) // Grace is how long after a test's deadline an answer is still // accepted; it never applies to learning quizzes. See TestGraceFrom. Grace time.Duration } func (s *Service) now() time.Time { if s.Now != nil { return s.Now() } return time.Now() } func (s *Service) db(ctx context.Context) *gorm.DB { return s.DB.WithContext(ctx) } // QuizSummary is one row of the quiz list with the caller's statistics. type QuizSummary struct { Quiz models.Quiz QuestionCount int BestScorePercent *float64 AttemptsCount int // QuestionTypes counts the questions per type; every models.QuestionTypes // key is present. QuestionTypes map[string]int // AttemptsUsed counts every attempt of the user, open or completed. AttemptsUsed int } // ListQuizzes returns the published quizzes, optionally only those whose // context equals quizContext, ordered by title. AttemptsCount counts the // user's completed attempts, AttemptsUsed every attempt, and // BestScorePercent is their best result. func (s *Service) ListQuizzes(ctx context.Context, userID uint, quizContext string) ([]QuizSummary, error) { q := s.db(ctx).Where("is_published = ?", true) if quizContext != "" { q = q.Where("context = ?", quizContext) } var quizzes []models.Quiz if err := q.Order("title ASC, id ASC").Find(&quizzes).Error; err != nil { return nil, err } out := make([]QuizSummary, 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 Type string N int } var counts []countRow if err := s.db(ctx).Model(&models.Question{}).Select("quiz_id, type, COUNT(*) AS n"). Where("quiz_id IN ?", ids).Group("quiz_id, type").Scan(&counts).Error; err != nil { return nil, err } type statRow struct { QuizID uint N int Used int Best *float64 } var stats []statRow if err := s.db(ctx).Model(&models.Attempt{}). Select("quiz_id, COUNT(*) FILTER (WHERE completed_at IS NOT NULL) AS n, COUNT(*) AS used, "+ "MAX(score_percent) FILTER (WHERE completed_at IS NOT NULL) AS best"). Where("quiz_id IN ? AND user_id = ?", ids, userID). Group("quiz_id").Scan(&stats).Error; err != nil { return nil, err } typesBy := map[uint]map[string]int{} for _, c := range counts { if typesBy[c.QuizID] == nil { typesBy[c.QuizID] = map[string]int{} } typesBy[c.QuizID][c.Type] += c.N } statBy := map[uint]statRow{} for _, st := range stats { statBy[st.QuizID] = st } for _, quiz := range quizzes { st := statBy[quiz.ID] types := make(map[string]int, len(models.QuestionTypes)) for _, typ := range models.QuestionTypes { types[typ] = 0 } total := 0 for typ, n := range typesBy[quiz.ID] { types[typ] += n total += n } out = append(out, QuizSummary{ Quiz: quiz, QuestionCount: total, BestScorePercent: st.Best, AttemptsCount: st.N, QuestionTypes: types, AttemptsUsed: st.Used, }) } return out, nil } // QuizDetail is a published quiz with its questions and answers. type QuizDetail struct { Quiz models.Quiz Questions []models.Question Categories []string } // GetQuiz loads a published quiz by slug with its questions in sort order // (shuffled when the quiz says so) and each question's answers. A non-empty // category keeps only that category's questions; a test ignores it, as it // always covers the whole quiz. Categories lists every category of the quiz. func (s *Service) GetQuiz(ctx context.Context, slug, category string) (*QuizDetail, error) { quiz, err := s.publishedQuiz(ctx, slug) if err != nil { return nil, err } if IsTest(quiz) { category = "" } questions, err := s.questions(ctx, quiz.ID, category) if err != nil { return nil, err } if quiz.ShuffleQuestions { shuffle := s.Shuffle if shuffle == nil { shuffle = rand.Shuffle } shuffle(len(questions), func(i, j int) { questions[i], questions[j] = questions[j], questions[i] }) } cats, err := s.categories(ctx, quiz.ID) if err != nil { return nil, err } return &QuizDetail{Quiz: *quiz, Questions: questions, Categories: cats}, nil } func (s *Service) publishedQuiz(ctx context.Context, slug string) (*models.Quiz, error) { var quiz models.Quiz err := s.db(ctx).Where("slug = ? AND is_published = ?", slug, true).Take(&quiz).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } if err != nil { return nil, err } return &quiz, nil } func (s *Service) questions(ctx context.Context, quizID uint, category string) ([]models.Question, error) { q := s.db(ctx).Where("quiz_id = ?", quizID) if category != "" { q = q.Where("category = ?", category) } var questions []models.Question err := q.Preload("Answers", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order ASC, id ASC") }). Order("sort_order ASC, id ASC").Find(&questions).Error return questions, err } func (s *Service) categories(ctx context.Context, quizID uint) ([]string, error) { cats := []string{} err := s.db(ctx).Model(&models.Question{}). Where("quiz_id = ? AND category IS NOT NULL AND category <> ''", quizID). Distinct("category").Order("category").Pluck("category", &cats).Error return cats, err } // Started is the attempt POST /quizzes/{slug}/attempts answers with. type Started struct { Attempt *models.Attempt Quiz *models.Quiz // Resumed is true when an open test attempt was returned instead of a // new one. Resumed bool // AttemptsUsed counts the user's attempts on the quiz, this one // included; it is only counted for tests. AttemptsUsed int // AnsweredCount is the number of questions a resumed attempt answered. AnsweredCount int // OpenAnswers and Unreviewed count the open answers of a resumed // attempt, as in AttemptSummary. OpenAnswers int Unreviewed int } // StartAttempt starts an attempt at a published quiz for the user, like // Start without the resume and limit details. func (s *Service) StartAttempt(ctx context.Context, userID uint, slug, category string) (*models.Attempt, *models.Quiz, error) { st, err := s.Start(ctx, userID, slug, category) if err != nil { return nil, nil, err } return st.Attempt, st.Quiz, nil } // Start starts an attempt at a published quiz for the user. // // For a learning quiz a non-empty category limits the attempt to that // category's questions; a category without questions is a FieldError on // category. Every call creates a new attempt. // // A test always covers the whole quiz, so a category is a FieldError // (category_not_allowed). Under an advisory lock per (quiz, user) it closes // the user's open attempts past their deadline and grace at the deadline, // resumes a remaining open attempt (Resumed) and otherwise refuses with // ErrAttemptLimit once the user's attempts, open or completed and in any // mode, reach max_attempts. func (s *Service) Start(ctx context.Context, userID uint, slug, category string) (*Started, error) { quiz, err := s.publishedQuiz(ctx, slug) if err != nil { return nil, err } if IsTest(quiz) { if category != "" { return nil, &FieldError{Field: "category", Key: "category_not_allowed"} } return s.startTest(ctx, userID, quiz) } attempt := &models.Attempt{QuizID: quiz.ID, UserID: userID, StartedAt: s.now().UTC()} if category != "" { var n int64 if err := s.db(ctx).Model(&models.Question{}).Where("quiz_id = ? AND category = ?", quiz.ID, category).Count(&n).Error; err != nil { return nil, err } if n == 0 { return nil, &FieldError{Field: "category", Key: "unknown_category"} } attempt.Category = &category } maxPoints, err := s.maxPoints(ctx, s.DB, attempt) if err != nil { return nil, err } attempt.MaxPoints = maxPoints if err := s.db(ctx).Create(attempt).Error; err != nil { return nil, err } return &Started{Attempt: attempt, Quiz: quiz}, nil } func (s *Service) startTest(ctx context.Context, userID uint, quiz *models.Quiz) (*Started, error) { var st *Started // limited refuses the start after the commit, so the expired attempts // closed on the way stay closed (an error would roll the closes back). var limited bool err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { tx = tx.WithContext(ctx) if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, AttemptLockKey(quiz.ID, userID)).Error; err != nil { return err } var open []models.Attempt if err := tx.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}). Where("quiz_id = ? AND user_id = ? AND completed_at IS NULL", quiz.ID, userID). Order("started_at ASC, id ASC").Find(&open).Error; err != nil { return err } now := s.now() var resume *models.Attempt 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 } continue } // An open attempt left from the quiz's learning days may cover // one category; a test only resumes a whole-quiz attempt. if resume == nil && a.Category == nil { resume = a } } var used int64 if err := tx.Model(&models.Attempt{}).Where("quiz_id = ? AND user_id = ?", quiz.ID, userID).Count(&used).Error; err != nil { return err } if resume != nil { counts, err := answerCounts(ctx, tx, []uint{resume.ID}) if err != nil { return err } c := counts[resume.ID] st = &Started{ Attempt: resume, Quiz: quiz, Resumed: true, AttemptsUsed: int(used), AnsweredCount: c.N, OpenAnswers: c.OpenN, Unreviewed: c.UnreviewedN, } return nil } if left := AttemptsLeft(quiz, int(used)); left != nil && *left == 0 { limited = true return nil } attempt := &models.Attempt{QuizID: quiz.ID, UserID: userID, StartedAt: now.UTC()} maxPoints, err := s.maxPoints(ctx, tx, attempt) if err != nil { return err } attempt.MaxPoints = maxPoints if err := tx.Create(attempt).Error; err != nil { return err } st = &Started{Attempt: attempt, Quiz: quiz, AttemptsUsed: int(used) + 1} return nil }) if err != nil { return nil, err } if limited { return nil, ErrAttemptLimit } return st, nil } // AttemptsUsed counts the user's attempts on the quiz, open or completed. func (s *Service) AttemptsUsed(ctx context.Context, quizID, userID uint) (int, error) { var n int64 err := s.db(ctx).Model(&models.Attempt{}).Where("quiz_id = ? AND user_id = ?", quizID, userID).Count(&n).Error return int(n), err } func (s *Service) maxPoints(ctx context.Context, db *gorm.DB, a *models.Attempt) (int, error) { q := db.WithContext(ctx).Model(&models.Question{}).Where("quiz_id = ?", a.QuizID) if a.Category != nil { q = q.Where("category = ?", *a.Category) } var total int err := q.Select("COALESCE(SUM(GREATEST(points, 0)), 0)").Scan(&total).Error return total, err } // ownedAttempt loads the user's attempt with its quiz. Another user's // attempt is ErrNotFound, never a 403. func (s *Service) ownedAttempt(ctx context.Context, tx *gorm.DB, userID, attemptID uint, lock bool) (*models.Attempt, error) { var attempt models.Attempt q := tx.WithContext(ctx).Preload("Quiz").Where("id = ? AND user_id = ?", attemptID, userID) if lock { q = q.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}) } err := q.Take(&attempt).Error if errors.Is(err, gorm.ErrRecordNotFound) || (err == nil && attempt.Quiz == nil) { return nil, ErrNotFound } if err != nil { return nil, err } return &attempt, nil } // Deadline is when an attempt's time runs out, or nil without a limit. func Deadline(a *models.Attempt) *time.Time { if a == nil || a.Quiz == nil || a.Quiz.TimeLimitSeconds == nil || *a.Quiz.TimeLimitSeconds <= 0 { return nil } d := a.StartedAt.Add(time.Duration(*a.Quiz.TimeLimitSeconds) * time.Second) return &d } // AnswerInput is one POST /attempts/{id}/answers body. type AnswerInput struct { QuestionID uint AnswerIDs []uint Known *bool // Text is the answer to an open question. Text *string } // Feedback is what the user learns after answering one question. type Feedback struct { Question models.Question Result ScoreResult CorrectAnswerIDs []uint // Withheld is set for an open test attempt: the solutions stay hidden // and the serializer must drop Result, CorrectAnswerIDs and every // solution field of Question. Withheld bool // Answer is the stored row of an open question, with its text and // grading state; it is nil for every other type. Answer *models.AttemptAnswer } // Answer records or overwrites the user's answer to one question of an // open attempt and returns the feedback. It checks, in order: the attempt // belongs to the user (ErrNotFound), is not finished (ErrCompleted), is // within the time limit, plus the grace of a test (ErrTimeLimit), the // question belongs to the attempt (FieldError on question_id), a one_way // test has no answer to the question yet (ErrAnswerLocked; the attempt row // is locked, so the check is race-free), and the answer fits the question // type (FieldError on answer_ids, known or text). The feedback of an open // test attempt is Withheld. // // An open question stores its text as pending (see answerOpen) and is never // scored here: a grader or the owner scores it later. func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerInput) (*Feedback, error) { var fb *Feedback err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { attempt, err := s.ownedAttempt(ctx, tx, userID, attemptID, true) if err != nil { return err } if attempt.CompletedAt != nil { return ErrCompleted } if d := AnswerDeadline(attempt, s.Grace); d != nil && s.now().After(*d) { return ErrTimeLimit } var question models.Question q := tx.WithContext(ctx).Preload("Answers", func(db *gorm.DB) *gorm.DB { return db.Order("sort_order ASC, id ASC") }). Where("id = ? AND quiz_id = ?", in.QuestionID, attempt.QuizID) if attempt.Category != nil { q = q.Where("category = ?", *attempt.Category) } err = q.Take(&question).Error if errors.Is(err, gorm.ErrRecordNotFound) { return &FieldError{Field: "question_id", Key: "question_not_in_quiz"} } if err != nil { return err } if OneWay(attempt.Quiz) { var given int64 if err := tx.WithContext(ctx).Model(&models.AttemptAnswer{}). Where("attempt_id = ? AND question_id = ?", attempt.ID, question.ID).Count(&given).Error; err != nil { return err } if given > 0 { return ErrAnswerLocked } } row := models.AttemptAnswer{AttemptID: attempt.ID, QuestionID: question.ID} switch question.Type { case models.TypeOpen: if in.Text == nil { return &FieldError{Field: "text", Key: "text_required"} } text, err := CheckOpenText(*in.Text, question) if err != nil { return err } stored, err := s.answerOpen(ctx, tx, attempt, question, text) if err != nil { return err } fb = &Feedback{Question: question, Answer: stored, Withheld: Withheld(attempt)} return nil case models.TypeSelfCheck: if in.Known == nil { return &FieldError{Field: "known", Key: "known_required"} } known := *in.Known row.SelfCheckKnown = &known default: if in.AnswerIDs == nil { return &FieldError{Field: "answer_ids", Key: "answer_ids_required"} } ids := uniqueIDs(in.AnswerIDs) for _, id := range ids { if !slices.ContainsFunc(question.Answers, func(a models.Answer) bool { return a.ID == id }) { return &FieldError{Field: "answer_ids", Key: "answer_not_in_question"} } } if question.Type == models.TypeSingle && len(ids) > 1 { return &FieldError{Field: "answer_ids", Key: "single_one_answer"} } row.AnswerIDs = lagoon.Jsonable[[]uint]{Data: ids, Valid: true} } correct := CorrectAnswerIDs(question) res := Score(ScoreInput{ Type: question.Type, Points: question.Points, CorrectIDs: correct, ChosenIDs: row.AnswerIDs.Data, Known: row.SelfCheckKnown, }, s.Scoring) row.IsCorrect, row.Points = res.IsCorrect, res.Points now := s.now().UTC() row.CreatedAt, row.UpdatedAt = now, now if err := tx.WithContext(ctx).Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "attempt_id"}, {Name: "question_id"}}, DoUpdates: clause.AssignmentColumns([]string{"answer_ids", "self_check_known", "is_correct", "points", "updated_at"}), }).Create(&row).Error; err != nil { return err } fb = &Feedback{Question: question, Result: res, CorrectAnswerIDs: correct, Withheld: Withheld(attempt)} return nil }) if err != nil { return nil, err } return fb, nil } // answerOpen stores a cleaned open answer in a transaction that holds the // attempt row lock, so reading the existing row first is race-free. The // same text again changes nothing. Another text is a new answer (Pitfall 1): // the row goes back to pending with the next grading_seq, and every grade, // error, review and point of the old text is cleared, so a learning // re-answer replaces an earlier grade or review. A first answer creates the // row as pending with seq 1. A new or changed text is handed to // enqueueGrading. func (s *Service) answerOpen(ctx context.Context, tx *gorm.DB, attempt *models.Attempt, question models.Question, text string) (*models.AttemptAnswer, error) { tx = tx.WithContext(ctx) now := s.now().UTC() var existing models.AttemptAnswer err := tx.Where("attempt_id = ? AND question_id = ?", attempt.ID, question.ID).Take(&existing).Error switch { case err == nil: if existing.TextAnswer != nil && *existing.TextAnswer == text && existing.GradingStatus != nil { return &existing, nil } seq := existing.GradingSeq + 1 if err := tx.Model(&models.AttemptAnswer{}).Where("id = ?", existing.ID).Updates(map[string]any{ "text_answer": text, "answer_ids": nil, "self_check_known": nil, "grading_status": models.GradingPending, "grading_seq": seq, "grading_attempts": 0, "grader": nil, "grader_points": nil, "grader_feedback": nil, "grading_error": nil, "graded_at": nil, "review_comment": nil, "reviewed_at": nil, "reviewed_by_user_id": nil, "reviewed_by_admin_id": nil, "points": 0, "is_correct": false, "updated_at": now, }).Error; err != nil { return nil, err } if err := tx.Where("id = ?", existing.ID).Take(&existing).Error; err != nil { return nil, err } if err := s.enqueueGrading(ctx, tx, existing.ID, seq, attempt); err != nil { return nil, err } return &existing, nil case errors.Is(err, gorm.ErrRecordNotFound): default: return nil, err } pending := models.GradingPending row := &models.AttemptAnswer{ AttemptID: attempt.ID, QuestionID: question.ID, TextAnswer: &text, GradingStatus: &pending, GradingSeq: 1, CreatedAt: now, UpdatedAt: now, } if err := tx.Create(row).Error; err != nil { return nil, err } if err := s.enqueueGrading(ctx, tx, row.ID, row.GradingSeq, attempt); err != nil { return nil, err } return row, nil } // enqueueGrading hands a new or changed open answer to the quiz's grader. func (s *Service) enqueueGrading(ctx context.Context, tx *gorm.DB, answerID uint, seq int, attempt *models.Attempt) error { return nil } // CorrectAnswerIDs lists the IDs of a question's correct answers in sort // order; it is empty for a self_check question. func CorrectAnswerIDs(q models.Question) []uint { out := []uint{} for _, a := range q.Answers { if a.IsCorrect { out = append(out, a.ID) } } return out } // Finish completes an open attempt: it sums the recorded answers, floors // the total at 0 and stores the score and completion time. It is allowed // after the time limit, so a late finish still keeps the answers given in // time. func (s *Service) Finish(ctx context.Context, userID, attemptID uint) (*AttemptResult, error) { err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { attempt, err := s.ownedAttempt(ctx, tx, userID, attemptID, true) if err != nil { return err } if attempt.CompletedAt != nil { return ErrCompleted } return s.close(ctx, tx, attempt, s.now()) }) if err != nil { return nil, err } return s.Result(ctx, userID, attemptID) } // close completes an attempt at the given time: it sums the recorded // answers, floors the total at 0 and stores the score. Finish closes at the // current time, an expired test closes at its deadline. func (s *Service) close(ctx context.Context, tx *gorm.DB, attempt *models.Attempt, at time.Time) error { tx = tx.WithContext(ctx) var points []float64 if err := tx.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", attempt.ID).Pluck("points", &points).Error; err != nil { return err } maxPoints, err := s.maxPoints(ctx, tx, attempt) if err != nil { return err } score, percent := Totals(points, maxPoints) at = at.UTC() now := s.now().UTC() return tx.Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{ "completed_at": at, "score_points": score, "max_points": maxPoints, "score_percent": percent, "updated_at": now, }).Error } // AttemptSummary is an attempt with its quiz and answered question count. type AttemptSummary struct { Attempt models.Attempt AnsweredCount int // OpenAnswers counts the attempt's answers to open questions (rows // with a grading status). OpenAnswers int // Unreviewed counts the open answers the owner has not reviewed yet: // pending, graded or failed. Unreviewed int } // answerCountRow is the per-attempt answer counts answerCounts reads. type answerCountRow struct { AttemptID uint N int OpenN int UnreviewedN int } // answerCounts counts, per attempt, the answers, the open answers and the // open answers not reviewed yet. func answerCounts(ctx context.Context, db *gorm.DB, attemptIDs []uint) (map[uint]answerCountRow, error) { var rows []answerCountRow if err := db.WithContext(ctx).Model(&models.AttemptAnswer{}). Select("attempt_id, COUNT(*) AS n, "+ "COUNT(*) FILTER (WHERE grading_status IS NOT NULL) AS open_n, "+ "COUNT(*) FILTER (WHERE grading_status IN ('pending', 'graded', 'failed')) AS unreviewed_n"). Where("attempt_id IN ?", attemptIDs).Group("attempt_id").Scan(&rows).Error; err != nil { return nil, err } out := make(map[uint]answerCountRow, len(rows)) for _, r := range rows { out[r.AttemptID] = r } return out, nil } // unreviewed reports whether an open answer still waits for the owner. func unreviewed(a *models.AttemptAnswer) bool { if a == nil || a.GradingStatus == nil { return false } switch *a.GradingStatus { case models.GradingPending, models.GradingGraded, models.GradingFailed: return true } return false } // QuestionResult is one question of an attempt with the user's answer. type QuestionResult struct { Question models.Question // Answer is nil when the question was not answered. Answer *models.AttemptAnswer } // AttemptResult is an attempt with every question it covers. type AttemptResult struct { AttemptSummary Questions []QuestionResult } // Result loads the user's attempt with every question it covers and the // answers given. A test attempt past its deadline and grace is closed at // its deadline first. The questions of a shuffled test come in an order // seeded by the attempt, the same on every call; other attempts list them // in sort order. func (s *Service) Result(ctx context.Context, userID, attemptID uint) (*AttemptResult, error) { attempt, err := s.ownedAttempt(ctx, s.DB, userID, attemptID, false) if err != nil { return nil, err } if Expired(attempt, s.now(), s.Grace) { err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { locked, err := s.ownedAttempt(ctx, tx, userID, attemptID, true) if err != nil { return err } if !Expired(locked, s.now(), s.Grace) { return nil } return s.close(ctx, tx, locked, *Deadline(locked)) }) if err != nil { return nil, err } if attempt, err = s.ownedAttempt(ctx, s.DB, userID, attemptID, false); err != nil { return nil, err } } category := "" if attempt.Category != nil { category = *attempt.Category } questions, err := s.questions(ctx, attempt.QuizID, category) if err != nil { return nil, err } if IsTest(attempt.Quiz) && attempt.Quiz.ShuffleQuestions { rand.New(rand.NewPCG(uint64(attempt.ID), uint64(attempt.QuizID))). Shuffle(len(questions), func(i, j int) { questions[i], questions[j] = questions[j], questions[i] }) } var answers []models.AttemptAnswer if err := s.db(ctx).Where("attempt_id = ?", attempt.ID).Find(&answers).Error; err != nil { return nil, err } byQuestion := make(map[uint]*models.AttemptAnswer, len(answers)) for i := range answers { byQuestion[answers[i].QuestionID] = &answers[i] } res := &AttemptResult{AttemptSummary: AttemptSummary{Attempt: *attempt, AnsweredCount: len(answers)}} for i := range answers { if answers[i].GradingStatus != nil { res.OpenAnswers++ } if unreviewed(&answers[i]) { res.Unreviewed++ } } for _, q := range questions { res.Questions = append(res.Questions, QuestionResult{Question: q, Answer: byQuestion[q.ID]}) } return res, nil } // History lists the user's attempts, newest first, optionally of one quiz // (by slug, published or not), one page at a time. func (s *Service) History(ctx context.Context, userID uint, quizSlug string, page, perPage int) ([]AttemptSummary, int64, error) { q := s.db(ctx).Model(&models.Attempt{}).Where("golem15_quizzes_attempts.user_id = ?", userID) if quizSlug != "" { q = q.Joins("JOIN golem15_quizzes_quizzes qz ON qz.id = golem15_quizzes_attempts.quiz_id").Where("qz.slug = ?", quizSlug) } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, err } var attempts []models.Attempt if err := q.Preload("Quiz").Order("golem15_quizzes_attempts.started_at DESC, golem15_quizzes_attempts.id DESC"). Offset((page - 1) * perPage).Limit(perPage).Find(&attempts).Error; err != nil { return nil, 0, err } out := make([]AttemptSummary, 0, len(attempts)) if len(attempts) == 0 { return out, total, nil } ids := make([]uint, len(attempts)) for i, a := range attempts { ids[i] = a.ID } countBy, err := answerCounts(ctx, s.DB, ids) if err != nil { return nil, 0, err } for _, a := range attempts { c := countBy[a.ID] out = append(out, AttemptSummary{Attempt: a, AnsweredCount: c.N, OpenAnswers: c.OpenN, Unreviewed: c.UnreviewedN}) } return out, total, nil }