WR-02: Confirm and SetScore answer ErrAttemptOpen (409 attempt_open, an admin refusal) for an answer of a running test, ConfirmBulk skips such attempts, and the default open queue leaves them out, so AnswerReviewed never fires before the test ends.
249 lines
7.8 KiB
Go
249 lines
7.8 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.ErrAttemptOpen):
|
|
reviewConflict(w, "The test attempt is still open; review its answers once it ends.", "attempt_open")
|
|
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)
|
|
}
|