WR-02: Confirm and SetScore answer ErrAttemptOpen (409 attempt_open, an admin refusal) for an answer of a running test, ConfirmBulk skips such attempts, and the default open queue leaves them out, so AnswerReviewed never fires before the test ends.
858 lines
29 KiB
Go
858 lines
29 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, except those of a test attempt that is
|
|
// still running. 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")
|
|
// ErrAttemptOpen is a review write on an answer of a test attempt that
|
|
// is still running: a test's results, the owner's review included, come
|
|
// only after it ends (Phase 7 D-07, D-08).
|
|
ErrAttemptOpen = errors.New("quizzes: the test attempt is still open")
|
|
)
|
|
|
|
// 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.
|
|
// The default open filter leaves out the answers of a test attempt that is
|
|
// still running: they cannot be reviewed until it ends (ErrAttemptOpen).
|
|
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 status := strings.TrimSpace(f.Status); status == "" || status == ReviewStatusOpen {
|
|
b.WriteString(" AND NOT (qz.mode = ? AND a.completed_at IS NULL)")
|
|
args = append(args, models.ModeTest)
|
|
}
|
|
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; an answer of a test attempt that is still
|
|
// running is ErrAttemptOpen.
|
|
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
|
|
if Withheld(attempt) {
|
|
return nil, ErrAttemptOpen
|
|
}
|
|
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. Answers of a test attempt
|
|
// that is still running are skipped; a scope of one such attempt is
|
|
// ErrAttemptOpen. 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 {
|
|
if Withheld(attempts[row.AttemptID]) {
|
|
if scope.AttemptID != 0 {
|
|
return ErrAttemptOpen
|
|
}
|
|
continue
|
|
}
|
|
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
|
|
}
|