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" "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.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"` } 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) }