- import document, manage routes and MCP question tools take rubric and max_length; open questions refuse answers, max_length is 1..20000 and both apply to open questions only - quizzes take a grader; the manage routes refuse a code the registry does not know, the import accepts any well-formed code - quiz stats report unreviewed open answers and their average points - import_quiz warns that replace deletes the grades and reviews
976 lines
33 KiB
Go
976 lines
33 KiB
Go
package classes
|
|
|
|
// Owner-side management behind the manage routes under /api/v1/quizzes,
|
|
// which the MCP quiz tools bind. There is no owner column: a user whose
|
|
// groups grant golem15.quizzes.access_quizzes manages every quiz, as in the
|
|
// admin (A7). Quizzes are addressed by slug.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// ErrQuestionNotFound is a question id that is not a question of the quiz.
|
|
// It is an ErrNotFound.
|
|
var ErrQuestionNotFound = fmt.Errorf("%w: question", ErrNotFound)
|
|
|
|
// ValidationError is manage input that breaks a rule (a 422). Problems
|
|
// read like the importer's: "title is required".
|
|
type ValidationError struct {
|
|
Problems []string
|
|
}
|
|
|
|
func (e *ValidationError) Error() string { return "invalid quiz: " + strings.Join(e.Problems, "; ") }
|
|
|
|
// invalid wraps problems in a ValidationError, or returns nil for none.
|
|
func invalid(problems []string) error {
|
|
if len(problems) == 0 {
|
|
return nil
|
|
}
|
|
return &ValidationError{Problems: problems}
|
|
}
|
|
|
|
// QuizInput is the quiz fields of a create or a partial update; nil means
|
|
// not sent. There is no IsPublished: a created quiz starts unpublished and
|
|
// only SetPublished changes it.
|
|
type QuizInput struct {
|
|
Title *string `json:"title"`
|
|
Slug *string `json:"slug"`
|
|
Description *string `json:"description"`
|
|
Context *string `json:"context"`
|
|
Mode *string `json:"mode"`
|
|
Navigation *string `json:"navigation"`
|
|
MaxAttempts *int `json:"max_attempts"`
|
|
TimeLimitSeconds *int `json:"time_limit_seconds"`
|
|
PassingScore *int `json:"passing_score"`
|
|
RoundSize *int `json:"round_size"`
|
|
ShuffleQuestions *bool `json:"shuffle_questions"`
|
|
Grader *string `json:"grader"`
|
|
}
|
|
|
|
// CheckQuizInput returns the rule problems of in. On create the title is
|
|
// required and max_attempts, time_limit_seconds and round_size are at least
|
|
// 1; on update 0 is allowed for them, as it clears the value.
|
|
func CheckQuizInput(in QuizInput, create bool) []string {
|
|
var problems []string
|
|
switch {
|
|
case in.Title == nil:
|
|
if create {
|
|
problems = append(problems, "title is required")
|
|
}
|
|
case strings.TrimSpace(*in.Title) == "":
|
|
problems = append(problems, "title is required")
|
|
case utf8.RuneCountInString(strings.TrimSpace(*in.Title)) > 255:
|
|
problems = append(problems, "title must be at most 255 characters")
|
|
}
|
|
if create && in.Slug != nil && strings.TrimSpace(*in.Slug) != "" && models.Slugify(*in.Slug) == "" {
|
|
problems = append(problems, "slug is empty after normalising it")
|
|
}
|
|
if create && (in.Slug == nil || strings.TrimSpace(*in.Slug) == "") && in.Title != nil &&
|
|
strings.TrimSpace(*in.Title) != "" && models.Slugify(*in.Title) == "" {
|
|
problems = append(problems, "slug is empty after normalising the title")
|
|
}
|
|
if in.Context != nil && utf8.RuneCountInString(strings.TrimSpace(*in.Context)) > 255 {
|
|
problems = append(problems, "context must be at most 255 characters")
|
|
}
|
|
if in.Mode != nil && !slices.Contains(models.QuizModes, *in.Mode) {
|
|
problems = append(problems, "mode must be one of "+strings.Join(models.QuizModes, ", "))
|
|
}
|
|
if in.Navigation != nil && !slices.Contains(models.QuizNavigations, *in.Navigation) {
|
|
problems = append(problems, "navigation must be one of "+strings.Join(models.QuizNavigations, ", "))
|
|
}
|
|
if in.PassingScore != nil && (*in.PassingScore < 0 || *in.PassingScore > 100) {
|
|
problems = append(problems, "passing_score must be between 0 and 100")
|
|
}
|
|
problems = append(problems, checkGraderFormat(in.Grader)...)
|
|
least, word := 1, "at least 1"
|
|
if !create {
|
|
least, word = 0, "0 (clears it) or more"
|
|
}
|
|
for _, f := range []struct {
|
|
name string
|
|
v *int
|
|
}{{"max_attempts", in.MaxAttempts}, {"time_limit_seconds", in.TimeLimitSeconds}, {"round_size", in.RoundSize}} {
|
|
if f.v != nil && *f.v < least {
|
|
problems = append(problems, f.name+" must be "+word)
|
|
}
|
|
}
|
|
return problems
|
|
}
|
|
|
|
// CheckGrader is the problem of a sent grader code that is not one of
|
|
// known, the codes of the registry (manual first), naming the valid codes.
|
|
// A nil code is fine.
|
|
func CheckGrader(code *string, known []string) []string {
|
|
if code == nil || slices.Contains(known, *code) {
|
|
return nil
|
|
}
|
|
return []string{"grader must be one of " + strings.Join(known, ", ")}
|
|
}
|
|
|
|
// positive is v, or nil when v is nil or not positive (0 clears).
|
|
func positive(v *int) *int {
|
|
if v == nil || *v <= 0 {
|
|
return nil
|
|
}
|
|
n := *v
|
|
return &n
|
|
}
|
|
|
|
// CreateQuiz creates an unpublished quiz from in. The slug comes from Slug,
|
|
// or from the title when Slug is empty; a taken slug is ErrQuizExists. The
|
|
// passing score defaults to 50, mode and navigation to learning / free.
|
|
func CreateQuiz(ctx context.Context, db *gorm.DB, in QuizInput) (*models.Quiz, error) {
|
|
if err := invalid(CheckQuizInput(in, true)); err != nil {
|
|
return nil, err
|
|
}
|
|
quiz := models.Quiz{Title: strings.TrimSpace(*in.Title), PassingScore: 50}
|
|
if in.Slug != nil && strings.TrimSpace(*in.Slug) != "" {
|
|
quiz.Slug = models.Slugify(*in.Slug)
|
|
} else {
|
|
quiz.Slug = models.Slugify(quiz.Title)
|
|
}
|
|
applyQuizInput(&quiz, in)
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
var n int64
|
|
if err := tx.Model(&models.Quiz{}).Where("slug = ?", quiz.Slug).Count(&n).Error; err != nil {
|
|
return err
|
|
}
|
|
if n > 0 {
|
|
return ErrQuizExists
|
|
}
|
|
return tx.Create(&quiz).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &quiz, nil
|
|
}
|
|
|
|
// applyQuizInput sets the sent fields of in on quiz, except the slug and
|
|
// the title's presence check. An empty description or context is NULL, a
|
|
// 0 limit is NULL.
|
|
func applyQuizInput(quiz *models.Quiz, in QuizInput) {
|
|
if in.Title != nil {
|
|
quiz.Title = strings.TrimSpace(*in.Title)
|
|
}
|
|
if in.Description != nil {
|
|
quiz.Description = nilIfBlank(*in.Description, false)
|
|
}
|
|
if in.Context != nil {
|
|
quiz.Context = nilIfBlank(*in.Context, true)
|
|
}
|
|
if in.Mode != nil {
|
|
quiz.Mode = *in.Mode
|
|
}
|
|
if in.Navigation != nil {
|
|
quiz.Navigation = *in.Navigation
|
|
}
|
|
if in.MaxAttempts != nil {
|
|
quiz.MaxAttempts = positive(in.MaxAttempts)
|
|
}
|
|
if in.TimeLimitSeconds != nil {
|
|
quiz.TimeLimitSeconds = positive(in.TimeLimitSeconds)
|
|
}
|
|
if in.RoundSize != nil {
|
|
quiz.RoundSize = positive(in.RoundSize)
|
|
}
|
|
if in.PassingScore != nil {
|
|
quiz.PassingScore = *in.PassingScore
|
|
}
|
|
if in.ShuffleQuestions != nil {
|
|
quiz.ShuffleQuestions = *in.ShuffleQuestions
|
|
}
|
|
if in.Grader != nil {
|
|
quiz.Grader = *in.Grader
|
|
}
|
|
}
|
|
|
|
// nilIfBlank is nil for a blank s, else s (trimmed when trim is set).
|
|
func nilIfBlank(s string, trim bool) *string {
|
|
if strings.TrimSpace(s) == "" {
|
|
return nil
|
|
}
|
|
if trim {
|
|
s = strings.TrimSpace(s)
|
|
}
|
|
return &s
|
|
}
|
|
|
|
// UpdateQuiz changes only the sent fields of the quiz with this slug: 0
|
|
// clears max_attempts, time_limit_seconds and round_size, an empty string
|
|
// clears description and context. Slug is ignored, and it cannot publish
|
|
// (A10). A missing slug is ErrNotFound.
|
|
func UpdateQuiz(ctx context.Context, db *gorm.DB, slug string, in QuizInput) (*models.Quiz, error) {
|
|
if err := invalid(CheckQuizInput(in, false)); err != nil {
|
|
return nil, err
|
|
}
|
|
var quiz models.Quiz
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
if err := lockedQuiz(tx, slug, &quiz); err != nil {
|
|
return err
|
|
}
|
|
applyQuizInput(&quiz, in)
|
|
return tx.Omit(clause.Associations).Save(&quiz).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &quiz, nil
|
|
}
|
|
|
|
// lockedQuiz loads the quiz with this slug FOR UPDATE, or ErrNotFound.
|
|
func lockedQuiz(tx *gorm.DB, slug string, quiz *models.Quiz) error {
|
|
err := tx.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}).
|
|
Where("slug = ?", slug).Take(quiz).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ValidateDeleteConfirm refuses a delete whose confirm value is not the
|
|
// quiz slug, so a stray DELETE cannot remove a quiz by accident.
|
|
func ValidateDeleteConfirm(slug, confirm string) error {
|
|
if strings.TrimSpace(confirm) == "" || confirm != slug {
|
|
return &ValidationError{Problems: []string{"confirm must repeat the quiz slug"}}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeletedQuiz is the answer of a quiz delete: what was removed.
|
|
type DeletedQuiz struct {
|
|
ID uint
|
|
Slug string
|
|
Title string
|
|
Questions int64
|
|
Attempts int64
|
|
}
|
|
|
|
// DeleteQuiz removes the quiz with this slug for good. The foreign keys
|
|
// cascade to its questions, answers, attempts and attempt answers; the
|
|
// counts are taken first. A missing slug is ErrNotFound.
|
|
func DeleteQuiz(ctx context.Context, db *gorm.DB, slug string) (*DeletedQuiz, error) {
|
|
var out DeletedQuiz
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
var quiz models.Quiz
|
|
if err := lockedQuiz(tx, slug, &quiz); err != nil {
|
|
return err
|
|
}
|
|
out = DeletedQuiz{ID: quiz.ID, Slug: quiz.Slug, Title: quiz.Title}
|
|
if err := tx.Model(&models.Question{}).Where("quiz_id = ?", quiz.ID).Count(&out.Questions).Error; err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Model(&models.Attempt{}).Where("quiz_id = ?", quiz.ID).Count(&out.Attempts).Error; err != nil {
|
|
return err
|
|
}
|
|
return tx.Where("id = ?", quiz.ID).Delete(&models.Quiz{}).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &out, nil
|
|
}
|
|
|
|
// SetPublished publishes or unpublishes the quiz with this slug; doing it
|
|
// twice is the same as once. A missing slug is ErrNotFound.
|
|
func SetPublished(ctx context.Context, db *gorm.DB, slug string, published bool) (*models.Quiz, error) {
|
|
var quiz models.Quiz
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
if err := lockedQuiz(tx, slug, &quiz); err != nil {
|
|
return err
|
|
}
|
|
if quiz.IsPublished == published {
|
|
return nil
|
|
}
|
|
quiz.IsPublished = published
|
|
return tx.Model(&models.Quiz{}).Where("id = ?", quiz.ID).
|
|
Updates(map[string]any{"is_published": published, "updated_at": time.Now().UTC()}).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &quiz, nil
|
|
}
|
|
|
|
// ManagedRow counts the questions and completed attempts of one quiz, for
|
|
// the answers of the quiz writes.
|
|
func ManagedRow(ctx context.Context, db *gorm.DB, quiz models.Quiz) (ManagedQuizRow, error) {
|
|
row := ManagedQuizRow{Quiz: quiz}
|
|
var n int64
|
|
if err := db.WithContext(ctx).Model(&models.Question{}).Where("quiz_id = ?", quiz.ID).Count(&n).Error; err != nil {
|
|
return row, err
|
|
}
|
|
row.QuestionCount = int(n)
|
|
attempts, err := CompletedAttemptCount(ctx, db, quiz.ID)
|
|
row.AttemptsCount = attempts
|
|
return row, err
|
|
}
|
|
|
|
// ManagedQuizRow is one quiz of the owner list with its question count and
|
|
// the number of completed attempts (of every user).
|
|
type ManagedQuizRow struct {
|
|
Quiz models.Quiz
|
|
QuestionCount int
|
|
AttemptsCount int
|
|
}
|
|
|
|
// ListManagedQuizzes returns every quiz, published or not, optionally only
|
|
// those whose context equals quizContext, ordered by context (quizzes
|
|
// without one last), title and id.
|
|
func ListManagedQuizzes(ctx context.Context, db *gorm.DB, quizContext string) ([]ManagedQuizRow, error) {
|
|
db = db.WithContext(ctx)
|
|
q := db.Model(&models.Quiz{})
|
|
if quizContext != "" {
|
|
q = q.Where("context = ?", quizContext)
|
|
}
|
|
var quizzes []models.Quiz
|
|
if err := q.Order("context ASC NULLS LAST, title ASC, id ASC").Find(&quizzes).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]ManagedQuizRow, 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 questions, attempts []countRow
|
|
if err := db.Model(&models.Question{}).Select("quiz_id, COUNT(*) AS n").
|
|
Where("quiz_id IN ?", ids).Group("quiz_id").Scan(&questions).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Model(&models.Attempt{}).Select("quiz_id, COUNT(*) AS n").
|
|
Where("quiz_id IN ? AND completed_at IS NOT NULL", ids).Group("quiz_id").Scan(&attempts).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
questionsBy := map[uint]int{}
|
|
for _, c := range questions {
|
|
questionsBy[c.QuizID] = c.N
|
|
}
|
|
attemptsBy := map[uint]int{}
|
|
for _, c := range attempts {
|
|
attemptsBy[c.QuizID] = c.N
|
|
}
|
|
for _, quiz := range quizzes {
|
|
out = append(out, ManagedQuizRow{Quiz: quiz, QuestionCount: questionsBy[quiz.ID], AttemptsCount: attemptsBy[quiz.ID]})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ManagedQuiz loads a quiz, published or not, with every question in sort
|
|
// order and every answer of each, solutions included. A missing slug is
|
|
// ErrNotFound.
|
|
func ManagedQuiz(ctx context.Context, db *gorm.DB, slug string) (*models.Quiz, error) {
|
|
var quiz models.Quiz
|
|
err := db.WithContext(ctx).
|
|
Preload("Questions", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }).
|
|
Preload("Questions.Answers", func(tx *gorm.DB) *gorm.DB { return tx.Order("sort_order ASC, id ASC") }).
|
|
Where("slug = ?", slug).Take(&quiz).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &quiz, nil
|
|
}
|
|
|
|
// CompletedAttemptCount counts the completed attempts of every user on a
|
|
// quiz.
|
|
func CompletedAttemptCount(ctx context.Context, db *gorm.DB, quizID uint) (int, error) {
|
|
var n int64
|
|
err := db.WithContext(ctx).Model(&models.Attempt{}).
|
|
Where("quiz_id = ? AND completed_at IS NOT NULL", quizID).Count(&n).Error
|
|
return int(n), err
|
|
}
|
|
|
|
// StudentStat is one student's results on a quiz, over their completed
|
|
// whole-quiz attempts (category IS NULL, A9).
|
|
type StudentStat struct {
|
|
UserID uint
|
|
Email string
|
|
Name *string
|
|
Surname *string
|
|
Attempts int
|
|
BestScorePercent *float64
|
|
LastScorePercent *float64
|
|
LastCompletedAt *time.Time
|
|
// Passed is BestScorePercent >= the quiz's passing score.
|
|
Passed bool
|
|
}
|
|
|
|
// WrongAnswerStat is the wrong answer chosen most often for a question.
|
|
type WrongAnswerStat struct {
|
|
AnswerID uint
|
|
Text string
|
|
Count int
|
|
}
|
|
|
|
// QuestionStat is one question's results over every completed attempt's
|
|
// answers. CorrectPercent is nil when nobody answered it; MostChosenWrong
|
|
// is nil for a self_check or open question and when no wrong answer was
|
|
// chosen. Unreviewed counts the open answers the owner has not reviewed
|
|
// yet (pending, graded or failed); AveragePoints is the mean score of an
|
|
// open question's graded and reviewed answers, nil for other types and
|
|
// before any grade.
|
|
type QuestionStat struct {
|
|
ID uint
|
|
Text string
|
|
Type string
|
|
Answered int
|
|
Correct int
|
|
CorrectPercent *float64
|
|
MostChosenWrong *WrongAnswerStat
|
|
Unreviewed int
|
|
AveragePoints *float64
|
|
}
|
|
|
|
// Stats is the owner's report on one quiz (D-15).
|
|
type Stats struct {
|
|
Quiz models.Quiz
|
|
QuestionCount int
|
|
Students []StudentStat
|
|
Questions []QuestionStat
|
|
// Unreviewed is the sum of the questions' Unreviewed.
|
|
Unreviewed int
|
|
}
|
|
|
|
// StudentRow is one row of the per-student stats query.
|
|
type StudentRow struct {
|
|
UserID uint
|
|
Email string
|
|
Name *string
|
|
Surname *string
|
|
Attempts int
|
|
BestScorePercent *float64
|
|
LastScorePercent *float64
|
|
LastCompletedAt *time.Time
|
|
}
|
|
|
|
// AnswerRow is one row of the per-question answered/correct query.
|
|
type AnswerRow struct {
|
|
QuestionID uint
|
|
Answered int
|
|
Correct int
|
|
Unreviewed int
|
|
AveragePoints *float64
|
|
}
|
|
|
|
// WrongRow is one wrong answer chosen N times for a question.
|
|
type WrongRow struct {
|
|
QuestionID uint
|
|
AnswerID uint
|
|
Text string
|
|
N int
|
|
}
|
|
|
|
const (
|
|
statsStudentsSQL = `SELECT a.user_id, u.email, u.name, u.surname, COUNT(*) AS attempts,
|
|
MAX(a.score_percent) AS best_score_percent,
|
|
(ARRAY_AGG(a.score_percent ORDER BY a.completed_at DESC, a.id DESC))[1] AS last_score_percent,
|
|
MAX(a.completed_at) AS last_completed_at
|
|
FROM golem15_quizzes_attempts a JOIN users u ON u.id = a.user_id
|
|
WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL AND a.category IS NULL
|
|
GROUP BY a.user_id, u.email, u.name, u.surname
|
|
ORDER BY u.surname ASC NULLS LAST, u.name ASC NULLS LAST, u.email ASC, a.user_id ASC`
|
|
|
|
// average_points counts only scored open answers: a pending or failed
|
|
// answer holds 0 points until it is graded.
|
|
statsAnswersSQL = `SELECT aa.question_id, COUNT(*) AS answered, COUNT(*) FILTER (WHERE aa.is_correct) AS correct,
|
|
COUNT(*) FILTER (WHERE aa.grading_status IN ('pending', 'graded', 'failed')) AS unreviewed,
|
|
AVG(aa.points) FILTER (WHERE aa.grading_status IN ('graded', 'reviewed')) AS average_points
|
|
FROM golem15_quizzes_attempt_answers aa
|
|
JOIN golem15_quizzes_attempts a ON a.id = aa.attempt_id
|
|
WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL
|
|
GROUP BY aa.question_id`
|
|
|
|
// A stored answer_ids that is SQL NULL or not an array (self_check
|
|
// rows) yields no elements instead of an error.
|
|
statsWrongSQL = `SELECT aa.question_id, ans.id AS answer_id, ans.text, COUNT(*) AS n
|
|
FROM golem15_quizzes_attempt_answers aa
|
|
JOIN golem15_quizzes_attempts a ON a.id = aa.attempt_id
|
|
CROSS JOIN LATERAL jsonb_array_elements_text(CASE WHEN jsonb_typeof(aa.answer_ids) = 'array' THEN aa.answer_ids ELSE '[]'::jsonb END) AS e(value)
|
|
JOIN golem15_quizzes_answers ans ON ans.id = e.value::bigint AND ans.question_id = aa.question_id AND ans.is_correct = FALSE
|
|
WHERE a.quiz_id = ? AND a.completed_at IS NOT NULL
|
|
GROUP BY aa.question_id, ans.id, ans.text
|
|
ORDER BY aa.question_id, n DESC, ans.id`
|
|
)
|
|
|
|
// QuizStats reports on the quiz with this slug. For a test it first closes
|
|
// every expired open attempt at its deadline, through svc, so a student who
|
|
// left a test counts with the answers given in time. Students are counted
|
|
// over completed whole-quiz attempts, questions over the answers of every
|
|
// completed attempt. A missing slug is ErrNotFound.
|
|
func QuizStats(ctx context.Context, db *gorm.DB, slug string, svc *Service) (*Stats, error) {
|
|
if svc == nil {
|
|
svc = &Service{DB: db, Grace: DefaultTestGrace}
|
|
}
|
|
var quiz models.Quiz
|
|
err := db.WithContext(ctx).Where("slug = ?", slug).Take(&quiz).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if IsTest(&quiz) {
|
|
if err := svc.closeExpired(ctx, db, &quiz); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
tx := db.WithContext(ctx)
|
|
var questions []models.Question
|
|
if err := tx.Where("quiz_id = ?", quiz.ID).Order("sort_order ASC, id ASC").Find(&questions).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var students []StudentRow
|
|
if err := tx.Raw(statsStudentsSQL, quiz.ID).Scan(&students).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var answers []AnswerRow
|
|
if err := tx.Raw(statsAnswersSQL, quiz.ID).Scan(&answers).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
var wrong []WrongRow
|
|
if err := tx.Raw(statsWrongSQL, quiz.ID).Scan(&wrong).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
stats := BuildStats(quiz, questions, students, answers, wrong)
|
|
return &stats, nil
|
|
}
|
|
|
|
// closeExpired closes every expired open attempt of a test at its
|
|
// deadline, in one transaction, with the rows locked as Start locks them.
|
|
func (s *Service) closeExpired(ctx context.Context, db *gorm.DB, quiz *models.Quiz) error {
|
|
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
var open []models.Attempt
|
|
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}).
|
|
Where("quiz_id = ? AND completed_at IS NULL", quiz.ID).Order("id ASC").Find(&open).Error; err != nil {
|
|
return err
|
|
}
|
|
now := s.now()
|
|
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
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// BuildStats assembles the report from the query rows. Every question of
|
|
// questions appears, in that order, with zero counts when nobody answered
|
|
// it; correct_percent is rounded to one decimal.
|
|
func BuildStats(quiz models.Quiz, questions []models.Question, students []StudentRow, answers []AnswerRow, wrong []WrongRow) Stats {
|
|
out := Stats{
|
|
Quiz: quiz,
|
|
QuestionCount: len(questions),
|
|
Students: make([]StudentStat, 0, len(students)),
|
|
Questions: make([]QuestionStat, 0, len(questions)),
|
|
}
|
|
for _, r := range students {
|
|
out.Students = append(out.Students, StudentStat{
|
|
UserID: r.UserID, Email: r.Email, Name: r.Name, Surname: r.Surname, Attempts: r.Attempts,
|
|
BestScorePercent: r.BestScorePercent, LastScorePercent: r.LastScorePercent, LastCompletedAt: r.LastCompletedAt,
|
|
Passed: r.BestScorePercent != nil && *r.BestScorePercent >= float64(quiz.PassingScore),
|
|
})
|
|
}
|
|
answeredBy := map[uint]AnswerRow{}
|
|
for _, r := range answers {
|
|
answeredBy[r.QuestionID] = r
|
|
}
|
|
wrongBy := map[uint]*WrongAnswerStat{}
|
|
for _, r := range wrong {
|
|
best := wrongBy[r.QuestionID]
|
|
if best == nil || r.N > best.Count || (r.N == best.Count && r.AnswerID < best.AnswerID) {
|
|
wrongBy[r.QuestionID] = &WrongAnswerStat{AnswerID: r.AnswerID, Text: r.Text, Count: r.N}
|
|
}
|
|
}
|
|
for _, q := range questions {
|
|
a := answeredBy[q.ID]
|
|
st := QuestionStat{ID: q.ID, Text: q.Text, Type: q.Type, Answered: a.Answered, Correct: a.Correct}
|
|
if a.Answered > 0 {
|
|
pct := math.Round(float64(a.Correct)*1000/float64(a.Answered)) / 10
|
|
st.CorrectPercent = &pct
|
|
}
|
|
switch q.Type {
|
|
case models.TypeSelfCheck:
|
|
case models.TypeOpen:
|
|
st.Unreviewed = a.Unreviewed
|
|
if a.AveragePoints != nil {
|
|
avg := math.Round(*a.AveragePoints*100) / 100
|
|
st.AveragePoints = &avg
|
|
}
|
|
default:
|
|
st.MostChosenWrong = wrongBy[q.ID]
|
|
}
|
|
out.Unreviewed += st.Unreviewed
|
|
out.Questions = append(out.Questions, st)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ManagedAnswerInput is one answer of a question write. An ID keeps and
|
|
// updates that answer of the question; no ID creates one. Explanation nil
|
|
// keeps a kept answer's explanation, "" clears it.
|
|
type ManagedAnswerInput struct {
|
|
ID *uint `json:"id"`
|
|
Text string `json:"text"`
|
|
IsCorrect bool `json:"is_correct"`
|
|
Explanation *string `json:"explanation"`
|
|
}
|
|
|
|
// QuestionInput is the fields of a question create or partial update; nil
|
|
// means not sent. Answers, when sent, is the question's full new answer
|
|
// list in order. An empty string clears category, answer_text,
|
|
// explanation and rubric; a max_length of 0 clears it (the default 2000
|
|
// applies).
|
|
type QuestionInput struct {
|
|
Text *string `json:"text"`
|
|
Type *string `json:"type"`
|
|
Category *string `json:"category"`
|
|
AnswerText *string `json:"answer_text"`
|
|
Explanation *string `json:"explanation"`
|
|
Points *int `json:"points"`
|
|
SortOrder *int `json:"sort_order"`
|
|
Rubric *string `json:"rubric"`
|
|
MaxLength *int `json:"max_length"`
|
|
Answers *[]ManagedAnswerInput `json:"answers"`
|
|
}
|
|
|
|
// MergeQuestion applies the sent fields of in, and its answers list when
|
|
// sent, to current (a zero Question for a create) and returns the result
|
|
// in the importer's shape, for CheckQuestion. An answer id that is not an
|
|
// answer of current, or is listed twice, is a problem.
|
|
func MergeQuestion(current models.Question, in QuestionInput) (ImportQuestion, []string) {
|
|
q := ImportQuestion{
|
|
Text: current.Text, Type: current.Type, Category: current.Category,
|
|
AnswerText: current.AnswerText, Explanation: current.Explanation,
|
|
Rubric: current.Rubric, MaxLength: current.MaxLength,
|
|
}
|
|
points := current.Points
|
|
if current.ID == 0 {
|
|
points = 1
|
|
}
|
|
if in.Text != nil {
|
|
q.Text = strings.TrimSpace(*in.Text)
|
|
}
|
|
if in.Type != nil {
|
|
q.Type = *in.Type
|
|
}
|
|
if in.Category != nil {
|
|
q.Category = nilIfBlank(*in.Category, true)
|
|
}
|
|
if in.AnswerText != nil {
|
|
q.AnswerText = nilIfBlank(*in.AnswerText, false)
|
|
}
|
|
if in.Explanation != nil {
|
|
q.Explanation = nilIfBlank(*in.Explanation, false)
|
|
}
|
|
if in.Points != nil {
|
|
points = *in.Points
|
|
}
|
|
if in.Rubric != nil {
|
|
q.Rubric = nilIfBlank(*in.Rubric, false)
|
|
}
|
|
if in.MaxLength != nil {
|
|
if *in.MaxLength == 0 {
|
|
q.MaxLength = nil
|
|
} else {
|
|
n := *in.MaxLength
|
|
q.MaxLength = &n
|
|
}
|
|
}
|
|
q.Points = &points
|
|
var problems []string
|
|
if in.Category != nil && utf8.RuneCountInString(strings.TrimSpace(*in.Category)) > 255 {
|
|
problems = append(problems, "category must be at most 255 characters")
|
|
}
|
|
if in.Answers == nil {
|
|
for _, a := range current.Answers {
|
|
q.Answers = append(q.Answers, ImportAnswer{Text: a.Text, IsCorrect: a.IsCorrect, Explanation: a.Explanation})
|
|
}
|
|
return q, problems
|
|
}
|
|
byID := map[uint]models.Answer{}
|
|
for _, a := range current.Answers {
|
|
byID[a.ID] = a
|
|
}
|
|
seen := map[uint]bool{}
|
|
q.Answers = make([]ImportAnswer, 0, len(*in.Answers))
|
|
for i, a := range *in.Answers {
|
|
out := ImportAnswer{Text: strings.TrimSpace(a.Text), IsCorrect: a.IsCorrect, Explanation: a.Explanation}
|
|
if a.ID != nil {
|
|
kept, ok := byID[*a.ID]
|
|
switch {
|
|
case !ok:
|
|
problems = append(problems, fmt.Sprintf("answers[%d].id does not belong to this question", i))
|
|
case seen[*a.ID]:
|
|
problems = append(problems, fmt.Sprintf("answers[%d].id is listed twice", i))
|
|
case a.Explanation == nil:
|
|
out.Explanation = kept.Explanation
|
|
}
|
|
seen[*a.ID] = true
|
|
}
|
|
if out.Explanation != nil {
|
|
out.Explanation = nilIfBlank(*out.Explanation, false)
|
|
}
|
|
q.Answers = append(q.Answers, out)
|
|
}
|
|
return q, problems
|
|
}
|
|
|
|
// CheckNewQuestion is the rule check of a create, before any database
|
|
// access: text and type are required, answers carry no id, and the merged
|
|
// question passes CheckQuestion.
|
|
func CheckNewQuestion(in QuestionInput) []string {
|
|
var problems []string
|
|
if in.Text == nil {
|
|
problems = append(problems, "text is required")
|
|
}
|
|
if in.Type == nil {
|
|
problems = append(problems, "type is required")
|
|
}
|
|
if len(problems) > 0 {
|
|
return problems
|
|
}
|
|
merged, problems := MergeQuestion(models.Question{}, in)
|
|
if in.SortOrder != nil && *in.SortOrder < 1 {
|
|
problems = append(problems, "sort_order must be at least 1")
|
|
}
|
|
return append(problems, CheckQuestion("question", merged)...)
|
|
}
|
|
|
|
// managedQuizID loads the id of the quiz with this slug, or ErrNotFound.
|
|
func managedQuizID(tx *gorm.DB, slug string) (uint, error) {
|
|
var quiz models.Quiz
|
|
if err := lockedQuiz(tx, slug, &quiz); err != nil {
|
|
return 0, err
|
|
}
|
|
return quiz.ID, nil
|
|
}
|
|
|
|
// CreateQuestion adds a question with its answers to the quiz with this
|
|
// slug: at sort_order when given, else after the last question. Points
|
|
// default to 1; answers are ordered as sent. A missing slug is ErrNotFound.
|
|
func CreateQuestion(ctx context.Context, db *gorm.DB, slug string, in QuestionInput) (*models.Question, error) {
|
|
if err := invalid(CheckNewQuestion(in)); err != nil {
|
|
return nil, err
|
|
}
|
|
merged, _ := MergeQuestion(models.Question{}, in)
|
|
var q models.Question
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
quizID, err := managedQuizID(tx, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
order := 0
|
|
if in.SortOrder != nil {
|
|
order = *in.SortOrder
|
|
} else if err := tx.Model(&models.Question{}).Where("quiz_id = ?", quizID).
|
|
Select("COALESCE(MAX(sort_order), 0) + 1").Scan(&order).Error; err != nil {
|
|
return err
|
|
}
|
|
q = models.Question{
|
|
QuizID: quizID, Text: merged.Text, Type: merged.Type, Category: merged.Category,
|
|
AnswerText: merged.AnswerText, Explanation: merged.Explanation, SortOrder: order, Points: *merged.Points,
|
|
Rubric: merged.Rubric, MaxLength: merged.MaxLength,
|
|
}
|
|
if err := tx.Omit(clause.Associations).Create(&q).Error; err != nil {
|
|
return err
|
|
}
|
|
for i, a := range merged.Answers {
|
|
ans := models.Answer{QuestionID: q.ID, Text: a.Text, IsCorrect: a.IsCorrect, Explanation: a.Explanation, SortOrder: i + 1}
|
|
if err := tx.Omit(clause.Associations).Create(&ans).Error; err != nil {
|
|
return err
|
|
}
|
|
q.Answers = append(q.Answers, ans)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &q, nil
|
|
}
|
|
|
|
// UpdateQuestion changes only the sent fields of one question of the quiz
|
|
// with this slug. When answers is sent it is the full new list: answers
|
|
// with an id are updated in place (so the answer ids stored in past
|
|
// attempts stay valid), answers without one are created, answers missing
|
|
// from the list are deleted, and the order is the list order. The merged
|
|
// question must pass CheckQuestion. Stored attempt answers and scores are
|
|
// never touched (D-16). A missing quiz is ErrNotFound, a missing question
|
|
// ErrQuestionNotFound.
|
|
func UpdateQuestion(ctx context.Context, db *gorm.DB, slug string, id uint, in QuestionInput) (*models.Question, error) {
|
|
if in.SortOrder != nil && *in.SortOrder < 1 {
|
|
return nil, invalid([]string{"sort_order must be at least 1"})
|
|
}
|
|
var q models.Question
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
quizID, err := managedQuizID(tx, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = tx.Clauses(clause.Locking{Strength: "UPDATE", Table: clause.Table{Name: clause.CurrentTable}}).
|
|
Where("id = ? AND quiz_id = ?", id, quizID).Take(&q).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrQuestionNotFound
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := tx.Where("question_id = ?", q.ID).Order("sort_order ASC, id ASC").Find(&q.Answers).Error; err != nil {
|
|
return err
|
|
}
|
|
merged, problems := MergeQuestion(q, in)
|
|
if err := invalid(append(problems, CheckQuestion("question", merged)...)); err != nil {
|
|
return err
|
|
}
|
|
q.Text, q.Type, q.Category = merged.Text, merged.Type, merged.Category
|
|
q.AnswerText, q.Explanation, q.Points = merged.AnswerText, merged.Explanation, *merged.Points
|
|
q.Rubric, q.MaxLength = merged.Rubric, merged.MaxLength
|
|
if in.SortOrder != nil {
|
|
q.SortOrder = *in.SortOrder
|
|
}
|
|
if err := tx.Omit(clause.Associations).Save(&q).Error; err != nil {
|
|
return err
|
|
}
|
|
if in.Answers == nil {
|
|
return nil
|
|
}
|
|
current := map[uint]models.Answer{}
|
|
for _, a := range q.Answers {
|
|
current[a.ID] = a
|
|
}
|
|
answers := make([]models.Answer, 0, len(merged.Answers))
|
|
keep := map[uint]bool{}
|
|
for i, a := range merged.Answers {
|
|
ans := models.Answer{QuestionID: q.ID}
|
|
if sent := (*in.Answers)[i]; sent.ID != nil {
|
|
ans = current[*sent.ID]
|
|
keep[ans.ID] = true
|
|
}
|
|
ans.Text, ans.IsCorrect, ans.Explanation, ans.SortOrder = a.Text, a.IsCorrect, a.Explanation, i+1
|
|
if err := tx.Omit(clause.Associations).Save(&ans).Error; err != nil {
|
|
return err
|
|
}
|
|
answers = append(answers, ans)
|
|
}
|
|
for _, a := range q.Answers {
|
|
if !keep[a.ID] {
|
|
if err := tx.Where("id = ?", a.ID).Delete(&models.Answer{}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
q.Answers = answers
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &q, nil
|
|
}
|
|
|
|
// DeleteQuestion removes one question of the quiz with this slug. The
|
|
// foreign keys cascade to its answers and the attempt answers given to
|
|
// it; finished attempts keep their stored scores (D-16).
|
|
func DeleteQuestion(ctx context.Context, db *gorm.DB, slug string, id uint) error {
|
|
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
quizID, err := managedQuizID(tx, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
res := tx.Where("id = ? AND quiz_id = ?", id, quizID).Delete(&models.Question{})
|
|
if res.Error != nil {
|
|
return res.Error
|
|
}
|
|
if res.RowsAffected == 0 {
|
|
return ErrQuestionNotFound
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// CheckQuestionOrder is the check of a reorder list before any database
|
|
// access: not empty, no id twice.
|
|
func CheckQuestionOrder(ids []uint) []string {
|
|
seen := map[uint]bool{}
|
|
for _, id := range ids {
|
|
if seen[id] {
|
|
return []string{"question_ids must list every question of the quiz exactly once"}
|
|
}
|
|
seen[id] = true
|
|
}
|
|
if len(ids) == 0 {
|
|
return []string{"question_ids must list every question of the quiz exactly once"}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReorderQuestions sets sort_order = index + 1 for ids, which must be
|
|
// exactly the quiz's question ids, and returns the managed quiz.
|
|
func ReorderQuestions(ctx context.Context, db *gorm.DB, slug string, ids []uint) (*models.Quiz, error) {
|
|
if err := invalid(CheckQuestionOrder(ids)); err != nil {
|
|
return nil, err
|
|
}
|
|
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
|
|
tx = tx.WithContext(ctx)
|
|
quizID, err := managedQuizID(tx, slug)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var have []uint
|
|
if err := tx.Model(&models.Question{}).Where("quiz_id = ?", quizID).Pluck("id", &have).Error; err != nil {
|
|
return err
|
|
}
|
|
sorted := slices.Clone(ids)
|
|
slices.Sort(sorted)
|
|
slices.Sort(have)
|
|
if !slices.Equal(sorted, have) {
|
|
return invalid([]string{"question_ids must list every question of the quiz exactly once"})
|
|
}
|
|
now := time.Now().UTC()
|
|
for i, id := range ids {
|
|
if err := tx.Model(&models.Question{}).Where("id = ?", id).
|
|
Updates(map[string]any{"sort_order": i + 1, "updated_at": now}).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return ManagedQuiz(ctx, db, slug)
|
|
}
|