Files
sm-quizzes-plugin/controllers/api/reviews.go
Jakub Zych 2fa8aa4178 feat: list, show and confirm open answers over MCP
- classes/review.go: review queue, detail and Confirm, shared by MCP and
  the admin screen; AnswerReviewed and AttemptReviewed after commit
- /api/v1/quiz-reviews routes behind the quizzes permission
- quizzes_review_list, quizzes_review_show, quizzes_review_confirm
2026-11-09 18:02:35 +01:00

146 lines
4.5 KiB
Go

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)
}