feat: create, publish, import and report on quizzes over MCP
- quizzes_create_quiz, update_quiz, delete_quiz (confirm repeats the slug), publish, unpublish, import_quiz (the existing import route) and quiz_stats on new manage routes - stats close expired open test attempts first, then report per student over completed whole-quiz attempts and per question over every completed attempt - input is checked before the database; TestManageAPIAgainstDatabase is written and skipped under -short
This commit is contained in:
@@ -8,11 +8,300 @@ package classes
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -95,3 +384,210 @@ func CompletedAttemptCount(ctx context.Context, db *gorm.DB, quizID uint) (int,
|
||||
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 question and when no wrong answer was chosen.
|
||||
type QuestionStat struct {
|
||||
ID uint
|
||||
Text string
|
||||
Type string
|
||||
Answered int
|
||||
Correct int
|
||||
CorrectPercent *float64
|
||||
MostChosenWrong *WrongAnswerStat
|
||||
}
|
||||
|
||||
// Stats is the owner's report on one quiz (D-15).
|
||||
type Stats struct {
|
||||
Quiz models.Quiz
|
||||
QuestionCount int
|
||||
Students []StudentStat
|
||||
Questions []QuestionStat
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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`
|
||||
|
||||
statsAnswersSQL = `SELECT aa.question_id, COUNT(*) AS answered, COUNT(*) FILTER (WHERE aa.is_correct) AS correct
|
||||
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
|
||||
}
|
||||
if q.Type != models.TypeSelfCheck {
|
||||
st.MostChosenWrong = wrongBy[q.ID]
|
||||
}
|
||||
out.Questions = append(out.Questions, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
114
classes/manage_test.go
Normal file
114
classes/manage_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package classes_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
)
|
||||
|
||||
func str(s string) *string { return &s }
|
||||
func num(n int) *int { return &n }
|
||||
|
||||
func TestCheckQuizInput(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
in classes.QuizInput
|
||||
create bool
|
||||
want string
|
||||
}{
|
||||
"missing title": {classes.QuizInput{}, true, "title is required"},
|
||||
"blank title": {classes.QuizInput{Title: str(" ")}, true, "title is required"},
|
||||
"long title": {classes.QuizInput{Title: str(strings.Repeat("a", 256))}, true, "title must be at most 255"},
|
||||
"mode exam": {classes.QuizInput{Title: str("A"), Mode: str("exam")}, true, "mode must be one of"},
|
||||
"navigation back": {classes.QuizInput{Title: str("A"), Navigation: str("back")}, true, "navigation must be one of"},
|
||||
"passing score 101": {classes.QuizInput{Title: str("A"), PassingScore: num(101)}, true, "passing_score must be between"},
|
||||
"max attempts 0": {classes.QuizInput{Title: str("A"), MaxAttempts: num(0)}, true, "max_attempts must be at least 1"},
|
||||
"time limit 0": {classes.QuizInput{Title: str("A"), TimeLimitSeconds: num(0)}, true, "time_limit_seconds must be at least 1"},
|
||||
"long context": {classes.QuizInput{Title: str("A"), Context: str(strings.Repeat("c", 256))}, true, "context must be at most 255"},
|
||||
"update blank title": {classes.QuizInput{Title: str("")}, false, "title is required"},
|
||||
"update negative limit": {classes.QuizInput{MaxAttempts: num(-1)}, false, "max_attempts must be 0"},
|
||||
} {
|
||||
got := strings.Join(classes.CheckQuizInput(tc.in, tc.create), "; ")
|
||||
if !strings.Contains(got, tc.want) {
|
||||
t.Errorf("%s: problems %q, want %q", name, got, tc.want)
|
||||
}
|
||||
}
|
||||
if got := classes.CheckQuizInput(classes.QuizInput{Title: str("Pojęcia")}, true); len(got) != 0 {
|
||||
t.Errorf("minimal create: %v", got)
|
||||
}
|
||||
if got := classes.CheckQuizInput(classes.QuizInput{}, false); len(got) != 0 {
|
||||
t.Errorf("empty update: %v", got)
|
||||
}
|
||||
clears := classes.QuizInput{MaxAttempts: num(0), TimeLimitSeconds: num(0), RoundSize: num(0), Description: str(""), Context: str("")}
|
||||
if got := classes.CheckQuizInput(clears, false); len(got) != 0 {
|
||||
t.Errorf("update clearing with 0: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeleteConfirm(t *testing.T) {
|
||||
if err := classes.ValidateDeleteConfirm("a", "a"); err != nil {
|
||||
t.Errorf("matching confirm: %v", err)
|
||||
}
|
||||
for _, confirm := range []string{"", "b", " a"} {
|
||||
err := classes.ValidateDeleteConfirm("a", confirm)
|
||||
var invalid *classes.ValidationError
|
||||
if !errors.As(err, &invalid) || !strings.Contains(err.Error(), "confirm") {
|
||||
t.Errorf("confirm %q: %v, want a ValidationError naming confirm", confirm, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStats(t *testing.T) {
|
||||
quiz := models.Quiz{ID: 1, Slug: "q", PassingScore: 60}
|
||||
questions := []models.Question{
|
||||
{ID: 10, Text: "single", Type: models.TypeSingle},
|
||||
{ID: 11, Text: "card", Type: models.TypeSelfCheck},
|
||||
{ID: 12, Text: "unanswered", Type: models.TypeMultiple},
|
||||
{ID: 13, Text: "all right", Type: models.TypeSingle},
|
||||
}
|
||||
best1, best2, last := 75.0, 50.0, 40.0
|
||||
students := []classes.StudentRow{
|
||||
{UserID: 5, Email: "a@x", Attempts: 2, BestScorePercent: &best1, LastScorePercent: &last},
|
||||
{UserID: 6, Email: "b@x", Attempts: 1, BestScorePercent: &best2, LastScorePercent: &best2},
|
||||
{UserID: 7, Email: "c@x", Attempts: 1},
|
||||
}
|
||||
answers := []classes.AnswerRow{
|
||||
{QuestionID: 10, Answered: 3, Correct: 2},
|
||||
{QuestionID: 11, Answered: 4, Correct: 1},
|
||||
{QuestionID: 13, Answered: 2, Correct: 2},
|
||||
}
|
||||
wrong := []classes.WrongRow{
|
||||
{QuestionID: 10, AnswerID: 102, Text: "second", N: 1},
|
||||
{QuestionID: 10, AnswerID: 101, Text: "first", N: 1},
|
||||
{QuestionID: 11, AnswerID: 999, Text: "ignored", N: 9},
|
||||
}
|
||||
s := classes.BuildStats(quiz, questions, students, answers, wrong)
|
||||
if s.QuestionCount != 4 || len(s.Questions) != 4 || len(s.Students) != 3 {
|
||||
t.Fatalf("stats = %+v", s)
|
||||
}
|
||||
if !s.Students[0].Passed || s.Students[1].Passed || s.Students[2].Passed {
|
||||
t.Errorf("passed = %v %v %v, want true false false", s.Students[0].Passed, s.Students[1].Passed, s.Students[2].Passed)
|
||||
}
|
||||
q := s.Questions[0]
|
||||
if q.CorrectPercent == nil || *q.CorrectPercent != 66.7 {
|
||||
t.Errorf("correct_percent 2 of 3 = %v, want 66.7", q.CorrectPercent)
|
||||
}
|
||||
if q.MostChosenWrong == nil || q.MostChosenWrong.AnswerID != 101 || q.MostChosenWrong.Count != 1 {
|
||||
t.Errorf("most_chosen_wrong = %+v, want answer 101 (lowest id on a tie)", q.MostChosenWrong)
|
||||
}
|
||||
if s.Questions[1].MostChosenWrong != nil {
|
||||
t.Errorf("self_check most_chosen_wrong = %+v, want nil", s.Questions[1].MostChosenWrong)
|
||||
}
|
||||
if un := s.Questions[2]; un.ID != 12 || un.Answered != 0 || un.CorrectPercent != nil || un.MostChosenWrong != nil {
|
||||
t.Errorf("unanswered question = %+v", un)
|
||||
}
|
||||
if ok := s.Questions[3]; ok.CorrectPercent == nil || *ok.CorrectPercent != 100 || ok.MostChosenWrong != nil {
|
||||
t.Errorf("all-correct question = %+v", ok)
|
||||
}
|
||||
more := append(wrong, classes.WrongRow{QuestionID: 10, AnswerID: 103, Text: "third", N: 2})
|
||||
if w := classes.BuildStats(quiz, questions, nil, answers, more).Questions[0].MostChosenWrong; w == nil || w.AnswerID != 103 {
|
||||
t.Errorf("most_chosen_wrong by count = %+v, want 103", w)
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,15 @@ package api
|
||||
// manage_json.go, which carry the solutions.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -30,11 +34,15 @@ func (h *Handlers) manageDB(w http.ResponseWriter) (*gorm.DB, bool) {
|
||||
}
|
||||
|
||||
// writeManaged answers {data: data}, or maps err: ErrNotFound is a 404,
|
||||
// ErrQuizExists a 409, anything else an opaque 500.
|
||||
// ErrQuizExists a 409, a *classes.ValidationError the import route's 422
|
||||
// {"message"}, anything else an opaque 500.
|
||||
func writeManaged(w http.ResponseWriter, status int, data any, err error) {
|
||||
var invalid *classes.ValidationError
|
||||
switch {
|
||||
case err == nil:
|
||||
wire.WriteJSON(w, status, map[string]any{"data": data})
|
||||
case errors.As(err, &invalid):
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": invalid.Error()})
|
||||
case errors.Is(err, classes.ErrNotFound):
|
||||
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Quiz not found."})
|
||||
case errors.Is(err, classes.ErrQuizExists):
|
||||
@@ -87,3 +95,159 @@ func (h *Handlers) writeManagedQuiz(w http.ResponseWriter, r *http.Request, db *
|
||||
}
|
||||
writeManaged(w, status, managedQuizDetail(*quiz, attempts), nil)
|
||||
}
|
||||
|
||||
// readManaged reads a JSON body into dest, refusing unknown keys: 413 over
|
||||
// the route's body limit, 400 for an unreadable body, 422 {"message"} for
|
||||
// invalid JSON. It writes the answer and reports false on failure.
|
||||
func readManaged(w http.ResponseWriter, r *http.Request, dest any) bool {
|
||||
raw, err := io.ReadAll(r.Body)
|
||||
var tooLarge *http.MaxBytesError
|
||||
switch {
|
||||
case errors.As(err, &tooLarge):
|
||||
wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": true, "message": "Request body too large."})
|
||||
return false
|
||||
case err != nil:
|
||||
wire.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": true, "message": "Unreadable request body."})
|
||||
return false
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dest); err != nil {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": "invalid JSON: " + err.Error()})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// refuse answers the 422 of a ValidationError and reports whether there
|
||||
// were problems.
|
||||
func refuse(w http.ResponseWriter, problems []string) bool {
|
||||
if len(problems) == 0 {
|
||||
return false
|
||||
}
|
||||
writeManaged(w, 0, nil, &classes.ValidationError{Problems: problems})
|
||||
return true
|
||||
}
|
||||
|
||||
// updateQuizBody is the PATCH /{slug} body: QuizInput without the slug,
|
||||
// which an update cannot change, so sending it is refused as unknown.
|
||||
type updateQuizBody struct {
|
||||
Title *string `json:"title"`
|
||||
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"`
|
||||
}
|
||||
|
||||
func (b updateQuizBody) input() classes.QuizInput {
|
||||
return classes.QuizInput{
|
||||
Title: b.Title, Description: b.Description, Context: b.Context, Mode: b.Mode, Navigation: b.Navigation,
|
||||
MaxAttempts: b.MaxAttempts, TimeLimitSeconds: b.TimeLimitSeconds, PassingScore: b.PassingScore,
|
||||
RoundSize: b.RoundSize, ShuffleQuestions: b.ShuffleQuestions,
|
||||
}
|
||||
}
|
||||
|
||||
// answerQuiz counts and answers a quiz a write returned, or maps err.
|
||||
func answerQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, quiz *models.Quiz, err error) {
|
||||
if err != nil {
|
||||
writeManaged(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
row, err := classes.ManagedRow(r.Context(), db, *quiz)
|
||||
if err != nil {
|
||||
writeManaged(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
writeManaged(w, status, managedQuizRow(row), nil)
|
||||
}
|
||||
|
||||
// ManageCreateQuiz answers POST /api/v1/quizzes: 201 with a new,
|
||||
// unpublished quiz, 409 on a taken slug. The input is checked before the
|
||||
// database is touched.
|
||||
func (h *Handlers) ManageCreateQuiz(w http.ResponseWriter, r *http.Request) {
|
||||
var in classes.QuizInput
|
||||
if !readManaged(w, r, &in) || refuse(w, classes.CheckQuizInput(in, true)) {
|
||||
return
|
||||
}
|
||||
db, ok := h.manageDB(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
quiz, err := classes.CreateQuiz(r.Context(), db, in)
|
||||
answerQuiz(w, r, db, http.StatusCreated, quiz, err)
|
||||
}
|
||||
|
||||
// ManageUpdateQuiz answers PATCH /api/v1/quizzes/{slug}: only the sent
|
||||
// fields change.
|
||||
func (h *Handlers) ManageUpdateQuiz(w http.ResponseWriter, r *http.Request) {
|
||||
var body updateQuizBody
|
||||
if !readManaged(w, r, &body) || refuse(w, classes.CheckQuizInput(body.input(), false)) {
|
||||
return
|
||||
}
|
||||
db, ok := h.manageDB(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
quiz, err := classes.UpdateQuiz(r.Context(), db, r.PathValue("slug"), body.input())
|
||||
answerQuiz(w, r, db, http.StatusOK, quiz, err)
|
||||
}
|
||||
|
||||
// ManageDeleteQuiz answers DELETE /api/v1/quizzes/{slug}?confirm={slug}:
|
||||
// the quiz goes with its questions, answers and attempts. A confirm that
|
||||
// does not repeat the slug is a 422 before the database is read.
|
||||
func (h *Handlers) ManageDeleteQuiz(w http.ResponseWriter, r *http.Request) {
|
||||
slug := r.PathValue("slug")
|
||||
if err := classes.ValidateDeleteConfirm(slug, r.URL.Query().Get("confirm")); err != nil {
|
||||
writeManaged(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
db, ok := h.manageDB(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
del, err := classes.DeleteQuiz(r.Context(), db, slug)
|
||||
if err != nil {
|
||||
writeManaged(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
writeManaged(w, http.StatusOK, map[string]any{
|
||||
"id": del.ID, "slug": del.Slug, "title": del.Title, "questions": del.Questions, "attempts": del.Attempts,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// ManagePublish answers POST /api/v1/quizzes/{slug}/publish.
|
||||
func (h *Handlers) ManagePublish(w http.ResponseWriter, r *http.Request) { h.setPublished(w, r, true) }
|
||||
|
||||
// ManageUnpublish answers POST /api/v1/quizzes/{slug}/unpublish.
|
||||
func (h *Handlers) ManageUnpublish(w http.ResponseWriter, r *http.Request) {
|
||||
h.setPublished(w, r, false)
|
||||
}
|
||||
|
||||
func (h *Handlers) setPublished(w http.ResponseWriter, r *http.Request, published bool) {
|
||||
db, ok := h.manageDB(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
quiz, err := classes.SetPublished(r.Context(), db, r.PathValue("slug"), published)
|
||||
answerQuiz(w, r, db, http.StatusOK, quiz, err)
|
||||
}
|
||||
|
||||
// ManageQuizStats answers GET /api/v1/quizzes/{slug}/stats: per student
|
||||
// and per question, after closing the quiz's expired open test attempts.
|
||||
func (h *Handlers) ManageQuizStats(w http.ResponseWriter, r *http.Request) {
|
||||
svc, ok := h.service(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
stats, err := classes.QuizStats(r.Context(), svc.DB, r.PathValue("slug"), svc)
|
||||
if err != nil {
|
||||
writeManaged(w, 0, nil, err)
|
||||
return
|
||||
}
|
||||
writeManaged(w, http.StatusOK, quizStats(stats), nil)
|
||||
}
|
||||
|
||||
@@ -114,3 +114,78 @@ func managedQuestion(q models.Question) managedQuestionJSON {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type statsJSON struct {
|
||||
Quiz statsQuizJSON `json:"quiz"`
|
||||
Students []studentStatJSON `json:"students"`
|
||||
Questions []questionStatJSON `json:"questions"`
|
||||
}
|
||||
|
||||
type statsQuizJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Mode string `json:"mode"`
|
||||
PassingScore int `json:"passing_score"`
|
||||
QuestionCount int `json:"question_count"`
|
||||
}
|
||||
|
||||
type studentStatJSON struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Name *string `json:"name"`
|
||||
Surname *string `json:"surname"`
|
||||
Attempts int `json:"attempts"`
|
||||
BestScorePercent *float64 `json:"best_score_percent"`
|
||||
LastScorePercent *float64 `json:"last_score_percent"`
|
||||
LastCompletedAt *wire.Time `json:"last_completed_at"`
|
||||
Passed bool `json:"passed"`
|
||||
}
|
||||
|
||||
type questionStatJSON struct {
|
||||
ID uint `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"`
|
||||
Answered int `json:"answered"`
|
||||
Correct int `json:"correct"`
|
||||
CorrectPercent *float64 `json:"correct_percent"`
|
||||
MostChosenWrong *wrongAnswerJSON `json:"most_chosen_wrong"`
|
||||
}
|
||||
|
||||
type wrongAnswerJSON struct {
|
||||
AnswerID uint `json:"answer_id"`
|
||||
Text string `json:"text"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
func quizStats(s *classes.Stats) statsJSON {
|
||||
out := statsJSON{
|
||||
Quiz: statsQuizJSON{
|
||||
ID: s.Quiz.ID, Slug: s.Quiz.Slug, Title: s.Quiz.Title, Mode: quizMode(&s.Quiz),
|
||||
PassingScore: s.Quiz.PassingScore, QuestionCount: s.QuestionCount,
|
||||
},
|
||||
Students: make([]studentStatJSON, 0, len(s.Students)),
|
||||
Questions: make([]questionStatJSON, 0, len(s.Questions)),
|
||||
}
|
||||
for _, st := range s.Students {
|
||||
row := studentStatJSON{
|
||||
UserID: st.UserID, Email: st.Email, Name: st.Name, Surname: st.Surname, Attempts: st.Attempts,
|
||||
BestScorePercent: st.BestScorePercent, LastScorePercent: st.LastScorePercent, Passed: st.Passed,
|
||||
}
|
||||
if st.LastCompletedAt != nil {
|
||||
t := wireTime(*st.LastCompletedAt)
|
||||
row.LastCompletedAt = &t
|
||||
}
|
||||
out.Students = append(out.Students, row)
|
||||
}
|
||||
for _, q := range s.Questions {
|
||||
row := questionStatJSON{
|
||||
ID: q.ID, Text: q.Text, Type: q.Type, Answered: q.Answered, Correct: q.Correct, CorrectPercent: q.CorrectPercent,
|
||||
}
|
||||
if q.MostChosenWrong != nil {
|
||||
row.MostChosenWrong = &wrongAnswerJSON{AnswerID: q.MostChosenWrong.AnswerID, Text: q.MostChosenWrong.Text, Count: q.MostChosenWrong.Count}
|
||||
}
|
||||
out.Questions = append(out.Questions, row)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -2,21 +2,64 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
)
|
||||
|
||||
// TestManageHandlersWithoutDatabase: every manage handler answers 503
|
||||
// when the application has no database.
|
||||
// when the application has no database and the input is valid.
|
||||
func TestManageHandlersWithoutDatabase(t *testing.T) {
|
||||
h := New(backpack.New(nil))
|
||||
for name, fn := range map[string]http.HandlerFunc{
|
||||
"list": h.ManageListQuizzes, "show": h.ManageShowQuiz,
|
||||
for name, tc := range map[string]struct {
|
||||
fn http.HandlerFunc
|
||||
method string
|
||||
target string
|
||||
body string
|
||||
}{
|
||||
"list": {h.ManageListQuizzes, "GET", "/", ""},
|
||||
"show": {h.ManageShowQuiz, "GET", "/", ""},
|
||||
"create": {h.ManageCreateQuiz, "POST", "/", `{"title":"Pojęcia"}`},
|
||||
"update": {h.ManageUpdateQuiz, "PATCH", "/", `{"max_attempts":0}`},
|
||||
"delete": {h.ManageDeleteQuiz, "DELETE", "/?confirm=x", ""},
|
||||
"publish": {h.ManagePublish, "POST", "/", ""},
|
||||
"unpublish": {h.ManageUnpublish, "POST", "/", ""},
|
||||
"stats": {h.ManageQuizStats, "GET", "/", ""},
|
||||
} {
|
||||
code, _ := call(t, fn, "GET", "/", "", 7, map[string]string{"slug": "x"})
|
||||
code, _ := call(t, tc.fn, tc.method, tc.target, tc.body, 7, map[string]string{"slug": "x"})
|
||||
if code != http.StatusServiceUnavailable {
|
||||
t.Errorf("%s without a database = %d, want 503", name, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestManageWritesRefuseBadInputBeforeDatabase: invalid input is a 422
|
||||
// even without a database, so it is checked before any read.
|
||||
func TestManageWritesRefuseBadInputBeforeDatabase(t *testing.T) {
|
||||
h := New(backpack.New(nil))
|
||||
for name, tc := range map[string]struct {
|
||||
fn http.HandlerFunc
|
||||
method string
|
||||
target string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
"create invalid JSON": {h.ManageCreateQuiz, "POST", "/", `{"title":`, "invalid JSON"},
|
||||
"create empty body": {h.ManageCreateQuiz, "POST", "/", "", "invalid JSON"},
|
||||
"create is_published": {h.ManageCreateQuiz, "POST", "/", `{"title":"A","is_published":true}`, "is_published"},
|
||||
"create missing title": {h.ManageCreateQuiz, "POST", "/", `{"slug":"a"}`, "title is required"},
|
||||
"create mode exam": {h.ManageCreateQuiz, "POST", "/", `{"title":"A","mode":"exam"}`, "mode must be one of"},
|
||||
"update slug": {h.ManageUpdateQuiz, "PATCH", "/", `{"slug":"b"}`, "slug"},
|
||||
"update is_published": {h.ManageUpdateQuiz, "PATCH", "/", `{"is_published":true}`, "is_published"},
|
||||
"update passing 101": {h.ManageUpdateQuiz, "PATCH", "/", `{"passing_score":101}`, "passing_score"},
|
||||
"delete without confirm": {h.ManageDeleteQuiz, "DELETE", "/", "", "confirm must repeat the quiz slug"},
|
||||
"delete wrong confirm": {h.ManageDeleteQuiz, "DELETE", "/?confirm=y", "", "confirm must repeat the quiz slug"},
|
||||
} {
|
||||
code, out := call(t, tc.fn, tc.method, tc.target, tc.body, 7, map[string]string{"slug": "x"})
|
||||
msg, _ := out["message"].(string)
|
||||
if code != http.StatusUnprocessableEntity || !strings.Contains(msg, tc.want) {
|
||||
t.Errorf("%s = %d %v, want 422 with %q", name, code, out, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
202
manage_api_postgres_test.go
Normal file
202
manage_api_postgres_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package quizzes_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
)
|
||||
|
||||
// TestManageAPIAgainstDatabase covers the owner manage routes the MCP quiz
|
||||
// tools bind (D-13..D-16): access, quiz CRUD, publish, show with
|
||||
// solutions, stats with the lazy close of expired test attempts, delete
|
||||
// with confirm and the import route. Skipped under -short.
|
||||
func TestManageAPIAgainstDatabase(t *testing.T) {
|
||||
app, plugins, gdb := migrated(t)
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
must(t, err)
|
||||
c := &client{t: t, h: h}
|
||||
const prefix = "/api/v1/quizzes"
|
||||
|
||||
editor, _ := newUser(t, gdb, "ala@example.com")
|
||||
stranger, _ := newUser(t, gdb, "ola@example.com")
|
||||
var groupID uint
|
||||
must(t, gdb.Raw(`INSERT INTO user_groups (name, code, permissions, created_at, updated_at)
|
||||
VALUES ('Editors', 'editors', '{"golem15.quizzes.access_quizzes":1}', NOW(), NOW()) RETURNING id`).Scan(&groupID).Error)
|
||||
must(t, gdb.Exec(`INSERT INTO users_groups (user_id, user_group_id) VALUES (?, ?)`, editor.ID, groupID).Error)
|
||||
token := apiToken(t, gdb, editor.ID, "read", "write")
|
||||
readOnly := apiToken(t, gdb, editor.ID, "read")
|
||||
|
||||
t.Run("access", func(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
method, path, token string
|
||||
want int
|
||||
}{
|
||||
"no token": {"GET", prefix, "", http.StatusUnauthorized},
|
||||
"read token on a write": {"POST", prefix, readOnly, http.StatusForbidden},
|
||||
"user without permission": {"GET", prefix, apiToken(t, gdb, stranger.ID, "read", "write"), http.StatusForbidden},
|
||||
} {
|
||||
if code, _ := c.do(tc.method, tc.path, tc.token, `{"title":"X"}`); code != tc.want {
|
||||
t.Errorf("%s: %s %s = %d, want %d", name, tc.method, tc.path, code, tc.want)
|
||||
}
|
||||
}
|
||||
if code, _ := c.do("GET", prefix, readOnly, ""); code != http.StatusOK {
|
||||
t.Errorf("read token on a read = %d, want 200", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create, update, publish and list", func(t *testing.T) {
|
||||
out := data(c.ok(http.StatusCreated, "POST", prefix, token, `{"title":"Pojęcia bezpieczeństwa","context":"BM2","mode":"test","max_attempts":2}`))
|
||||
if out["slug"] != "pojecia-bezpieczenstwa" || out["is_published"] != false || out["mode"] != "test" || out["max_attempts"] != float64(2) || out["passing_score"] != float64(50) {
|
||||
t.Fatalf("created = %v", out)
|
||||
}
|
||||
if code, _ := c.do("POST", prefix, token, `{"title":"Inny","slug":"pojecia-bezpieczenstwa"}`); code != http.StatusConflict {
|
||||
t.Errorf("taken slug = %d, want 409", code)
|
||||
}
|
||||
out = data(c.ok(http.StatusOK, "PATCH", prefix+"/pojecia-bezpieczenstwa", token, `{"max_attempts":0,"description":"Opis"}`))
|
||||
if out["max_attempts"] != nil || out["description"] != "Opis" || out["mode"] != "test" {
|
||||
t.Errorf("update clearing max_attempts = %v", out)
|
||||
}
|
||||
out = data(c.ok(http.StatusOK, "PATCH", prefix+"/pojecia-bezpieczenstwa", token, `{"description":""}`))
|
||||
if out["description"] != nil {
|
||||
t.Errorf("empty description = %v, want null", out["description"])
|
||||
}
|
||||
if code, _ := c.do("PATCH", prefix+"/pojecia-bezpieczenstwa", token, `{"is_published":true}`); code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("update with is_published = %d, want 422", code)
|
||||
}
|
||||
for range 2 {
|
||||
if out := data(c.ok(http.StatusOK, "POST", prefix+"/pojecia-bezpieczenstwa/publish", token, "")); out["is_published"] != true {
|
||||
t.Errorf("publish = %v", out)
|
||||
}
|
||||
}
|
||||
data(c.ok(http.StatusOK, "POST", prefix+"/pojecia-bezpieczenstwa/unpublish", token, ""))
|
||||
c.ok(http.StatusCreated, "POST", prefix, token, `{"title":"Drugi"}`)
|
||||
rows := list(c.ok(http.StatusOK, "GET", prefix, token, ""))
|
||||
if len(rows) != 2 || rows[0].(map[string]any)["context"] != "BM2" || rows[0].(map[string]any)["is_published"] != false {
|
||||
t.Errorf("list = %v, want two unpublished quizzes, BM2 first", rows)
|
||||
}
|
||||
if rows := list(c.ok(http.StatusOK, "GET", prefix+"?context=BM2", token, "")); len(rows) != 1 {
|
||||
t.Errorf("context filter = %v", rows)
|
||||
}
|
||||
if code, _ := c.do("GET", prefix+"/nie-ma", token, ""); code != http.StatusNotFound {
|
||||
t.Errorf("missing quiz = %d, want 404", code)
|
||||
}
|
||||
})
|
||||
|
||||
f := seedQuiz(t, gdb, "statystyka", func(q *models.Quiz) { q.PassingScore = 60 })
|
||||
|
||||
t.Run("show includes the solutions", func(t *testing.T) {
|
||||
out := data(c.ok(http.StatusOK, "GET", prefix+"/statystyka", token, ""))
|
||||
qs, _ := out["questions"].([]any)
|
||||
if len(qs) != 3 || out["question_count"] != float64(3) {
|
||||
t.Fatalf("show = %v", out)
|
||||
}
|
||||
first := qs[0].(map[string]any)
|
||||
if first["explanation"] != "Warszawa od 1596." {
|
||||
t.Errorf("question explanation = %v", first["explanation"])
|
||||
}
|
||||
answers := first["answers"].([]any)
|
||||
if answers[0].(map[string]any)["is_correct"] != true || answers[1].(map[string]any)["explanation"] != "Dawna stolica." {
|
||||
t.Errorf("answers = %v", answers)
|
||||
}
|
||||
if qs[2].(map[string]any)["answer_text"] != "Zdolność społeczeństwa do..." {
|
||||
t.Errorf("self_check answer_text = %v", qs[2])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stats", func(t *testing.T) {
|
||||
finish := func(email string, answers ...string) {
|
||||
t.Helper()
|
||||
_, jwt := newUser(t, gdb, email)
|
||||
att := data(c.ok(http.StatusCreated, "POST", apiPrefix+"/quizzes/statystyka/attempts", jwt, ""))
|
||||
path := fmt.Sprintf("%s/attempts/%d", apiPrefix, int(att["id"].(float64)))
|
||||
for _, body := range answers {
|
||||
c.ok(http.StatusOK, "POST", path+"/answers", jwt, body)
|
||||
}
|
||||
c.ok(http.StatusOK, "POST", path+"/finish", jwt, "")
|
||||
}
|
||||
right := fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleRight.ID)
|
||||
wrong := fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d]}`, f.single.ID, f.singleNo.ID)
|
||||
multiWrong := fmt.Sprintf(`{"question_id":%d,"answer_ids":[%d,%d]}`, f.multi.ID, f.multiA.ID, f.multiC.ID)
|
||||
known := fmt.Sprintf(`{"question_id":%d,"known":true}`, f.selfQ.ID)
|
||||
finish("s1@example.com", right, multiWrong, known)
|
||||
finish("s2@example.com", wrong)
|
||||
finish("s3@example.com", right)
|
||||
|
||||
out := data(c.ok(http.StatusOK, "GET", prefix+"/statystyka/stats", token, ""))
|
||||
students := out["students"].([]any)
|
||||
if len(students) != 3 {
|
||||
t.Fatalf("students = %v", students)
|
||||
}
|
||||
for _, s := range students {
|
||||
s := s.(map[string]any)
|
||||
if s["attempts"] != float64(1) || s["best_score_percent"] == nil || s["last_completed_at"] == nil {
|
||||
t.Errorf("student row = %v", s)
|
||||
}
|
||||
}
|
||||
qs := out["questions"].([]any)
|
||||
single := qs[0].(map[string]any)
|
||||
if single["answered"] != float64(3) || single["correct"] != float64(2) || single["correct_percent"] != 66.7 {
|
||||
t.Errorf("single question = %v", single)
|
||||
}
|
||||
if w, _ := single["most_chosen_wrong"].(map[string]any); w == nil || w["answer_id"] != float64(f.singleNo.ID) || w["count"] != float64(1) {
|
||||
t.Errorf("single most_chosen_wrong = %v", single["most_chosen_wrong"])
|
||||
}
|
||||
if w, _ := qs[1].(map[string]any)["most_chosen_wrong"].(map[string]any); w == nil || w["answer_id"] != float64(f.multiC.ID) {
|
||||
t.Errorf("multiple most_chosen_wrong = %v", qs[1])
|
||||
}
|
||||
if self := qs[2].(map[string]any); self["most_chosen_wrong"] != nil || self["correct"] != float64(1) {
|
||||
t.Errorf("self_check stats = %v", self)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stats closes an expired test attempt", func(t *testing.T) {
|
||||
tq := seedQuiz(t, gdb, "test-wygasly", func(q *models.Quiz) { q.Mode = models.ModeTest; q.TimeLimitSeconds = intPtr(60) })
|
||||
student, _ := newUser(t, gdb, "late@example.com")
|
||||
started := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Second)
|
||||
attempt := models.Attempt{QuizID: tq.quiz.ID, UserID: student.ID, StartedAt: started, MaxPoints: 4}
|
||||
must(t, gdb.Create(&attempt).Error)
|
||||
out := data(c.ok(http.StatusOK, "GET", prefix+"/test-wygasly/stats", token, ""))
|
||||
var closed models.Attempt
|
||||
must(t, gdb.Take(&closed, attempt.ID).Error)
|
||||
if closed.CompletedAt == nil || !closed.CompletedAt.Equal(started.Add(time.Minute)) {
|
||||
t.Errorf("expired attempt completed_at = %v, want the deadline %v", closed.CompletedAt, started.Add(time.Minute))
|
||||
}
|
||||
if students := out["students"].([]any); len(students) != 1 || students[0].(map[string]any)["passed"] != false {
|
||||
t.Errorf("students after lazy close = %v", students)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
code, body := c.do("DELETE", prefix+"/statystyka?confirm=inny", token, "")
|
||||
if code != http.StatusUnprocessableEntity || !strings.Contains(fmt.Sprint(body["message"]), "confirm") {
|
||||
t.Errorf("wrong confirm = %d %v", code, body)
|
||||
}
|
||||
out := data(c.ok(http.StatusOK, "DELETE", prefix+"/statystyka?confirm=statystyka", token, ""))
|
||||
if out["slug"] != "statystyka" || out["questions"] != float64(3) || out["attempts"] != float64(3) {
|
||||
t.Errorf("delete = %v", out)
|
||||
}
|
||||
var n int64
|
||||
must(t, gdb.Model(&models.Attempt{}).Where("quiz_id = ?", f.quiz.ID).Count(&n).Error)
|
||||
if n != 0 {
|
||||
t.Errorf("attempts after delete = %d", n)
|
||||
}
|
||||
if code, _ := c.do("GET", prefix+"/statystyka", token, ""); code != http.StatusNotFound {
|
||||
t.Errorf("deleted quiz = %d, want 404", code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("import still works", func(t *testing.T) {
|
||||
raw, err := os.ReadFile("testdata/quiz.json")
|
||||
must(t, err)
|
||||
c.ok(http.StatusOK, "POST", prefix+"/import", token, string(raw))
|
||||
if out := data(c.ok(http.StatusOK, "POST", prefix+"/import?replace=true", token, string(raw))); out["created"] != false {
|
||||
t.Errorf("import with replace=true = %v", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
152
mcp_quizzes.go
152
mcp_quizzes.go
@@ -29,6 +29,91 @@ type showQuizInput struct {
|
||||
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug (lowercase letters, digits and hyphens), as listed by quizzes_list_quizzes"`
|
||||
}
|
||||
|
||||
// The quiz fields a create or an update may send.
|
||||
type createQuizInput struct {
|
||||
Title string `json:"title" jsonschema:"the quiz title, 1-255 characters"`
|
||||
Slug string `json:"slug,omitempty" jsonschema:"the slug (lowercase letters, digits and hyphens; normalised); default: made from the title; 409 when taken"`
|
||||
Description *string `json:"description,omitempty" jsonschema:"a description shown above the quiz"`
|
||||
Context *string `json:"context,omitempty" jsonschema:"a free context label (at most 255 characters) the student app groups quizzes by, such as a course code"`
|
||||
Mode *string `json:"mode,omitempty" jsonschema:"learning (default: solutions after each answer) or test (solutions only after finishing)"`
|
||||
Navigation *string `json:"navigation,omitempty" jsonschema:"for a test: free (default; answers can change) or one_way (an answer is final)"`
|
||||
MaxAttempts *int `json:"max_attempts,omitempty" jsonschema:"for a test: attempts per student, at least 1; leave out for unlimited"`
|
||||
TimeLimitSeconds *int `json:"time_limit_seconds,omitempty" jsonschema:"time limit of an attempt in seconds, at least 1; leave out for none"`
|
||||
PassingScore *int `json:"passing_score,omitempty" jsonschema:"percent needed to pass, 0-100; default 50"`
|
||||
ShuffleQuestions *bool `json:"shuffle_questions,omitempty" jsonschema:"true to shuffle the question order"`
|
||||
RoundSize *int `json:"round_size,omitempty" jsonschema:"for learning: questions per round, at least 1; leave out for all"`
|
||||
}
|
||||
|
||||
// updateQuizInput is the input of quizzes_update_quiz: only sent fields
|
||||
// change.
|
||||
type updateQuizInput struct {
|
||||
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug, as listed by quizzes_list_quizzes; it cannot be changed"`
|
||||
Title *string `json:"title,omitempty" jsonschema:"the new title, 1-255 characters"`
|
||||
Description *string `json:"description,omitempty" jsonschema:"the new description; an empty string clears it"`
|
||||
Context *string `json:"context,omitempty" jsonschema:"the new context label (at most 255 characters); an empty string clears it"`
|
||||
Mode *string `json:"mode,omitempty" jsonschema:"learning or test"`
|
||||
Navigation *string `json:"navigation,omitempty" jsonschema:"free or one_way (tests only)"`
|
||||
MaxAttempts *int `json:"max_attempts,omitempty" jsonschema:"attempts per student in a test; 0 clears it (unlimited)"`
|
||||
TimeLimitSeconds *int `json:"time_limit_seconds,omitempty" jsonschema:"time limit in seconds; 0 clears it (none)"`
|
||||
PassingScore *int `json:"passing_score,omitempty" jsonschema:"percent needed to pass, 0-100"`
|
||||
ShuffleQuestions *bool `json:"shuffle_questions,omitempty" jsonschema:"true to shuffle the question order"`
|
||||
RoundSize *int `json:"round_size,omitempty" jsonschema:"questions per learning round; 0 clears it (all)"`
|
||||
}
|
||||
|
||||
// deleteQuizInput is the input of quizzes_delete_quiz.
|
||||
type deleteQuizInput struct {
|
||||
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"`
|
||||
Confirm string `json:"confirm" query:"confirm" jsonschema:"must repeat the slug exactly, or the delete is refused"`
|
||||
}
|
||||
|
||||
// publishInput is the input of quizzes_publish and quizzes_unpublish.
|
||||
type publishInput struct {
|
||||
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"`
|
||||
}
|
||||
|
||||
// statsInput is the input of quizzes_quiz_stats.
|
||||
type statsInput struct {
|
||||
Slug string `json:"slug" path:"slug" jsonschema:"the quiz slug"`
|
||||
}
|
||||
|
||||
// importQuizInput is the input of quizzes_import_quiz: the quizzes:import
|
||||
// document. The keys match classes.ImportQuiz exactly, because the
|
||||
// importer refuses unknown keys.
|
||||
type importQuizInput struct {
|
||||
Replace bool `json:"replace,omitempty" query:"replace" jsonschema:"true to replace an existing quiz with this slug (its fields, and all its questions are deleted and recreated); without it a taken slug is a 409"`
|
||||
Title string `json:"title" jsonschema:"the quiz title"`
|
||||
Slug string `json:"slug,omitempty" jsonschema:"the slug; default: made from the title"`
|
||||
Description *string `json:"description,omitempty" jsonschema:"a description"`
|
||||
Context *string `json:"context,omitempty" jsonschema:"a free context label, such as a course code"`
|
||||
IsPublished *bool `json:"is_published,omitempty" jsonschema:"true to publish the quiz at once"`
|
||||
Mode *string `json:"mode,omitempty" jsonschema:"learning or test"`
|
||||
Navigation *string `json:"navigation,omitempty" jsonschema:"free or one_way (tests only)"`
|
||||
MaxAttempts *int `json:"max_attempts,omitempty" jsonschema:"attempts per student in a test, at least 1"`
|
||||
TimeLimitSeconds *int `json:"time_limit_seconds,omitempty" jsonschema:"time limit in seconds, at least 1"`
|
||||
PassingScore *int `json:"passing_score,omitempty" jsonschema:"percent needed to pass, 0-100; default 50"`
|
||||
ShuffleQuestions *bool `json:"shuffle_questions,omitempty" jsonschema:"true to shuffle the question order"`
|
||||
RoundSize *int `json:"round_size,omitempty" jsonschema:"questions per learning round, at least 1"`
|
||||
Questions []importQuestionInput `json:"questions" jsonschema:"the questions in order; at least one"`
|
||||
}
|
||||
|
||||
// importQuestionInput is one question of an import document.
|
||||
type importQuestionInput struct {
|
||||
Text string `json:"text" jsonschema:"the question"`
|
||||
Type string `json:"type" jsonschema:"single (exactly one correct answer), multiple (one or more correct) or self_check (a flashcard with answer_text and no answers)"`
|
||||
Category *string `json:"category,omitempty" jsonschema:"a category for learning rounds"`
|
||||
AnswerText *string `json:"answer_text,omitempty" jsonschema:"the answer a self_check card reveals"`
|
||||
Explanation *string `json:"explanation,omitempty" jsonschema:"shown with the solution"`
|
||||
Points *int `json:"points,omitempty" jsonschema:"points, zero or more; default 1"`
|
||||
Answers []importAnswerInput `json:"answers,omitempty" jsonschema:"the choices of a single or multiple question, at least two"`
|
||||
}
|
||||
|
||||
// importAnswerInput is one answer of an import question.
|
||||
type importAnswerInput struct {
|
||||
Text string `json:"text" jsonschema:"the answer text"`
|
||||
IsCorrect bool `json:"is_correct,omitempty" jsonschema:"true for a correct answer"`
|
||||
Explanation *string `json:"explanation,omitempty" jsonschema:"shown with the solution"`
|
||||
}
|
||||
|
||||
func quizTools() []pact.MCPTool {
|
||||
return []pact.MCPTool{
|
||||
{
|
||||
@@ -49,5 +134,72 @@ func quizTools() []pact.MCPTool {
|
||||
Input: showQuizInput{},
|
||||
Permissions: quizPermissions,
|
||||
},
|
||||
{
|
||||
Name: "create_quiz",
|
||||
Title: "Create a quiz",
|
||||
Description: "Creates a quiz without questions and returns it with the fields of quizzes_list_quizzes. title is required; slug defaults to one made from the title, and a taken slug is refused with 409. Optional: description, context, mode (learning or test), navigation (free or one_way), max_attempts, time_limit_seconds, passing_score (default 50), shuffle_questions, round_size. The quiz starts unpublished; add questions with quizzes_create_question, then use quizzes_publish. To create a quiz with all its questions in one call use quizzes_import_quiz." + needsWrite,
|
||||
Method: http.MethodPost,
|
||||
Path: ManagePrefix,
|
||||
Input: createQuizInput{},
|
||||
Permissions: quizPermissions,
|
||||
},
|
||||
{
|
||||
Name: "update_quiz",
|
||||
Title: "Change quiz fields",
|
||||
Description: "Changes only the quiz fields you send: title, description, context, mode, navigation, max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size. 0 clears time_limit_seconds, max_attempts and round_size; an empty string clears description and context. It cannot publish or unpublish (use quizzes_publish / quizzes_unpublish) and cannot change the slug. Returns the quiz with the fields of quizzes_list_quizzes. Finished attempts keep their scores." + needsWrite,
|
||||
Method: http.MethodPatch,
|
||||
Path: ManagePrefix + "/{slug}",
|
||||
Input: updateQuizInput{},
|
||||
Permissions: quizPermissions,
|
||||
},
|
||||
{
|
||||
Name: "delete_quiz",
|
||||
Title: "Delete a quiz",
|
||||
Description: "Deletes a quiz for good, with every question, answer and student attempt. Refused unless confirm repeats the slug. Returns id, slug, title and the number of questions and attempts removed. To hide a quiz without deleting it use quizzes_unpublish." + needsWrite,
|
||||
Method: http.MethodDelete,
|
||||
Path: ManagePrefix + "/{slug}",
|
||||
Input: deleteQuizInput{},
|
||||
Permissions: quizPermissions,
|
||||
Destructive: &yes,
|
||||
},
|
||||
{
|
||||
Name: "publish",
|
||||
Title: "Publish a quiz",
|
||||
Description: "Publishes the quiz so students see it in their quiz list; publishing a published quiz changes nothing. Returns the quiz with the fields of quizzes_list_quizzes." + needsWrite,
|
||||
Method: http.MethodPost,
|
||||
Path: ManagePrefix + "/{slug}/publish",
|
||||
Input: publishInput{},
|
||||
Permissions: quizPermissions,
|
||||
Idempotent: &yes,
|
||||
},
|
||||
{
|
||||
Name: "unpublish",
|
||||
Title: "Unpublish a quiz",
|
||||
Description: "Hides the quiz from students; their finished attempts and scores stay. Unpublishing an unpublished quiz changes nothing. Returns the quiz with the fields of quizzes_list_quizzes." + needsWrite,
|
||||
Method: http.MethodPost,
|
||||
Path: ManagePrefix + "/{slug}/unpublish",
|
||||
Input: publishInput{},
|
||||
Permissions: quizPermissions,
|
||||
Idempotent: &yes,
|
||||
},
|
||||
{
|
||||
Name: "import_quiz",
|
||||
Title: "Import a whole quiz",
|
||||
Description: "Creates a quiz with all its questions and answers from one quizzes:import document: title, slug, description, context, is_published, mode, navigation, max_attempts, time_limit_seconds, passing_score, shuffle_questions, round_size and questions [{text, type, category, answer_text, explanation, points, answers [{text, is_correct, explanation}]}]. A taken slug is a 409 unless replace is true: then the quiz fields are updated and all its questions are deleted and recreated, which deletes the attempt answers given to them. Finished attempts keep their scores, but their reviews lose those questions; on a quiz with attempts prefer the per-question tools. An invalid document is a 422 and writes nothing. Returns id, slug, created and the number of questions and answers." + needsWrite,
|
||||
Method: http.MethodPost,
|
||||
Path: ManagePrefix + "/import",
|
||||
Input: importQuizInput{},
|
||||
Permissions: quizPermissions,
|
||||
Destructive: &yes,
|
||||
},
|
||||
{
|
||||
Name: "quiz_stats",
|
||||
Title: "Quiz results",
|
||||
Description: "Reports on one quiz. Expired open test attempts are closed at their deadline first. students [{user_id, email, name, surname, attempts, best_score_percent, last_score_percent, last_completed_at, passed}] counts each student's completed whole-quiz attempts (learning rounds of one category are left out); passed is best_score_percent >= passing_score. questions [{id, text, type, answered, correct, correct_percent, most_chosen_wrong {answer_id, text, count} or null}] counts the answers of every completed attempt; correct_percent is null when nobody answered, and for self_check correct counts the cards marked known. It shows student emails and names." + needsRead,
|
||||
Method: http.MethodGet,
|
||||
Path: ManagePrefix + "/{slug}/stats",
|
||||
Input: statsInput{},
|
||||
Permissions: quizPermissions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +75,15 @@ func mcpTestApp(t *testing.T) (*backpack.App, []party.Plugin) {
|
||||
|
||||
// pinnedTools is the sorted tool list of golem15.user plus this plugin.
|
||||
var pinnedTools = []string{
|
||||
"quizzes_create_quiz",
|
||||
"quizzes_delete_quiz",
|
||||
"quizzes_import_quiz",
|
||||
"quizzes_list_quizzes",
|
||||
"quizzes_publish",
|
||||
"quizzes_quiz_stats",
|
||||
"quizzes_show_quiz",
|
||||
"quizzes_unpublish",
|
||||
"quizzes_update_quiz",
|
||||
"user_me",
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,12 @@ func TestRoutesRegistered(t *testing.T) {
|
||||
"POST /api/v1/quizzes/import",
|
||||
"GET /api/v1/quizzes",
|
||||
"GET /api/v1/quizzes/{slug}",
|
||||
"POST /api/v1/quizzes",
|
||||
"PATCH /api/v1/quizzes/{slug}",
|
||||
"DELETE /api/v1/quizzes/{slug}",
|
||||
"POST /api/v1/quizzes/{slug}/publish",
|
||||
"POST /api/v1/quizzes/{slug}/unpublish",
|
||||
"GET /api/v1/quizzes/{slug}/stats",
|
||||
}
|
||||
var got []string
|
||||
for _, r := range router.Routes() {
|
||||
|
||||
11
routes.go
11
routes.go
@@ -53,6 +53,17 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
g.Get("", h.ManageListQuizzes, "user.scope:read", perm)
|
||||
g.Get("/{slug}", h.ManageShowQuiz, "user.scope:read", perm)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
g.Post("", h.ManageCreateQuiz, "user.scope:write", perm)
|
||||
g.Patch("/{slug}", h.ManageUpdateQuiz, "user.scope:write", perm)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
g.Delete("/{slug}", h.ManageDeleteQuiz, "user.scope:write", perm)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
g.Post("/{slug}/publish", h.ManagePublish, "user.scope:write", perm)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
g.Post("/{slug}/unpublish", h.ManageUnpublish, "user.scope:write", perm)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
g.Get("/{slug}/stats", h.ManageQuizStats, "user.scope:read", perm)
|
||||
g.Where("slug", `[a-z0-9-]+`)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user