feat: edit quiz questions over MCP

- quizzes_create_question, update_question, delete_question and
  reorder_questions on /api/v1/quizzes/{slug}/questions
- update_question replaces answers by id (kept, created, deleted), so
  answer ids stored in past attempts stay valid; foreign ids are refused
- classes.CheckQuestion holds the importer's per-question rules, shared
  with the manage routes; attempt scores are never touched (D-16)
This commit is contained in:
Jakub Zych
2026-11-09 14:33:56 +01:00
parent 4a60eda425
commit c6d8d1a973
11 changed files with 792 additions and 44 deletions

View File

@@ -11,6 +11,7 @@ import (
"errors"
"io"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
@@ -43,6 +44,8 @@ func writeManaged(w http.ResponseWriter, status int, data any, err error) {
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.ErrQuestionNotFound):
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Question not found."})
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):
@@ -251,3 +254,105 @@ func (h *Handlers) ManageQuizStats(w http.ResponseWriter, r *http.Request) {
}
writeManaged(w, http.StatusOK, quizStats(stats), nil)
}
// questionID reads the {id} path value: anything but a positive integer
// is the 404 of a missing question.
func questionID(w http.ResponseWriter, r *http.Request) (uint, bool) {
id, err := strconv.ParseUint(r.PathValue("id"), 10, 32)
if err != nil || id == 0 {
writeManaged(w, 0, nil, classes.ErrQuestionNotFound)
return 0, false
}
return uint(id), true
}
// ManageCreateQuestion answers POST /api/v1/quizzes/{slug}/questions: 201
// with the new question and its answers.
func (h *Handlers) ManageCreateQuestion(w http.ResponseWriter, r *http.Request) {
var in classes.QuestionInput
if !readManaged(w, r, &in) || refuse(w, classes.CheckNewQuestion(in)) {
return
}
db, ok := h.manageDB(w)
if !ok {
return
}
q, err := classes.CreateQuestion(r.Context(), db, r.PathValue("slug"), in)
if err != nil {
writeManaged(w, 0, nil, err)
return
}
writeManaged(w, http.StatusCreated, managedQuestion(*q), nil)
}
// ManageUpdateQuestion answers PATCH /api/v1/quizzes/{slug}/questions/{id}:
// only the sent fields change; a sent answers list replaces the answers by
// id.
func (h *Handlers) ManageUpdateQuestion(w http.ResponseWriter, r *http.Request) {
id, ok := questionID(w, r)
if !ok {
return
}
var in classes.QuestionInput
if !readManaged(w, r, &in) {
return
}
if in.SortOrder != nil && *in.SortOrder < 1 {
refuse(w, []string{"sort_order must be at least 1"})
return
}
db, ok := h.manageDB(w)
if !ok {
return
}
q, err := classes.UpdateQuestion(r.Context(), db, r.PathValue("slug"), id, in)
if err != nil {
writeManaged(w, 0, nil, err)
return
}
writeManaged(w, http.StatusOK, managedQuestion(*q), nil)
}
// ManageDeleteQuestion answers DELETE /api/v1/quizzes/{slug}/questions/{id}
// with {id, quiz_slug}.
func (h *Handlers) ManageDeleteQuestion(w http.ResponseWriter, r *http.Request) {
id, ok := questionID(w, r)
if !ok {
return
}
db, ok := h.manageDB(w)
if !ok {
return
}
slug := r.PathValue("slug")
if err := classes.DeleteQuestion(r.Context(), db, slug, id); err != nil {
writeManaged(w, 0, nil, err)
return
}
writeManaged(w, http.StatusOK, map[string]any{"id": id, "quiz_slug": slug}, nil)
}
// reorderBody is the PUT /{slug}/questions/order body.
type reorderBody struct {
QuestionIDs []uint `json:"question_ids"`
}
// ManageReorderQuestions answers PUT /api/v1/quizzes/{slug}/questions/order
// with {"question_ids": [...]}, every question of the quiz exactly once,
// with the quiz and its questions in the new order.
func (h *Handlers) ManageReorderQuestions(w http.ResponseWriter, r *http.Request) {
var body reorderBody
if !readManaged(w, r, &body) || refuse(w, classes.CheckQuestionOrder(body.QuestionIDs)) {
return
}
db, ok := h.manageDB(w)
if !ok {
return
}
slug := r.PathValue("slug")
if _, err := classes.ReorderQuestions(r.Context(), db, slug, body.QuestionIDs); err != nil {
writeManaged(w, 0, nil, err)
return
}
h.writeManagedQuiz(w, r, db, http.StatusOK, slug)
}

View File

@@ -18,16 +18,20 @@ func TestManageHandlersWithoutDatabase(t *testing.T) {
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", "/", ""},
"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", "/", ""},
"create question": {h.ManageCreateQuestion, "POST", "/", `{"text":"Q","type":"self_check"}`},
"update question": {h.ManageUpdateQuestion, "PATCH", "/", `{"text":"Q"}`},
"delete question": {h.ManageDeleteQuestion, "DELETE", "/", ""},
"reorder": {h.ManageReorderQuestions, "PUT", "/", `{"question_ids":[2,1]}`},
} {
code, _ := call(t, tc.fn, tc.method, tc.target, tc.body, 7, map[string]string{"slug": "x"})
code, _ := call(t, tc.fn, tc.method, tc.target, tc.body, 7, map[string]string{"slug": "x", "id": "3"})
if code != http.StatusServiceUnavailable {
t.Errorf("%s without a database = %d, want 503", name, code)
}
@@ -56,10 +60,24 @@ func TestManageWritesRefuseBadInputBeforeDatabase(t *testing.T) {
"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"})
code, out := call(t, tc.fn, tc.method, tc.target, tc.body, 7, map[string]string{"slug": "x", "id": "3"})
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)
}
}
}
// TestManageQuestionIDs: a question id that is not a positive integer is
// the 404 of a missing question, before the database.
func TestManageQuestionIDs(t *testing.T) {
h := New(backpack.New(nil))
for _, id := range []string{"", "0", "abc", "-1"} {
for name, fn := range map[string]http.HandlerFunc{"update": h.ManageUpdateQuestion, "delete": h.ManageDeleteQuestion} {
code, out := call(t, fn, "PATCH", "/", `{}`, 7, map[string]string{"slug": "x", "id": id})
if code != http.StatusNotFound || out["message"] != "Question not found." {
t.Errorf("%s id %q = %d %v, want 404 Question not found.", name, id, code, out)
}
}
}
}