package api // The owner review routes under /api/v1/quiz-reviews, behind user.api_token, // the golem15.quizzes.manage throttle, a token scope and the access_quizzes // permission (D-13). The quizzes_review_* MCP tools bind them. Answers use // the owner shapes of review_json.go. import ( "errors" "net/http" "net/url" "strconv" "strings" "git.golem15.com/golem15/sm-quizzes-plugin/classes" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/wire" ) // reviewConflict answers a review action the answer's state refuses: 409 // with a stable code for clients to branch on. func reviewConflict(w http.ResponseWriter, message, code string) { wire.WriteJSON(w, http.StatusConflict, map[string]any{"error": true, "message": message, "code": code}) } // writeReview answers {data: data}, or maps err: the review conflicts are // 409s with a code, ErrNotFound a 404, everything else as writeManaged. func writeReview(w http.ResponseWriter, status int, data any, err error) { switch { case errors.Is(err, classes.ErrNoGraderScore): reviewConflict(w, "The answer has no grader score to confirm; set the score instead.", "no_grader_score") case errors.Is(err, classes.ErrNotFound): wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Open answer, attempt or quiz not found."}) default: writeManaged(w, status, data, err) } } // queryID reads an optional id query value: empty is 0, anything but a // positive integer is a problem. func queryID(q url.Values, name string) (uint, []string) { raw := strings.TrimSpace(q.Get(name)) if raw == "" { return 0, nil } id, err := strconv.ParseUint(raw, 10, 32) if err != nil || id == 0 { return 0, []string{name + " must be a positive integer"} } return uint(id), nil } // reviewID reads the {id} path value, the answer id: anything but a // positive integer is the 404 of a missing answer. func reviewID(w http.ResponseWriter, r *http.Request) (uint, bool) { id, err := strconv.ParseUint(r.PathValue("id"), 10, 32) if err != nil || id == 0 { writeReview(w, 0, nil, classes.ErrNotFound) return 0, false } return uint(id), true } // reviewer is the API token's user, who signs a review made over MCP. func (h *Handlers) reviewer(w http.ResponseWriter, r *http.Request) (classes.Reviewer, bool) { id, ok := h.user(w, r) return classes.Reviewer{UserID: id}, ok } // ReviewList answers GET /api/v1/quiz-reviews?quiz=&status=&user_id= // &attempt_id=&page=&per_page=: the open answers, oldest change first, // paginated. status defaults to open (pending, graded and failed). func (h *Handlers) ReviewList(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() f := classes.ReviewFilter{QuizSlug: strings.TrimSpace(q.Get("quiz")), Status: strings.TrimSpace(q.Get("status"))} _, problems := classes.CheckReviewStatus(f.Status) var p []string f.UserID, p = queryID(q, "user_id") problems = append(problems, p...) f.AttemptID, p = queryID(q, "attempt_id") problems = append(problems, p...) if refuse(w, problems) { return } svc, ok := h.service(w) if !ok { return } page := queryInt(r, "page", 1, 1, 100000) perPage := queryInt(r, "per_page", 20, 1, 100) rows, total, err := svc.ReviewQueue(r.Context(), f, page, perPage) if err != nil { writeReview(w, 0, nil, err) return } out := make([]reviewRowShape, 0, len(rows)) for _, row := range rows { out = append(out, reviewRowJSON(row)) } wire.WriteJSON(w, http.StatusOK, lagoon.Paginate(out, page, perPage, total)) } // ReviewShow answers GET /api/v1/quiz-reviews/{id}: one open answer with // its question, model answer, rubric, grading and review. func (h *Handlers) ReviewShow(w http.ResponseWriter, r *http.Request) { id, ok := reviewID(w, r) if !ok { return } svc, ok := h.service(w) if !ok { return } d, err := svc.ReviewItem(r.Context(), id) answerReview(w, d, err) } // ReviewConfirm answers POST /api/v1/quiz-reviews/{id}/confirm: the // grader's score becomes final; 409 no_grader_score for a pending or // failed answer. func (h *Handlers) ReviewConfirm(w http.ResponseWriter, r *http.Request) { id, ok := reviewID(w, r) if !ok { return } by, ok := h.reviewer(w, r) if !ok { return } svc, ok := h.service(w) if !ok { return } d, err := svc.Confirm(r.Context(), id, by) answerReview(w, d, err) } // answerReview answers one reviewed answer, or maps err. func answerReview(w http.ResponseWriter, d *classes.ReviewDetail, err error) { if err != nil { writeReview(w, 0, nil, err) return } writeReview(w, http.StatusOK, reviewDetailJSON(*d), nil) }