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