- Service.Start resumes an open test attempt, closes expired ones at their deadline and refuses a start past max_attempts under an advisory lock; StartAttempt stays as a wrapper - answers to a test are accepted until the deadline plus golem15.quizzes.test.grace_seconds; one_way answers are final - a test covers the whole quiz and keeps a stable per-attempt order - 409 bodies carry a code; list and attempt rows carry attempts_left, question_types and seconds_left; import accepts the test keys
682 lines
22 KiB
Go
682 lines
22 KiB
Go
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
|
|
}
|
|
|
|
// 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
|
|
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 {
|
|
var answered int64
|
|
if err := tx.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", resume.ID).Count(&answered).Error; err != nil {
|
|
return err
|
|
}
|
|
st = &Started{Attempt: resume, Quiz: quiz, Resumed: true, AttemptsUsed: int(used), AnsweredCount: int(answered)}
|
|
return nil
|
|
}
|
|
if left := AttemptsLeft(quiz, int(used)); left != nil && *left == 0 {
|
|
return ErrAttemptLimit
|
|
}
|
|
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
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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 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 or known). The feedback of an open test
|
|
// attempt is Withheld.
|
|
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.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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 _, 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
|
|
}
|
|
type countRow struct {
|
|
AttemptID uint
|
|
N int
|
|
}
|
|
var counts []countRow
|
|
if err := s.db(ctx).Model(&models.AttemptAnswer{}).Select("attempt_id, COUNT(*) AS n").
|
|
Where("attempt_id IN ?", ids).Group("attempt_id").Scan(&counts).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
countBy := map[uint]int{}
|
|
for _, c := range counts {
|
|
countBy[c.AttemptID] = c.N
|
|
}
|
|
for _, a := range attempts {
|
|
out = append(out, AttemptSummary{Attempt: a, AnsweredCount: countBy[a.ID]})
|
|
}
|
|
return out, total, nil
|
|
}
|