Files
sm-quizzes-plugin/classes/service.go

487 lines
16 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.
ErrTimeLimit = errors.New("quizzes: time limit exceeded")
)
// 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))
}
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
}
// ListQuizzes returns the published quizzes, optionally only those whose
// context equals quizContext, ordered by title. AttemptsCount counts the
// user's completed attempts 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
N int
}
var counts []countRow
if err := s.db(ctx).Model(&models.Question{}).Select("quiz_id, COUNT(*) AS n").
Where("quiz_id IN ?", ids).Group("quiz_id").Scan(&counts).Error; err != nil {
return nil, err
}
type statRow struct {
QuizID uint
N int
Best *float64
}
var stats []statRow
if err := s.db(ctx).Model(&models.Attempt{}).Select("quiz_id, COUNT(*) AS n, MAX(score_percent) AS best").
Where("quiz_id IN ? AND user_id = ? AND completed_at IS NOT NULL", ids, userID).
Group("quiz_id").Scan(&stats).Error; err != nil {
return nil, err
}
countBy := map[uint]int{}
for _, c := range counts {
countBy[c.QuizID] = c.N
}
statBy := map[uint]statRow{}
for _, st := range stats {
statBy[st.QuizID] = st
}
for _, quiz := range quizzes {
st := statBy[quiz.ID]
out = append(out, QuizSummary{Quiz: quiz, QuestionCount: countBy[quiz.ID], BestScorePercent: st.Best, AttemptsCount: st.N})
}
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. 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
}
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
}
// StartAttempt starts an attempt at a published quiz for the user. A
// non-empty category limits the attempt to that category's questions; a
// category without questions is a FieldError on category.
func (s *Service) StartAttempt(ctx context.Context, userID uint, slug, category string) (*models.Attempt, *models.Quiz, error) {
quiz, err := s.publishedQuiz(ctx, slug)
if err != nil {
return nil, nil, err
}
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, nil, err
}
if n == 0 {
return nil, nil, &FieldError{Field: "category", Key: "unknown_category"}
}
attempt.Category = &category
}
maxPoints, err := s.maxPoints(ctx, attempt)
if err != nil {
return nil, nil, err
}
attempt.MaxPoints = maxPoints
if err := s.db(ctx).Create(attempt).Error; err != nil {
return nil, nil, err
}
return attempt, quiz, nil
}
func (s *Service) maxPoints(ctx context.Context, a *models.Attempt) (int, error) {
q := s.db(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
}
// 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 (ErrTimeLimit), the question belongs to the attempt
// (FieldError on question_id), and the answer fits the question type
// (FieldError on answer_ids or known).
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 := Deadline(attempt); 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
}
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}
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
}
var points []float64
if err := tx.WithContext(ctx).Model(&models.AttemptAnswer{}).Where("attempt_id = ?", attempt.ID).
Pluck("points", &points).Error; err != nil {
return err
}
maxPoints, err := s.maxPoints(ctx, attempt)
if err != nil {
return err
}
score, percent := Totals(points, maxPoints)
now := s.now().UTC()
return tx.WithContext(ctx).Model(&models.Attempt{}).Where("id = ?", attempt.ID).Updates(map[string]any{
"completed_at": now,
"score_points": score,
"max_points": maxPoints,
"score_percent": percent,
"updated_at": now,
}).Error
})
if err != nil {
return nil, err
}
return s.Result(ctx, userID, attemptID)
}
// 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.
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
}
category := ""
if attempt.Category != nil {
category = *attempt.Category
}
questions, err := s.questions(ctx, attempt.QuizID, category)
if err != nil {
return nil, err
}
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
}