Files
sm-quizzes-plugin/classes/importer.go
Jakub Zych 9688b4d552 feat: carry open questions and the grader through import and the quiz tools
- 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
2026-11-09 18:36:20 +01:00

291 lines
10 KiB
Go

package classes
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// ImportQuiz is the JSON document quizzes:import reads. Mode, Navigation,
// MaxAttempts and Grader keep the quiz's current value (the model default
// on create) when absent. Grader is any well-formed grader code: one that
// no activated plugin registers grades as manual (D-05).
type ImportQuiz struct {
Title string `json:"title"`
Slug string `json:"slug"`
Description *string `json:"description"`
Context *string `json:"context"`
IsPublished *bool `json:"is_published"`
TimeLimitSeconds *int `json:"time_limit_seconds"`
PassingScore *int `json:"passing_score"`
ShuffleQuestions *bool `json:"shuffle_questions"`
RoundSize *int `json:"round_size"`
Mode *string `json:"mode"`
Navigation *string `json:"navigation"`
MaxAttempts *int `json:"max_attempts"`
Grader *string `json:"grader"`
Questions []ImportQuestion `json:"questions"`
}
// ImportQuestion is one question of an ImportQuiz. Rubric (owner-only
// grading notes) and MaxLength (the longest answer in characters) belong
// to open questions; AnswerText is an open question's model answer.
type ImportQuestion 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"`
Rubric *string `json:"rubric"`
MaxLength *int `json:"max_length"`
Answers []ImportAnswer `json:"answers"`
}
// ImportAnswer is one answer of an ImportQuestion.
type ImportAnswer struct {
Text string `json:"text"`
IsCorrect bool `json:"is_correct"`
Explanation *string `json:"explanation"`
}
// ErrQuizExists is an import of an existing slug without replace.
var ErrQuizExists = errors.New("quizzes: a quiz with this slug already exists")
// ParseImport decodes and checks an import document. Unknown keys are an
// error, so a typo never drops data silently. The returned slug is the one
// the quiz will have.
func ParseImport(raw []byte) (*ImportQuiz, error) {
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var doc ImportQuiz
if err := dec.Decode(&doc); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
var problems []string
doc.Title = strings.TrimSpace(doc.Title)
if doc.Title == "" {
problems = append(problems, "title is required")
}
doc.Slug = models.Slugify(doc.Slug)
if doc.Slug == "" {
doc.Slug = models.Slugify(doc.Title)
}
if doc.Slug == "" && doc.Title != "" {
problems = append(problems, "slug is empty after normalising the title")
}
if doc.PassingScore != nil && (*doc.PassingScore < 0 || *doc.PassingScore > 100) {
problems = append(problems, "passing_score must be between 0 and 100")
}
if doc.RoundSize != nil && *doc.RoundSize < 1 {
problems = append(problems, "round_size must be at least 1")
}
if doc.TimeLimitSeconds != nil && *doc.TimeLimitSeconds < 1 {
problems = append(problems, "time_limit_seconds must be at least 1")
}
if doc.Mode != nil && !slices.Contains(models.QuizModes, *doc.Mode) {
problems = append(problems, "mode must be one of "+strings.Join(models.QuizModes, ", "))
}
if doc.Navigation != nil && !slices.Contains(models.QuizNavigations, *doc.Navigation) {
problems = append(problems, "navigation must be one of "+strings.Join(models.QuizNavigations, ", "))
}
if doc.MaxAttempts != nil && *doc.MaxAttempts < 1 {
problems = append(problems, "max_attempts must be at least 1")
}
problems = append(problems, checkGraderFormat(doc.Grader)...)
if len(doc.Questions) == 0 {
problems = append(problems, "questions must not be empty")
}
for i := range doc.Questions {
doc.Questions[i].Rubric = nilIfBlank(deref(doc.Questions[i].Rubric), false)
problems = append(problems, CheckQuestion(fmt.Sprintf("questions[%d]", i), doc.Questions[i])...)
}
if len(problems) > 0 {
return nil, fmt.Errorf("invalid quiz: %s", strings.Join(problems, "; "))
}
return &doc, nil
}
// CheckQuestion returns the problems of one question, each prefixed with
// at (such as "questions[0]"): the text is required, the type is one of
// models.QuestionTypes, points are not negative, answer texts are
// required, a single question has at least two answers and exactly one
// correct, a multiple question at least two and one or more correct, a
// self_check question none, and an open question none either (answer_text
// is its model answer). rubric and max_length (1..models.MaxMaxLength)
// apply to open questions only. The importer and the manage routes share
// it.
func CheckQuestion(at string, q ImportQuestion) []string {
var problems []string
if strings.TrimSpace(q.Text) == "" {
problems = append(problems, at+".text is required")
}
if !slices.Contains(models.QuestionTypes, q.Type) {
return append(problems, fmt.Sprintf("%s.type must be one of %s", at, strings.Join(models.QuestionTypes, ", ")))
}
if q.Points != nil && *q.Points < 0 {
problems = append(problems, at+".points must not be negative")
}
correct := 0
for j, a := range q.Answers {
if strings.TrimSpace(a.Text) == "" {
problems = append(problems, fmt.Sprintf("%s.answers[%d].text is required", at, j))
}
if a.IsCorrect {
correct++
}
}
switch q.Type {
case models.TypeSingle:
if len(q.Answers) < 2 || correct != 1 {
problems = append(problems, at+": a single question needs at least two answers and exactly one correct")
}
case models.TypeMultiple:
if len(q.Answers) < 2 || correct < 1 {
problems = append(problems, at+": a multiple question needs at least two answers and one or more correct")
}
case models.TypeSelfCheck:
if len(q.Answers) > 0 {
problems = append(problems, at+": a self_check question has no answers; use answer_text")
}
case models.TypeOpen:
if len(q.Answers) > 0 {
problems = append(problems, at+": an open question has no answers; put the model answer in answer_text")
}
if q.MaxLength != nil && (*q.MaxLength < 1 || *q.MaxLength > models.MaxMaxLength) {
problems = append(problems, fmt.Sprintf("%s.max_length must be between 1 and %d", at, models.MaxMaxLength))
}
}
if q.Type != models.TypeOpen {
if q.Rubric != nil && strings.TrimSpace(*q.Rubric) != "" {
problems = append(problems, at+".rubric applies to open questions only")
}
if q.MaxLength != nil {
problems = append(problems, at+".max_length applies to open questions only")
}
}
return problems
}
// checkGraderFormat is the problem of a sent grader code that is not
// well-formed. Whether a plugin registers it is CheckGrader's question.
func checkGraderFormat(code *string) []string {
if code == nil || graders.ValidCode(*code) {
return nil
}
return []string{"grader must be lowercase letters, digits and underscores"}
}
// ImportResult reports what an import wrote.
type ImportResult struct {
Quiz models.Quiz
Created bool
Questions int
Answers int
}
// Import writes doc in one transaction. A new slug creates the quiz. An
// existing slug is ErrQuizExists unless replace is true: then the quiz's
// fields are updated and its questions (with their answers and the attempt
// answers given to them) are deleted and recreated. Questions keep the
// document order as sort_order; points default to 1.
func Import(ctx context.Context, db *gorm.DB, doc *ImportQuiz, replace bool) (*ImportResult, error) {
res := &ImportResult{}
err := lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
tx = tx.WithContext(ctx)
var quiz models.Quiz
err := tx.Where("slug = ?", doc.Slug).Take(&quiz).Error
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
res.Created = true
quiz = models.Quiz{PassingScore: 50}
case err != nil:
return err
case !replace:
return ErrQuizExists
}
quiz.Title, quiz.Slug = doc.Title, doc.Slug
quiz.Description, quiz.Context = doc.Description, doc.Context
quiz.TimeLimitSeconds, quiz.RoundSize = doc.TimeLimitSeconds, doc.RoundSize
if doc.IsPublished != nil {
quiz.IsPublished = *doc.IsPublished
}
if doc.PassingScore != nil {
quiz.PassingScore = *doc.PassingScore
}
if doc.ShuffleQuestions != nil {
quiz.ShuffleQuestions = *doc.ShuffleQuestions
}
if doc.Mode != nil {
quiz.Mode = *doc.Mode
}
if doc.Navigation != nil {
quiz.Navigation = *doc.Navigation
}
if doc.MaxAttempts != nil {
limit := *doc.MaxAttempts
quiz.MaxAttempts = &limit
}
if doc.Grader != nil {
quiz.Grader = *doc.Grader
}
if err := tx.Save(&quiz).Error; err != nil {
return err
}
if !res.Created {
// The foreign keys cascade to answers and attempt answers,
// with the grades and reviews of open answers.
if err := tx.Where("quiz_id = ?", quiz.ID).Delete(&models.Question{}).Error; err != nil {
return err
}
}
for i, in := range doc.Questions {
points := 1
if in.Points != nil {
points = *in.Points
}
q := models.Question{
QuizID: quiz.ID, Text: strings.TrimSpace(in.Text), Type: in.Type, Category: trimmed(in.Category),
AnswerText: in.AnswerText, Explanation: in.Explanation, SortOrder: i + 1, Points: points,
Rubric: in.Rubric, MaxLength: in.MaxLength,
}
if err := tx.Create(&q).Error; err != nil {
return err
}
for j, a := range in.Answers {
ans := models.Answer{QuestionID: q.ID, Text: strings.TrimSpace(a.Text), IsCorrect: a.IsCorrect, Explanation: a.Explanation, SortOrder: j + 1}
if err := tx.Create(&ans).Error; err != nil {
return err
}
res.Answers++
}
res.Questions++
}
res.Quiz = quiz
return nil
})
if err != nil {
return nil, err
}
return res, nil
}
func trimmed(s *string) *string {
if s == nil {
return nil
}
t := strings.TrimSpace(*s)
if t == "" {
return nil
}
return &t
}