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