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
This commit is contained in:
Jakub Zych
2026-11-09 18:02:35 +01:00
parent e1f9807701
commit 2fa8aa4178
10 changed files with 1070 additions and 3 deletions

View File

@@ -0,0 +1,175 @@
package api
// The owner shapes of the review routes. They carry student emails, the
// model answer, the rubric and grading errors, and are never used by a
// student route; the student shapes live in json.go. Built field by field,
// so a new column never leaks.
import (
"time"
"unicode/utf8"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/wire"
)
type reviewQuizShape struct {
ID uint `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Mode string `json:"mode"`
}
type reviewQuestionShape struct {
ID uint `json:"id"`
Text string `json:"text"`
Points int `json:"points"`
}
type reviewDetailQuestionShape struct {
reviewQuestionShape
AnswerText *string `json:"answer_text"`
Rubric *string `json:"rubric"`
MaxLength int `json:"max_length"`
}
type reviewAttemptShape struct {
ID uint `json:"id"`
Completed bool `json:"completed"`
CompletedAt *wire.Time `json:"completed_at"`
}
type reviewStudentShape struct {
UserID uint `json:"user_id"`
Email string `json:"email"`
Name *string `json:"name"`
Surname *string `json:"surname"`
}
type reviewerShape struct {
UserID *uint `json:"user_id"`
AdminID *uint `json:"admin_id"`
}
// reviewRowShape is one row of GET /api/v1/quiz-reviews.
type reviewRowShape struct {
AnswerID uint `json:"answer_id"`
Status string `json:"status"`
Quiz reviewQuizShape `json:"quiz"`
Question reviewQuestionShape `json:"question"`
Attempt reviewAttemptShape `json:"attempt"`
Student reviewStudentShape `json:"student"`
Text string `json:"text"`
TextTruncated bool `json:"text_truncated"`
GraderPoints *float64 `json:"grader_points"`
Points float64 `json:"points"`
GradedAt *wire.Time `json:"graded_at"`
ReviewedAt *wire.Time `json:"reviewed_at"`
UpdatedAt wire.Time `json:"updated_at"`
}
// reviewDetailShape is GET /api/v1/quiz-reviews/{id} and every review
// write: the row keys with the whole text, plus the solution and the
// grading and review record.
type reviewDetailShape struct {
AnswerID uint `json:"answer_id"`
Status string `json:"status"`
Quiz reviewQuizShape `json:"quiz"`
Question reviewDetailQuestionShape `json:"question"`
Attempt reviewAttemptShape `json:"attempt"`
Student reviewStudentShape `json:"student"`
Text string `json:"text"`
GraderPoints *float64 `json:"grader_points"`
Points float64 `json:"points"`
GradedAt *wire.Time `json:"graded_at"`
ReviewedAt *wire.Time `json:"reviewed_at"`
UpdatedAt wire.Time `json:"updated_at"`
Grader *string `json:"grader"`
GraderFeedback *string `json:"grader_feedback"`
GradingError *string `json:"grading_error"`
GradingAttempts int `json:"grading_attempts"`
IsCorrect bool `json:"is_correct"`
ReviewComment *string `json:"review_comment"`
ReviewedBy reviewerShape `json:"reviewed_by"`
}
func wireTimePtr(t *time.Time) *wire.Time {
if t == nil {
return nil
}
w := wireTime(*t)
return &w
}
func reviewQuiz(r classes.ReviewRow) reviewQuizShape {
return reviewQuizShape{ID: r.QuizID, Slug: r.QuizSlug, Title: r.QuizTitle, Mode: quizMode(&models.Quiz{Mode: r.QuizMode})}
}
func reviewAttempt(r classes.ReviewRow) reviewAttemptShape {
return reviewAttemptShape{ID: r.AttemptID, Completed: r.AttemptCompletedAt != nil, CompletedAt: wireTimePtr(r.AttemptCompletedAt)}
}
func reviewStudent(r classes.ReviewRow) reviewStudentShape {
return reviewStudentShape{UserID: r.UserID, Email: r.Email, Name: r.Name, Surname: r.Surname}
}
// excerpt keeps the first n runes of s and reports whether it cut.
func excerpt(s string, n int) (string, bool) {
if utf8.RuneCountInString(s) <= n {
return s, false
}
return string([]rune(s)[:n]), true
}
// reviewRowJSON is one queue row; the text is cut to the first
// classes.ReviewExcerptRunes runes with text_truncated set.
func reviewRowJSON(r classes.ReviewRow) reviewRowShape {
text, cut := excerpt(r.Text, classes.ReviewExcerptRunes)
return reviewRowShape{
AnswerID: r.AnswerID,
Status: r.Status,
Quiz: reviewQuiz(r),
Question: reviewQuestionShape{ID: r.QuestionID, Text: r.QuestionText, Points: r.QuestionPoints},
Attempt: reviewAttempt(r),
Student: reviewStudent(r),
Text: text,
TextTruncated: cut,
GraderPoints: r.GraderPoints,
Points: r.Points,
GradedAt: wireTimePtr(r.GradedAt),
ReviewedAt: wireTimePtr(r.ReviewedAt),
UpdatedAt: wireTime(r.UpdatedAt),
}
}
// reviewDetailJSON is one answer for review, owner view.
func reviewDetailJSON(d classes.ReviewDetail) reviewDetailShape {
r := d.ReviewRow
return reviewDetailShape{
AnswerID: r.AnswerID,
Status: r.Status,
Quiz: reviewQuiz(r),
Question: reviewDetailQuestionShape{
reviewQuestionShape: reviewQuestionShape{ID: r.QuestionID, Text: r.QuestionText, Points: r.QuestionPoints},
AnswerText: d.AnswerText,
Rubric: d.Rubric,
MaxLength: d.MaxLength,
},
Attempt: reviewAttempt(r),
Student: reviewStudent(r),
Text: r.Text,
GraderPoints: r.GraderPoints,
Points: r.Points,
GradedAt: wireTimePtr(r.GradedAt),
ReviewedAt: wireTimePtr(r.ReviewedAt),
UpdatedAt: wireTime(r.UpdatedAt),
Grader: d.Grader,
GraderFeedback: d.GraderFeedback,
GradingError: d.GradingError,
GradingAttempts: d.GradingAttempts,
IsCorrect: d.IsCorrect,
ReviewComment: d.ReviewComment,
ReviewedBy: reviewerShape{UserID: d.ReviewedByUserID, AdminID: d.ReviewedByAdminID},
}
}

145
controllers/api/reviews.go Normal file
View File

@@ -0,0 +1,145 @@
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)
}

View File

@@ -0,0 +1,169 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"unicode/utf8"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
)
func reviewRowFixture(text string) classes.ReviewRow {
at := time.Date(2026, 11, 9, 12, 0, 0, 0, time.UTC)
gp := 1.5
name := "Anna"
return classes.ReviewRow{
AnswerID: 41, Status: "graded",
QuizID: 3, QuizSlug: "pojecia", QuizTitle: "Pojęcia", QuizMode: "",
QuestionID: 7, QuestionText: "Czym jest odstraszanie?", QuestionPoints: 2,
AttemptID: 9, AttemptCompletedAt: &at,
UserID: 5, Email: "anna@example.test", Name: &name,
Text: text, GraderPoints: &gp, Points: 1.5, GradedAt: &at, UpdatedAt: at,
}
}
func marshalMap(t *testing.T, v any) (string, map[string]any) {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatal(err)
}
return string(raw), out
}
// TestReviewJSON: a queue row cuts the text to 300 runes and says so,
// keeps a short text whole, and names its keys in snake_case; the detail
// carries the whole text with the model answer, rubric and grading error.
func TestReviewJSON(t *testing.T) {
long := strings.Repeat("ż", 301)
_, out := marshalMap(t, reviewRowJSON(reviewRowFixture(long)))
if got := out["text"].(string); utf8.RuneCountInString(got) != 300 || out["text_truncated"] != true {
t.Errorf("long text = %d runes, truncated %v; want 300, true", utf8.RuneCountInString(got), out["text_truncated"])
}
short := strings.Repeat("a", 120)
raw, out := marshalMap(t, reviewRowJSON(reviewRowFixture(short)))
if out["text"] != short || out["text_truncated"] != false {
t.Errorf("short text = %v, truncated %v; want whole, false", out["text"], out["text_truncated"])
}
for _, key := range []string{"answer_id", "status", "quiz", "question", "attempt", "student", "text", "text_truncated", "grader_points", "points", "graded_at", "reviewed_at", "updated_at"} {
if _, ok := out[key]; !ok {
t.Errorf("row lacks %q: %s", key, raw)
}
}
if q := out["quiz"].(map[string]any); q["slug"] != "pojecia" || q["mode"] != "learning" {
t.Errorf("quiz = %v, want slug pojecia and mode learning", q)
}
if a := out["attempt"].(map[string]any); a["id"] != float64(9) || a["completed"] != true {
t.Errorf("attempt = %v, want id 9 completed", a)
}
if s := out["student"].(map[string]any); s["user_id"] != float64(5) || s["email"] != "anna@example.test" || s["name"] != "Anna" || s["surname"] != nil {
t.Errorf("student = %v", s)
}
for _, leak := range []string{"rubric", "answer_text", "grading_error"} {
if strings.Contains(raw, `"`+leak+`"`) {
t.Errorf("row carries %q: %s", leak, raw)
}
}
model, rubric, failure := "model", "R-RUBRIC", "grader timed out"
d := classes.ReviewDetail{
ReviewRow: reviewRowFixture(long), AnswerText: &model, Rubric: &rubric, MaxLength: 2000,
GradingError: &failure, GradingAttempts: 3,
}
raw, out = marshalMap(t, reviewDetailJSON(d))
if got := out["text"].(string); got != long {
t.Errorf("detail text has %d runes, want the whole 301", utf8.RuneCountInString(got))
}
q := out["question"].(map[string]any)
if q["answer_text"] != "model" || q["rubric"] != "R-RUBRIC" || q["max_length"] != float64(2000) || q["points"] != float64(2) {
t.Errorf("detail question = %v", q)
}
for _, key := range []string{"grader", "grader_feedback", "grading_error", "grading_attempts", "is_correct", "review_comment", "reviewed_by", "reviewed_at"} {
if _, ok := out[key]; !ok {
t.Errorf("detail lacks %q: %s", key, raw)
}
}
if out["grading_error"] != failure || out["grading_attempts"] != float64(3) {
t.Errorf("detail grading = %v / %v", out["grading_error"], out["grading_attempts"])
}
}
// TestReviewErrorMapping: the review conflicts are 409s with a code, a
// missing answer a 404, invalid input a 422, other errors an opaque 500.
func TestReviewErrorMapping(t *testing.T) {
for name, tc := range map[string]struct {
err error
code int
key string
}{
"no grader score": {classes.ErrNoGraderScore, http.StatusConflict, "no_grader_score"},
"not found": {classes.ErrNotFound, http.StatusNotFound, ""},
"validation": {&classes.ValidationError{Problems: []string{"status must be one of"}}, http.StatusUnprocessableEntity, ""},
"other": {errors.New("boom"), http.StatusInternalServerError, ""},
} {
rec := httptest.NewRecorder()
writeReview(rec, 0, nil, tc.err)
if rec.Code != tc.code {
t.Errorf("%s = %d, want %d: %s", name, rec.Code, tc.code, rec.Body.String())
continue
}
if tc.key != "" {
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out["code"] != tc.key || out["error"] != true || out["message"] == "" {
t.Errorf("%s body = %v, want code %s", name, out, tc.key)
}
}
}
rec := httptest.NewRecorder()
writeReview(rec, http.StatusOK, map[string]any{"x": 1}, nil)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"data"`) {
t.Errorf("success = %d %s", rec.Code, rec.Body.String())
}
}
// TestReviewHandlersBeforeDatabase: bad filters are a 422 and a bad id a
// 404 before the database; valid requests without one are a 503.
func TestReviewHandlersBeforeDatabase(t *testing.T) {
h := New(backpack.New(nil))
for target, want := range map[string]string{
"/?status=done": "status must be one of",
"/?user_id=abc": "user_id must be a positive integer",
"/?attempt_id=0": "attempt_id must be a positive integer",
"/?attempt_id=-3": "attempt_id must be a positive integer",
"/?user_id=99999999999": "user_id must be a positive integer",
} {
code, out := call(t, h.ReviewList, "GET", target, "", 7, nil)
if msg, _ := out["message"].(string); code != http.StatusUnprocessableEntity || !strings.Contains(msg, want) {
t.Errorf("list %s = %d %v, want 422 with %q", target, code, out, want)
}
}
for _, id := range []string{"", "0", "abc"} {
for name, fn := range map[string]http.HandlerFunc{"show": h.ReviewShow, "confirm": h.ReviewConfirm} {
code, _ := call(t, fn, "POST", "/", "", 7, map[string]string{"id": id})
if code != http.StatusNotFound {
t.Errorf("%s id %q = %d, want 404", name, id, code)
}
}
}
for name, fn := range map[string]http.HandlerFunc{"list": h.ReviewList, "show": h.ReviewShow, "confirm": h.ReviewConfirm} {
code, _ := call(t, fn, "GET", "/?status=graded", "", 7, map[string]string{"id": "3"})
if code != http.StatusServiceUnavailable {
t.Errorf("%s without a database = %d, want 503", name, code)
}
}
if code, _ := call(t, h.ReviewConfirm, "POST", "/", "", 0, map[string]string{"id": "3"}); code != http.StatusUnauthorized {
t.Errorf("confirm without a user = %d, want 401", code)
}
}