package classes // The owner's review queue of open answers: one service shared by the MCP // review routes (/api/v1/quiz-reviews) and the admin "Do oceny" screen. // Reviewers hold the golem15.quizzes.access_quizzes permission (D-13); the // routes and the admin controller check it, this file trusts its caller. // // Every write runs in lagoon.Transaction on s.DB, so a Service whose DB is // an outer transaction (an admin save) nests as a savepoint. A write locks // the answer's attempt first, as grading does, recalculates a completed // attempt and fires its events only after the commit (D-14). import ( "context" "errors" "math" "slices" "strconv" "strings" "time" "unicode/utf8" "git.golem15.com/golem15/sm-quizzes-plugin/events" "git.golem15.com/golem15/sm-quizzes-plugin/models" "git.golem15.com/golem15/summercms/modules/lagoon" "gorm.io/gorm" ) // Review status filters besides the grading statuses themselves. const ( // ReviewStatusOpen is every answer the owner has not reviewed yet: // pending, graded and failed. It is the default filter. ReviewStatusOpen = "open" // ReviewStatusAll lists every open answer, reviewed ones included. ReviewStatusAll = "all" ) // ReviewExcerptRunes is how much of a student's text a queue row shows. const ReviewExcerptRunes = 300 // MaxReviewComment is the longest comment to the student, in runes. const MaxReviewComment = 2000 // Errors of the review actions, answered as 409s with a code. var ( // ErrNoGraderScore is a confirm of an answer that has no grader score: // it is pending or failed, so the owner sets the score instead. ErrNoGraderScore = errors.New("quizzes: the answer has no grader score to confirm") // ErrAlreadyReviewed is a regrade of one answer the owner already // reviewed: owner decisions are final (D-12). ErrAlreadyReviewed = errors.New("quizzes: the answer is already reviewed") // ErrGraderManual is a regrade on a quiz whose grader is manual or not // registered by any activated plugin. ErrGraderManual = errors.New("quizzes: the quiz has no grader that can grade again") ) // ReviewFilter narrows the review queue. Zero values do not filter; Status // is one CheckReviewStatus accepts. type ReviewFilter struct { QuizSlug string Status string UserID uint AttemptID uint } // Reviewer is who reviews: a frontend user over MCP (UserID, the API // token's user) or a backend administrator (AdminID). A zero id is stored // as NULL (Pitfall 7: the two live in different tables). type Reviewer struct { UserID uint AdminID uint } func (r Reviewer) ids() (user, admin *uint) { if r.UserID != 0 { id := r.UserID user = &id } if r.AdminID != 0 { id := r.AdminID admin = &id } return user, admin } // ReviewScope picks the open answers of a bulk action: one answer, one // attempt or one quiz (by slug). Exactly one is set; see CheckReviewScope. type ReviewScope struct { AnswerID uint AttemptID uint QuizSlug string } // CheckReviewScope requires exactly one of the scope's keys: answer_id, // attempt_id or quiz when allowAnswer (regrade), attempt_id or quiz // otherwise (bulk confirm). func CheckReviewScope(scope ReviewScope, allowAnswer bool) []string { n := 0 if scope.AttemptID != 0 { n++ } if strings.TrimSpace(scope.QuizSlug) != "" { n++ } if scope.AnswerID != 0 { if !allowAnswer { return []string{"exactly one of attempt_id and quiz is required"} } n++ } if n == 1 { return nil } if allowAnswer { return []string{"exactly one of answer_id, attempt_id and quiz is required"} } return []string{"exactly one of attempt_id and quiz is required"} } // CheckReviewPoints reports whether points is a score the owner may give a // question worth maxPoints: finite, 0..maxPoints (a negative maxPoints // counts as 0) and a multiple of 0.5. func CheckReviewPoints(points float64, maxPoints int) bool { if math.IsNaN(points) || math.IsInf(points, 0) { return false } full := float64(max(maxPoints, 0)) return points >= 0 && points <= full && points*2 == math.Trunc(points*2) } // CheckReviewComment cleans the owner's comment to the student: trimmed, // NUL bytes dropped, and nil when nothing is left. More than // MaxReviewComment runes is a problem. func CheckReviewComment(comment *string) (*string, []string) { if comment == nil { return nil, nil } c := strings.TrimSpace(strings.ReplaceAll(*comment, "\x00", "")) if c == "" { return nil, nil } if utf8.RuneCountInString(c) > MaxReviewComment { return nil, []string{"comment must be at most " + strconv.Itoa(MaxReviewComment) + " characters"} } return &c, nil } // unreviewedStatuses are the grading statuses the owner still has to act // on. func unreviewedStatuses() []string { return []string{models.GradingPending, models.GradingGraded, models.GradingFailed} } // CheckReviewStatus maps a status filter to the grading statuses to list: // "" and open give pending, graded and failed, a grading status gives // itself and all gives nil (no filter). Anything else is a problem naming // the valid values. func CheckReviewStatus(status string) ([]string, []string) { status = strings.TrimSpace(status) switch status { case "", ReviewStatusOpen: return unreviewedStatuses(), nil case ReviewStatusAll: return nil, nil } if slices.Contains(models.GradingStatuses, status) { return []string{status}, nil } return nil, []string{"status must be one of open, pending, graded, failed, reviewed, all"} } // ReviewRow is one open answer of the review queue. In a queue row Text is // cut to ReviewExcerptRunes+1 runes, enough to tell that it was cut; the // detail carries the whole text. type ReviewRow struct { AnswerID uint Status string QuizID uint QuizSlug string QuizTitle string QuizMode string QuestionID uint QuestionText string QuestionPoints int AttemptID uint AttemptCompletedAt *time.Time UserID uint Email string Name *string Surname *string Text string GraderPoints *float64 Points float64 GradedAt *time.Time ReviewedAt *time.Time UpdatedAt time.Time } // ReviewDetail is one open answer with everything the owner reviews it // against: the model answer and the rubric (owner-only), the grader's // result or error and the review so far. MaxLength is the question's // effective limit. type ReviewDetail struct { ReviewRow AnswerText *string Rubric *string MaxLength int Grader *string GraderFeedback *string GradingError *string ReviewComment *string GradingAttempts int IsCorrect bool ReviewedByUserID *uint ReviewedByAdminID *uint } // reviewFromSQL joins an open answer to its question, attempt, quiz and // student. Only open answers (a grading status) are in the queue. const reviewFromSQL = ` FROM golem15_quizzes_attempt_answers aa JOIN golem15_quizzes_questions q ON q.id = aa.question_id JOIN golem15_quizzes_attempts a ON a.id = aa.attempt_id JOIN golem15_quizzes_quizzes qz ON qz.id = a.quiz_id JOIN users u ON u.id = a.user_id WHERE aa.grading_status IS NOT NULL` // reviewRowColumnsSQL are the columns of a ReviewRow but the text. const reviewRowColumnsSQL = `aa.id AS answer_id, aa.grading_status AS status, qz.id AS quiz_id, qz.slug AS quiz_slug, qz.title AS quiz_title, qz.mode AS quiz_mode, q.id AS question_id, q.text AS question_text, q.points AS question_points, a.id AS attempt_id, a.completed_at AS attempt_completed_at, a.user_id AS user_id, COALESCE(u.email, '') AS email, u.name AS name, u.surname AS surname, aa.grader_points AS grader_points, aa.points AS points, aa.graded_at AS graded_at, aa.reviewed_at AS reviewed_at, aa.updated_at AS updated_at` // reviewQueueSQL selects the queue rows; the filter predicates, the order // and the page are appended as bound parameters. const reviewQueueSQL = `SELECT ` + reviewRowColumnsSQL + `, LEFT(COALESCE(aa.text_answer, ''), 301) AS text` + reviewFromSQL // reviewDetailSQL selects one open answer by id with the detail columns. const reviewDetailSQL = `SELECT ` + reviewRowColumnsSQL + `, COALESCE(aa.text_answer, '') AS text, q.answer_text AS answer_text, q.rubric AS rubric, q.max_length AS max_length, aa.grader AS grader, aa.grader_feedback AS grader_feedback, aa.grading_error AS grading_error, aa.review_comment AS review_comment, aa.grading_attempts AS grading_attempts, aa.is_correct AS is_correct, aa.reviewed_by_user_id AS reviewed_by_user_id, aa.reviewed_by_admin_id AS reviewed_by_admin_id` + reviewFromSQL + ` AND aa.id = ?` // reviewScan is what the review queries scan into. type reviewScan struct { ReviewRow AnswerText *string Rubric *string MaxLength *int Grader *string GraderFeedback *string GradingError *string ReviewComment *string GradingAttempts int IsCorrect bool ReviewedByUserID *uint ReviewedByAdminID *uint } func (r reviewScan) detail() *ReviewDetail { maxLength := models.DefaultMaxLength if r.MaxLength != nil { maxLength = *r.MaxLength } return &ReviewDetail{ ReviewRow: r.ReviewRow, AnswerText: r.AnswerText, Rubric: r.Rubric, MaxLength: maxLength, Grader: r.Grader, GraderFeedback: r.GraderFeedback, GradingError: r.GradingError, ReviewComment: r.ReviewComment, GradingAttempts: r.GradingAttempts, IsCorrect: r.IsCorrect, ReviewedByUserID: r.ReviewedByUserID, ReviewedByAdminID: r.ReviewedByAdminID, } } // reviewWhere turns a filter into predicates appended to reviewFromSQL. func reviewWhere(f ReviewFilter, statuses []string) (string, []any) { var b strings.Builder var args []any if statuses != nil { b.WriteString(" AND aa.grading_status IN ?") args = append(args, statuses) } if slug := strings.TrimSpace(f.QuizSlug); slug != "" { b.WriteString(" AND qz.slug = ?") args = append(args, slug) } if f.UserID != 0 { b.WriteString(" AND a.user_id = ?") args = append(args, f.UserID) } if f.AttemptID != 0 { b.WriteString(" AND aa.attempt_id = ?") args = append(args, f.AttemptID) } return b.String(), args } // ReviewQueue lists the open answers matching f, oldest change first, one // page at a time (perPage 1..100, default 20), with the total count. An // invalid status is a *ValidationError. func (s *Service) ReviewQueue(ctx context.Context, f ReviewFilter, page, perPage int) ([]ReviewRow, int64, error) { statuses, problems := CheckReviewStatus(f.Status) if len(problems) > 0 { return nil, 0, &ValidationError{Problems: problems} } if page < 1 { page = 1 } if perPage < 1 { perPage = 20 } perPage = min(perPage, 100) where, args := reviewWhere(f, statuses) var total int64 if err := s.db(ctx).Raw("SELECT COUNT(*)"+reviewFromSQL+where, args...).Scan(&total).Error; err != nil { return nil, 0, err } var scans []reviewScan q := reviewQueueSQL + where + " ORDER BY aa.updated_at ASC, aa.id ASC LIMIT ? OFFSET ?" if err := s.db(ctx).Raw(q, append(args, perPage, (page-1)*perPage)...).Scan(&scans).Error; err != nil { return nil, 0, err } out := make([]ReviewRow, 0, len(scans)) for _, sc := range scans { out = append(out, sc.ReviewRow) } return out, total, nil } // ReviewItem loads one open answer with its detail. A missing answer, or // one that is not an open answer, is ErrNotFound. func (s *Service) ReviewItem(ctx context.Context, answerID uint) (*ReviewDetail, error) { return reviewItem(ctx, s.db(ctx), answerID) } func reviewItem(ctx context.Context, db *gorm.DB, answerID uint) (*ReviewDetail, error) { var scans []reviewScan if err := db.WithContext(ctx).Raw(reviewDetailSQL, answerID).Scan(&scans).Error; err != nil { return nil, err } if len(scans) == 0 { return nil, ErrNotFound } return scans[0].detail(), nil } // reviewTarget is an open answer loaded for a review write, with its // question and its attempt (quiz set); the attempt row is locked. type reviewTarget struct { Answer models.AttemptAnswer Question models.Question Attempt *models.Attempt } // lockReviewTarget locks the attempt of an open answer and reads the // answer again under the lock, so a grading job or a re-answer cannot // change it in between. A missing answer or attempt, or an answer that is // not open, is ErrNotFound. func lockReviewTarget(ctx context.Context, tx *gorm.DB, answerID uint) (*reviewTarget, error) { tx = tx.WithContext(ctx) var row models.AttemptAnswer if err := tx.Where("id = ? AND grading_status IS NOT NULL", answerID).Take(&row).Error; err != nil { return nil, notFound(err) } attempt, err := lockAttempt(ctx, tx, row.AttemptID) if err != nil { return nil, notFound(err) } if err := tx.Where("id = ? AND grading_status IS NOT NULL", answerID).Take(&row).Error; err != nil { return nil, notFound(err) } var quiz models.Quiz if err := tx.Where("id = ?", attempt.QuizID).Take(&quiz).Error; err != nil { return nil, notFound(err) } attempt.Quiz = &quiz var question models.Question if err := tx.Where("id = ?", row.QuestionID).Take(&question).Error; err != nil { return nil, notFound(err) } return &reviewTarget{Answer: row, Question: question, Attempt: attempt}, nil } // notFound maps gorm.ErrRecordNotFound to ErrNotFound. func notFound(err error) error { if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNotFound } return err } // Confirm makes the grader's score of a graded answer final: status // reviewed, points = grader_points (D-12). A reviewed answer is returned // unchanged, without a write or an event; a pending or failed answer has // no score to confirm (ErrNoGraderScore). A completed attempt is // recalculated, and AnswerReviewedEvent, plus AttemptReviewedEvent when // the attempt is now fully reviewed, fire after the commit. func (s *Service) Confirm(ctx context.Context, answerID uint, by Reviewer) (*ReviewDetail, error) { var detail *ReviewDetail err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { t, err := lockReviewTarget(ctx, tx, answerID) if err != nil { return err } if done, err := s.confirmTarget(ctx, tx, t, by); err != nil { return err } else if done { if err := s.recalcAttempt(ctx, tx, t.Attempt.ID); err != nil { return err } if err := s.fireAttemptReviewedIfDone(ctx, tx, t.Attempt.ID); err != nil { return err } } detail, err = reviewItem(ctx, tx, answerID) return err }) if err != nil { return nil, err } return detail, nil } // confirmTarget confirms one locked answer and reports whether it wrote: // false for a reviewed answer, ErrNoGraderScore without a grader score. // The caller recalculates the attempt. func (s *Service) confirmTarget(ctx context.Context, tx *gorm.DB, t *reviewTarget, by Reviewer) (bool, error) { status := "" if t.Answer.GradingStatus != nil { status = *t.Answer.GradingStatus } switch { case status == models.GradingReviewed: return false, nil case status != models.GradingGraded || t.Answer.GraderPoints == nil: return false, ErrNoGraderScore } return true, s.storeReview(ctx, tx, t, *t.Answer.GraderPoints, t.Answer.ReviewComment, false, by) } // storeReview writes the owner's decision on one locked open answer and // registers its AnswerReviewedEvent. setComment replaces review_comment // with comment (nil clears it); otherwise the comment stays. grader_points // and grader_feedback stay for the record. func (s *Service) storeReview(ctx context.Context, tx *gorm.DB, t *reviewTarget, points float64, comment *string, setComment bool, by Reviewer) error { now := s.now().UTC() user, admin := by.ids() cols := map[string]any{ "grading_status": models.GradingReviewed, "points": points, "is_correct": OpenCorrect(points, t.Question.Points), "reviewed_at": now, "reviewed_by_user_id": user, "reviewed_by_admin_id": admin, "updated_at": now, } if setComment { cols["review_comment"] = comment } else { comment = t.Answer.ReviewComment } if err := tx.WithContext(ctx).Model(&models.AttemptAnswer{}).Where("id = ?", t.Answer.ID).Updates(cols).Error; err != nil { return err } fireAfterCommit(ctx, s, tx, events.AnswerReviewedEventName, &events.AnswerReviewedEvent{ QuizID: t.Attempt.QuizID, QuestionID: t.Question.ID, AttemptID: t.Attempt.ID, AnswerID: t.Answer.ID, UserID: t.Attempt.UserID, Points: points, MaxPoints: t.Question.Points, GraderPoints: t.Answer.GraderPoints, Comment: comment, ReviewerUserID: user, ReviewerAdminID: admin, }) return nil } // fireAttemptReviewedIfDone registers AttemptReviewedEvent for a completed // attempt that has at least one open answer and none left pending, graded // or failed, with the attempt's stored score; passed compares its percent // with the quiz's passing score. Call it after the attempt is // recalculated, inside the transaction that holds its lock. Without an // event bus it reads nothing. func (s *Service) fireAttemptReviewedIfDone(ctx context.Context, tx *gorm.DB, attemptID uint) error { if s == nil || s.Events == nil { return nil } tx = tx.WithContext(ctx) var a models.Attempt if err := tx.Preload("Quiz").Where("id = ?", attemptID).Take(&a).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil } return err } if a.CompletedAt == nil || a.Quiz == nil { return nil } counts, err := answerCounts(ctx, tx, []uint{attemptID}) if err != nil { return err } if c := counts[attemptID]; c.OpenN == 0 || c.UnreviewedN > 0 { return nil } fireAfterCommit(ctx, s, tx, events.AttemptReviewedEventName, &events.AttemptReviewedEvent{ QuizID: a.QuizID, AttemptID: a.ID, UserID: a.UserID, ScorePoints: a.ScorePoints, MaxPoints: a.MaxPoints, ScorePercent: a.ScorePercent, Passed: a.ScorePercent != nil && *a.ScorePercent >= float64(a.Quiz.PassingScore), }) return nil } // SetScore gives one open answer the owner's final score, on any status // (D-12): status reviewed, points as given, is_correct for full marks, and // the comment to the student replacing any earlier one (nil or blank // clears it). grader_points and grader_feedback stay for the record. // Points outside 0..question points or not a multiple of 0.5, and a // comment over MaxReviewComment runes, are a *ValidationError. A // completed attempt is recalculated; AnswerReviewedEvent, and // AttemptReviewedEvent when the attempt is fully reviewed, fire after the // commit. func (s *Service) SetScore(ctx context.Context, answerID uint, points float64, comment *string, by Reviewer) (*ReviewDetail, error) { comment, problems := CheckReviewComment(comment) if len(problems) > 0 { return nil, &ValidationError{Problems: problems} } var detail *ReviewDetail err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { t, err := lockReviewTarget(ctx, tx, answerID) if err != nil { return err } if !CheckReviewPoints(points, t.Question.Points) { return &ValidationError{Problems: []string{ "points must be between 0 and " + strconv.Itoa(max(t.Question.Points, 0)) + " in steps of 0.5", }} } if points == 0 { points = 0 // no negative zero in JSON } if err := s.storeReview(ctx, tx, t, points, comment, true, by); err != nil { return err } if err := s.recalcAttempt(ctx, tx, t.Attempt.ID); err != nil { return err } if err := s.fireAttemptReviewedIfDone(ctx, tx, t.Attempt.ID); err != nil { return err } detail, err = reviewItem(ctx, tx, answerID) return err }) if err != nil { return nil, err } return detail, nil } // ConfirmBulk confirms every graded answer of one quiz (QuizSlug) or one // attempt (AttemptID), exactly as Confirm does, and returns how many it // confirmed. Pending, failed and reviewed answers are left alone. The // affected attempts are locked in id order, recalculated once each, and // every changed answer and every completed attempt that is now fully // reviewed fire their events after the commit. A missing quiz or attempt // is ErrNotFound; a scope that is not exactly one of the two is a // *ValidationError. func (s *Service) ConfirmBulk(ctx context.Context, scope ReviewScope, by Reviewer) (int, error) { if problems := CheckReviewScope(scope, false); len(problems) > 0 { return 0, &ValidationError{Problems: problems} } confirmed := 0 err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { confirmed = 0 quiz, err := scopeQuiz(ctx, tx, scope) if err != nil { return err } rows, attempts, err := lockScope(ctx, tx, scope, quiz, []string{models.GradingGraded}) if err != nil { return err } questions, err := scopeQuestions(ctx, tx, rows) if err != nil { return err } touched := map[uint]bool{} for _, row := range rows { t := &reviewTarget{Answer: row, Question: questions[row.QuestionID], Attempt: attempts[row.AttemptID]} done, err := s.confirmTarget(ctx, tx, t, by) if err != nil { return err } if done { confirmed++ touched[row.AttemptID] = true } } for _, id := range sortedIDs(touched) { if err := s.recalcAttempt(ctx, tx, id); err != nil { return err } if err := s.fireAttemptReviewedIfDone(ctx, tx, id); err != nil { return err } } return nil }) if err != nil { return 0, err } return confirmed, nil } // Regrade sends open answers back to the quiz's grader, e.g. after a // rubric change (D-12): every pending, graded or failed answer of one // answer, attempt or quiz goes back to pending with the next grading_seq // (so a job for the old text is ignored), its grade, error and points // cleared, and is enqueued again. Answers of a test attempt that is still // open are not enqueued: closing the attempt enqueues them (UD-2). // Reviewed answers are skipped, as owner decisions are final; a single // reviewed answer is ErrAlreadyReviewed. A quiz whose grader is manual or // not registered is ErrGraderManual. Completed attempts are recalculated. // It returns how many answers were reset. func (s *Service) Regrade(ctx context.Context, scope ReviewScope) (int, error) { if problems := CheckReviewScope(scope, true); len(problems) > 0 { return 0, &ValidationError{Problems: problems} } regraded := 0 err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error { regraded = 0 tx = tx.WithContext(ctx) quiz, err := scopeQuiz(ctx, tx, scope) if err != nil { return err } if _, ok := GraderFor(s.Graders, quiz); !ok { return ErrGraderManual } if scope.AnswerID != 0 { if err := refuseReviewed(ctx, tx, scope.AnswerID); err != nil { return err } } rows, attempts, err := lockScope(ctx, tx, scope, quiz, unreviewedStatuses()) if err != nil { return err } if scope.AnswerID != 0 && len(rows) == 0 { // Reviewed between the first read and the lock. return refuseReviewed(ctx, tx, scope.AnswerID) } now := s.now().UTC() seqs := make(map[uint]int, len(rows)) for _, row := range rows { seq := row.GradingSeq + 1 if err := tx.Model(&models.AttemptAnswer{}).Where("id = ?", row.ID).Updates(map[string]any{ "grading_status": models.GradingPending, "grading_seq": seq, "grading_attempts": 0, "grader": nil, "grader_points": nil, "grader_feedback": nil, "grading_error": nil, "graded_at": nil, "points": 0, "is_correct": false, "updated_at": now, }).Error; err != nil { return err } seqs[row.ID] = seq regraded++ } touched := map[uint]bool{} for _, row := range rows { touched[row.AttemptID] = true } for _, id := range sortedIDs(touched) { if err := s.recalcAttempt(ctx, tx, id); err != nil { return err } } for _, row := range rows { a := attempts[row.AttemptID] if a.CompletedAt == nil && IsTest(quiz) { continue } if err := s.enqueueGrading(ctx, tx, row.ID, seqs[row.ID], a); err != nil { return err } } return nil }) if err != nil { return 0, err } return regraded, nil } // refuseReviewed is ErrAlreadyReviewed for a reviewed answer and // ErrNotFound for one that is missing or not open. func refuseReviewed(ctx context.Context, tx *gorm.DB, answerID uint) error { var row models.AttemptAnswer if err := tx.WithContext(ctx).Where("id = ? AND grading_status IS NOT NULL", answerID).Take(&row).Error; err != nil { return notFound(err) } if *row.GradingStatus == models.GradingReviewed { return ErrAlreadyReviewed } return nil } // scopeQuiz resolves the quiz of a review scope: by slug, from the // attempt, or from the open answer's attempt. Anything missing is // ErrNotFound. func scopeQuiz(ctx context.Context, tx *gorm.DB, scope ReviewScope) (*models.Quiz, error) { tx = tx.WithContext(ctx) var quiz models.Quiz var err error switch { case scope.AnswerID != 0: err = tx.Where(`id = (SELECT a.quiz_id FROM golem15_quizzes_attempt_answers aa JOIN golem15_quizzes_attempts a ON a.id = aa.attempt_id WHERE aa.id = ? AND aa.grading_status IS NOT NULL)`, scope.AnswerID).Take(&quiz).Error case scope.AttemptID != 0: err = tx.Where("id = (SELECT quiz_id FROM golem15_quizzes_attempts WHERE id = ?)", scope.AttemptID).Take(&quiz).Error default: err = tx.Where("slug = ?", strings.TrimSpace(scope.QuizSlug)).Take(&quiz).Error } if err != nil { return nil, notFound(err) } return &quiz, nil } // scopeRows narrows a query on the attempt answers to a scope of quiz. func scopeRows(q *gorm.DB, scope ReviewScope, quiz *models.Quiz) *gorm.DB { switch { case scope.AnswerID != 0: return q.Where("id = ?", scope.AnswerID) case scope.AttemptID != 0: return q.Where("attempt_id = ?", scope.AttemptID) } return q.Where("attempt_id IN (SELECT id FROM golem15_quizzes_attempts WHERE quiz_id = ?)", quiz.ID) } // lockScope locks, in id order, the attempts that have open answers with // one of statuses in scope, then selects those answers again under the // locks (only of the locked attempts, so nothing changes underneath). It // returns the answers by attempt and id, and the locked attempts by id // with their quiz set. func lockScope(ctx context.Context, tx *gorm.DB, scope ReviewScope, quiz *models.Quiz, statuses []string) ([]models.AttemptAnswer, map[uint]*models.Attempt, error) { tx = tx.WithContext(ctx) var ids []uint if err := scopeRows(tx.Model(&models.AttemptAnswer{}), scope, quiz). Where("grading_status IN ?", statuses).Distinct("attempt_id").Order("attempt_id ASC"). Pluck("attempt_id", &ids).Error; err != nil { return nil, nil, err } attempts := make(map[uint]*models.Attempt, len(ids)) locked := make([]uint, 0, len(ids)) for _, id := range ids { a, err := lockAttempt(ctx, tx, id) if errors.Is(err, gorm.ErrRecordNotFound) { continue } if err != nil { return nil, nil, err } a.Quiz = quiz attempts[id] = a locked = append(locked, id) } if len(locked) == 0 { return nil, attempts, nil } var rows []models.AttemptAnswer if err := scopeRows(tx, scope, quiz).Where("grading_status IN ? AND attempt_id IN ?", statuses, locked). Order("attempt_id ASC, id ASC").Find(&rows).Error; err != nil { return nil, nil, err } return rows, attempts, nil } // scopeQuestions loads the questions of the given answers by id. func scopeQuestions(ctx context.Context, tx *gorm.DB, rows []models.AttemptAnswer) (map[uint]models.Question, error) { out := map[uint]models.Question{} if len(rows) == 0 { return out, nil } ids := make([]uint, 0, len(rows)) for _, r := range rows { ids = append(ids, r.QuestionID) } var questions []models.Question if err := tx.WithContext(ctx).Where("id IN ?", ids).Find(&questions).Error; err != nil { return nil, err } for _, q := range questions { out[q.ID] = q } return out, nil } // sortedIDs returns the keys of set in ascending order. func sortedIDs(set map[uint]bool) []uint { out := make([]uint, 0, len(set)) for id := range set { out = append(out, id) } slices.Sort(out) return out }