- 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
247 lines
7.6 KiB
Go
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)
|
|
}
|