Files
sm-quizzes-plugin/controllers/api/reviews.go
Jakub Zych 692a647650 feat: set scores, confirm in bulk and regrade open answers over MCP
- SetScore (0..points in 0.5 steps, comment shown to the student),
  ConfirmBulk per quiz or attempt and Regrade through the quiz's grader
- close() announces an attempt reviewed while it was open
- quizzes_review_set_score, quizzes_review_confirm_bulk,
  quizzes_review_regrade; 409 already_reviewed and grader_manual
2026-11-09 18:06:02 +01:00

247 lines
7.6 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.ErrAlreadyReviewed):
reviewConflict(w, "The answer is already reviewed; set the score instead.", "already_reviewed")
case errors.Is(err, classes.ErrGraderManual):
reviewConflict(w, "The quiz's grader is manual or not available, so nothing can be graded again.", "grader_manual")
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)
}
// setScoreBody is the POST /{id}/score body.
type setScoreBody struct {
Points *float64 `json:"points"`
Comment *string `json:"comment"`
}
// ReviewSetScore answers POST /api/v1/quiz-reviews/{id}/score with
// {points, comment}: the owner's final score on any status, with an
// optional comment the student sees. Points outside 0..question points or
// off the 0.5 steps, and a comment over 2000 characters, are a 422.
func (h *Handlers) ReviewSetScore(w http.ResponseWriter, r *http.Request) {
id, ok := reviewID(w, r)
if !ok {
return
}
var body setScoreBody
if !readManaged(w, r, &body) {
return
}
comment, problems := classes.CheckReviewComment(body.Comment)
if body.Points == nil {
problems = append([]string{"points is required"}, problems...)
}
if refuse(w, problems) {
return
}
by, ok := h.reviewer(w, r)
if !ok {
return
}
svc, ok := h.service(w)
if !ok {
return
}
d, err := svc.SetScore(r.Context(), id, *body.Points, comment, by)
answerReview(w, d, err)
}
// confirmBulkBody is the POST /confirm body.
type confirmBulkBody struct {
Quiz string `json:"quiz"`
AttemptID uint `json:"attempt_id"`
}
// ReviewConfirmBulk answers POST /api/v1/quiz-reviews/confirm with
// exactly one of {quiz} and {attempt_id}: every graded answer in scope is
// confirmed; it answers {confirmed: n}.
func (h *Handlers) ReviewConfirmBulk(w http.ResponseWriter, r *http.Request) {
var body confirmBulkBody
if !readManaged(w, r, &body) {
return
}
scope := classes.ReviewScope{AttemptID: body.AttemptID, QuizSlug: strings.TrimSpace(body.Quiz)}
if refuse(w, classes.CheckReviewScope(scope, false)) {
return
}
by, ok := h.reviewer(w, r)
if !ok {
return
}
svc, ok := h.service(w)
if !ok {
return
}
n, err := svc.ConfirmBulk(r.Context(), scope, by)
writeReview(w, http.StatusOK, map[string]any{"confirmed": n}, err)
}
// regradeBody is the POST /regrade body.
type regradeBody struct {
AnswerID uint `json:"answer_id"`
AttemptID uint `json:"attempt_id"`
Quiz string `json:"quiz"`
}
// ReviewRegrade answers POST /api/v1/quiz-reviews/regrade with exactly one
// of {answer_id}, {attempt_id} and {quiz}: the unreviewed open answers in
// scope go back to the quiz's grader; it answers {regraded: n}. 409
// grader_manual when the quiz has no grader, already_reviewed for a single
// reviewed answer.
func (h *Handlers) ReviewRegrade(w http.ResponseWriter, r *http.Request) {
var body regradeBody
if !readManaged(w, r, &body) {
return
}
scope := classes.ReviewScope{AnswerID: body.AnswerID, AttemptID: body.AttemptID, QuizSlug: strings.TrimSpace(body.Quiz)}
if refuse(w, classes.CheckReviewScope(scope, true)) {
return
}
svc, ok := h.service(w)
if !ok {
return
}
n, err := svc.Regrade(r.Context(), scope)
writeReview(w, http.StatusOK, map[string]any{"regraded": n}, err)
}