Files
sm-quizzes-plugin/classes/review.go
Jakub Zych 692a647650 feat: set scores, confirm in bulk and regrade open answers over MCP
- SetScore (0..points in 0.5 steps, comment shown to the student),
  ConfirmBulk per quiz or attempt and Regrade through the quiz's grader
- close() announces an attempt reviewed while it was open
- quizzes_review_set_score, quizzes_review_confirm_bulk,
  quizzes_review_regrade; 409 already_reviewed and grader_manual
2026-11-09 18:06:02 +01:00

836 lines
28 KiB
Go

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
}