package api // The owner manage routes under /api/v1/quizzes, behind user.api_token, the // golem15.quizzes.manage throttle, a token scope and the access_quizzes // permission. The MCP quiz tools bind them. Answers use the owner shapes of // manage_json.go, which carry the solutions. import ( "bytes" "encoding/json" "errors" "io" "net/http" "strconv" "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" ) // manageDB returns the application database, or answers 503 as the student // routes do without one. func (h *Handlers) manageDB(w http.ResponseWriter) (*gorm.DB, bool) { var gdb *gorm.DB if h.App != nil { gdb, _ = h.App.Lookup[*gorm.DB]() } if gdb == nil { wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": "Database unavailable"}) return nil, false } return gdb, true } // writeManaged answers {data: data}, or maps err: ErrNotFound is a 404, // 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.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): wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": true, "message": "A quiz with this slug already exists."}) default: wire.WriteOpaque500(w) } } // ManageListQuizzes answers GET /api/v1/quizzes?context=: every quiz, // published or not, with its question and completed-attempt counts. func (h *Handlers) ManageListQuizzes(w http.ResponseWriter, r *http.Request) { db, ok := h.manageDB(w) if !ok { return } rows, err := classes.ListManagedQuizzes(r.Context(), db, strings.TrimSpace(r.URL.Query().Get("context"))) if err != nil { writeManaged(w, 0, nil, err) return } out := make([]managedQuizJSON, 0, len(rows)) for _, row := range rows { out = append(out, managedQuizRow(row)) } writeManaged(w, http.StatusOK, out, nil) } // ManageShowQuiz answers GET /api/v1/quizzes/{slug}: the quiz with every // question and answer, solutions included. func (h *Handlers) ManageShowQuiz(w http.ResponseWriter, r *http.Request) { db, ok := h.manageDB(w) if !ok { return } h.writeManagedQuiz(w, r, db, http.StatusOK, r.PathValue("slug")) } // writeManagedQuiz answers the quiz with its questions and solutions. func (h *Handlers) writeManagedQuiz(w http.ResponseWriter, r *http.Request, db *gorm.DB, status int, slug string) { quiz, err := classes.ManagedQuiz(r.Context(), db, slug) if err != nil { writeManaged(w, 0, nil, err) return } attempts, err := classes.CompletedAttemptCount(r.Context(), db, quiz.ID) if err != nil { writeManaged(w, 0, nil, err) return } 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"` 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, 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 { 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)) || refuse(w, h.graderProblems(in)) { 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)) || refuse(w, h.graderProblems(body.input())) { 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) } // 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) }