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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user