Files
sm-quizzes-plugin/controllers/api/review_json.go
Jakub Zych 2fa8aa4178 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
2026-11-09 18:02:35 +01:00

176 lines
6.1 KiB
Go

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