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
This commit is contained in:
Jakub Zych
2026-11-09 18:36:20 +01:00
parent 2ad4e8b1ec
commit 9688b4d552
10 changed files with 334 additions and 39 deletions

View File

@@ -145,16 +145,23 @@ type updateQuizBody struct {
PassingScore *int `json:"passing_score"`
RoundSize *int `json:"round_size"`
ShuffleQuestions *bool `json:"shuffle_questions"`
Grader *string `json:"grader"`
}
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,
RoundSize: b.RoundSize, ShuffleQuestions: b.ShuffleQuestions, Grader: b.Grader,
}
}
// graderProblems checks a sent grader against the codes the application's
// grader registry knows (manual and every registered grader).
func (h *Handlers) graderProblems(in classes.QuizInput) []string {
return classes.CheckGrader(in.Grader, classes.ForApp(h.App, nil).Graders.Codes())
}
// 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 {
@@ -174,7 +181,7 @@ func answerQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int,
// 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)) {
if !readManaged(w, r, &in) || refuse(w, classes.CheckQuizInput(in, true)) || refuse(w, h.graderProblems(in)) {
return
}
db, ok := h.manageDB(w)
@@ -189,7 +196,8 @@ func (h *Handlers) ManageCreateQuiz(w http.ResponseWriter, r *http.Request) {
// 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)) {
if !readManaged(w, r, &body) || refuse(w, classes.CheckQuizInput(body.input(), false)) ||
refuse(w, h.graderProblems(body.input())) {
return
}
db, ok := h.manageDB(w)

View File

@@ -7,6 +7,7 @@ package api
import (
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/wire"
)
@@ -24,6 +25,7 @@ type managedQuizJSON struct {
PassingScore int `json:"passing_score"`
ShuffleQuestions bool `json:"shuffle_questions"`
RoundSize *int `json:"round_size"`
Grader string `json:"grader"`
IsPublished bool `json:"is_published"`
QuestionCount int `json:"question_count"`
AttemptsCount int `json:"attempts_count"`
@@ -45,6 +47,8 @@ type managedQuestionJSON struct {
Explanation *string `json:"explanation"`
Points int `json:"points"`
SortOrder int `json:"sort_order"`
Rubric *string `json:"rubric"`
MaxLength *int `json:"max_length"`
Answers []managedAnswerJSON `json:"answers"`
}
@@ -70,6 +74,7 @@ func managedQuiz(q models.Quiz, questions, attempts int) managedQuizJSON {
PassingScore: q.PassingScore,
ShuffleQuestions: q.ShuffleQuestions,
RoundSize: q.RoundSize,
Grader: quizGrader(&q),
IsPublished: q.IsPublished,
QuestionCount: questions,
AttemptsCount: attempts,
@@ -105,6 +110,8 @@ func managedQuestion(q models.Question) managedQuestionJSON {
Explanation: q.Explanation,
Points: q.Points,
SortOrder: q.SortOrder,
Rubric: q.Rubric,
MaxLength: q.MaxLength,
Answers: make([]managedAnswerJSON, 0, len(q.Answers)),
}
for _, a := range q.Answers {
@@ -116,9 +123,10 @@ func managedQuestion(q models.Question) managedQuestionJSON {
}
type statsJSON struct {
Quiz statsQuizJSON `json:"quiz"`
Students []studentStatJSON `json:"students"`
Questions []questionStatJSON `json:"questions"`
Quiz statsQuizJSON `json:"quiz"`
Unreviewed int `json:"unreviewed"`
Students []studentStatJSON `json:"students"`
Questions []questionStatJSON `json:"questions"`
}
type statsQuizJSON struct {
@@ -150,6 +158,8 @@ type questionStatJSON struct {
Correct int `json:"correct"`
CorrectPercent *float64 `json:"correct_percent"`
MostChosenWrong *wrongAnswerJSON `json:"most_chosen_wrong"`
Unreviewed int `json:"unreviewed"`
AveragePoints *float64 `json:"average_points"`
}
type wrongAnswerJSON struct {
@@ -164,8 +174,9 @@ func quizStats(s *classes.Stats) statsJSON {
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)),
Unreviewed: s.Unreviewed,
Students: make([]studentStatJSON, 0, len(s.Students)),
Questions: make([]questionStatJSON, 0, len(s.Questions)),
}
for _, st := range s.Students {
row := studentStatJSON{
@@ -181,6 +192,7 @@ func quizStats(s *classes.Stats) statsJSON {
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,
Unreviewed: q.Unreviewed, AveragePoints: q.AveragePoints,
}
if q.MostChosenWrong != nil {
row.MostChosenWrong = &wrongAnswerJSON{AnswerID: q.MostChosenWrong.AnswerID, Text: q.MostChosenWrong.Text, Count: q.MostChosenWrong.Count}
@@ -189,3 +201,12 @@ func quizStats(s *classes.Stats) statsJSON {
}
return out
}
// quizGrader is the quiz's grader code, manual for a quiz read before the
// column had a value.
func quizGrader(q *models.Quiz) string {
if q.Grader == "" {
return graders.Manual
}
return q.Grader
}

View File

@@ -57,6 +57,11 @@ func TestManageWritesRefuseBadInputBeforeDatabase(t *testing.T) {
"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"},
"create unknown grader": {h.ManageCreateQuiz, "POST", "/", `{"title":"A","grader":"zzz"}`, "grader must be one of manual"},
"create bad grader": {h.ManageCreateQuiz, "POST", "/", `{"title":"A","grader":"Bad Grader"}`, "grader must be lowercase"},
"update unknown grader": {h.ManageUpdateQuiz, "PATCH", "/", `{"grader":"zzz"}`, "grader must be one of manual"},
"question open answers": {h.ManageCreateQuestion, "POST", "/", `{"text":"Q","type":"open","answers":[{"text":"a"}]}`, "an open question has no answers"},
"question rubric single": {h.ManageCreateQuestion, "POST", "/", `{"text":"Q","type":"single","rubric":"R","answers":[{"text":"a","is_correct":true},{"text":"b"}]}`, "rubric applies to open questions only"},
"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"},
} {