feat: review open answers in the admin and pick a grader per quiz

- Do oceny admin screen (golem15.quizzes.reviews) over the open answers,
  with a read-only context partial, score and comment virtual fields saved
  through SetScore, confirm and regrade record actions, bulk confirm
- open question type, rubric and max_length in the question form; grader
  dropdown on the quiz form from the grader registry
- Review model with quiz, status and student filters
This commit is contained in:
Jakub Zych
2026-11-09 18:31:19 +01:00
parent 1ce1ebee16
commit 2ad4e8b1ec
20 changed files with 1244 additions and 9 deletions

View File

@@ -2,12 +2,16 @@ package quizzes_test
import (
"slices"
"strings"
"testing"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
"gorm.io/gorm/utils/tests"
)
func TestAdminCompiles(t *testing.T) {
@@ -37,7 +41,7 @@ func TestAdminCompiles(t *testing.T) {
}
}
slices.Sort(ids)
want := []string{"golem15.quizzes.answers", "golem15.quizzes.attempts", "golem15.quizzes.questions", "golem15.quizzes.quizzes"}
want := []string{"golem15.quizzes.answers", "golem15.quizzes.attempts", "golem15.quizzes.questions", "golem15.quizzes.quizzes", "golem15.quizzes.reviews"}
if !slices.Equal(ids, want) {
t.Errorf("controllers = %v, want %v", ids, want)
}
@@ -52,17 +56,78 @@ func TestAdminCompiles(t *testing.T) {
}
nav := p.(pact.HasNavigation).Navigation()
if len(nav) != 1 || nav[0].Label != "golem15.quizzes::lang.plugin.name" || len(nav[0].SideMenu) != 4 {
if len(nav) != 1 || nav[0].Label != "golem15.quizzes::lang.plugin.name" || len(nav[0].SideMenu) != 5 {
t.Errorf("navigation = %+v", nav)
} else {
var codes []string
for _, item := range nav[0].SideMenu {
codes = append(codes, item.Code)
}
if !slices.Equal(codes, []string{"quizzes", "questions", "answers", "reviews", "attempts"}) {
t.Errorf("side menu = %v, want reviews after answers", codes)
}
if review := nav[0].SideMenu[3]; review.Controller != "golem15.quizzes.reviews" || !slices.Equal(review.Permissions, []string{"golem15.quizzes.access_quizzes"}) {
t.Errorf("reviews entry = %+v", review)
}
}
}
func TestQuestionFilterScopes(t *testing.T) {
q := models.Question{}
if got := q.FilterOptions(models.ScopeByType); len(got) != 3 {
if got := q.FilterOptions(models.ScopeByType); len(got) != 4 || got[3].Value != models.TypeOpen {
t.Errorf("type options = %v", got)
}
if got := q.FilterScope("unknown", nil, "x"); got != nil {
t.Errorf("FilterScope on a nil db = %v", got)
}
}
func TestReviewFilterScopes(t *testing.T) {
r := models.Review{}
if got := r.FilterScopes(); !slices.Equal(got, []string{models.ScopeByQuiz, models.ScopeByStatus, models.ScopeByStudent}) {
t.Errorf("FilterScopes = %v", got)
}
opts := r.FilterOptions(models.ScopeByStatus)
var values []string
for _, o := range opts {
values = append(values, o.Value)
if o.Label != "golem15.quizzes::lang.reviews.status_"+o.Value {
t.Errorf("status %s label = %q", o.Value, o.Label)
}
}
if !slices.Equal(values, []string{"pending", "graded", "failed", "reviewed"}) {
t.Errorf("status options = %v", values)
}
if got := r.FilterScope("unknown", nil, "x"); got != nil {
t.Errorf("FilterScope on a nil db = %v", got)
}
db, err := gorm.Open(tests.DummyDialector{}, &gorm.Config{DryRun: true})
if err != nil {
t.Fatalf("dry-run db: %v", err)
}
sqlOf := func(scope string, value any) string {
var rows []models.Review
stmt := r.FilterScope(scope, db.Session(&gorm.Session{DryRun: true}).Model(&models.Review{}), value).Find(&rows).Statement
return stmt.SQL.String()
}
if got := sqlOf(models.ScopeByStatus, []any{"graded"}); !strings.Contains(got, "golem15_quizzes_attempt_answers.grading_status IN") {
t.Errorf("status SQL = %s", got)
}
if got := sqlOf(models.ScopeByStatus, []any{"bogus"}); !strings.Contains(got, "1 = 0") {
t.Errorf("unknown status SQL = %s, want no rows", got)
}
if got := sqlOf(models.ScopeByQuiz, []any{"3"}); !strings.Contains(got, "question_id IN (SELECT id FROM golem15_quizzes_questions WHERE quiz_id IN") {
t.Errorf("quiz SQL = %s", got)
}
if got := sqlOf(models.ScopeByStudent, []any{"7"}); !strings.Contains(got, "attempt_id IN (SELECT id FROM golem15_quizzes_attempts WHERE user_id IN") {
t.Errorf("student SQL = %s", got)
}
if got := sqlOf(models.ScopeByQuiz, nil); strings.Contains(got, "question_id IN") {
t.Errorf("empty value SQL = %s, want no filter", got)
}
if name, err := lagoon.MorphType(nil, &models.Review{}); err != nil || name == "golem15_quizzes_attempt_answers" {
t.Errorf("Review morph type = %q, %v; it must differ from AttemptAnswer's table", name, err)
}
}

View File

@@ -3,6 +3,7 @@
package controllers
import (
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
@@ -11,8 +12,17 @@ var (
_ pact.AdminController = quizzesAdmin{}
_ pact.AdminRecordSource = quizzesAdmin{}
_ pact.AdminPermissioned = quizzesAdmin{}
_ pact.DropdownOptionsProvider = quizzesAdmin{}
)
// GraderOptions lists the grader dropdown choices: the registry golem15.quizzes
// built at boot. The plugin sets it at Boot; nil offers manual only.
//
// TODO: requires SummerCMS change. DropdownOptions receives neither a
// context nor the app, so the registry comes through a package-level hook.
var GraderOptions func() []graders.Option
// quizzesAdmin lists and edits quizzes. Questions have their own controller
// filtered by quiz.
//
@@ -33,5 +43,25 @@ func (quizzesAdmin) RequiredPermissions() []string {
return []string{"golem15.quizzes.access_quizzes"}
}
// DropdownOptions serves the grader dropdown (options: getGraderOptions):
// manual, then every grader an activated plugin registered. Other fields
// have no method-backed options.
func (quizzesAdmin) DropdownOptions(field string) []pact.Option {
if field != "grader" {
return nil
}
opts := []graders.Option{{Code: graders.Manual, Label: graders.ManualLabel}}
if GraderOptions != nil {
if got := GraderOptions(); len(got) > 0 {
opts = got
}
}
out := make([]pact.Option, 0, len(opts))
for _, o := range opts {
out = append(out, pact.Option{Value: o.Code, Label: o.Label})
}
return out
}
// QuizzesController returns the golem15.quizzes.quizzes admin controller.
func QuizzesController() pact.AdminController { return quizzesAdmin{} }

509
controllers/reviews.go Normal file
View File

@@ -0,0 +1,509 @@
package controllers
import (
"context"
"encoding/json"
"errors"
"math"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
"gorm.io/gorm"
)
var (
_ pact.AdminController = reviewsAdmin{}
_ pact.AdminRecordSource = reviewsAdmin{}
_ pact.AdminPermissioned = reviewsAdmin{}
_ pact.ListExtendQuery = reviewsAdmin{}
_ pact.FormExtendQuery = reviewsAdmin{}
_ pact.FormRecordTitle = reviewsAdmin{}
_ pact.FormBeforeCreate = reviewsAdmin{}
_ pact.FormBeforeDelete = reviewsAdmin{}
_ pact.FormVirtualFields = reviewsAdmin{}
_ pact.FormRules = reviewsAdmin{}
_ pact.FormAfterUpdate = reviewsAdmin{}
_ pact.HasAdminBulkActions = reviewsAdmin{}
_ pact.HasAdminRecordActions = reviewsAdmin{}
_ pact.AdminPartialData = reviewsAdmin{}
)
// Reviews builds the review service of an admin request: over the request's
// write transaction (cabana.TxFromContext) when there is one, else over the
// application's database. The plugin sets it at Boot.
//
// TODO: requires SummerCMS change. Admin hooks receive no app, so, as
// models.FilterDB, the service comes through a package-level hook.
var Reviews func(ctx context.Context) *classes.Service
// reviewsLang prefixes the lang keys of the review screen.
const reviewsLang = "golem15.quizzes::lang.reviews."
// The virtual fields of the review form and its partial.
const (
fieldReviewPoints = "review_points"
fieldReviewComment = "review_comment"
partialReview = "review_context"
)
// errReviewsUnavailable is an admin request before the plugin booted.
var errReviewsUnavailable = errors.New("quizzes: the review service is not available")
// reviewsAdmin is the "Do oceny" screen: the open answers of every quiz,
// with the grader's result, the owner's score and comment, and the confirm
// and regrade actions. Answers come from students, so it never creates or
// deletes one; every write goes through the review service shared with the
// MCP review tools.
type reviewsAdmin struct{}
func (reviewsAdmin) ID() string { return "golem15.quizzes.reviews" }
func (reviewsAdmin) ModelName() string { return "Review" }
func (reviewsAdmin) ConfigDir() string { return "controllers/reviews" }
// NewRecord returns the model the generic admin handlers query.
func (reviewsAdmin) NewRecord() any { return &models.Review{} }
// RequiredPermissions is the quiz editors' permission (D-13): reviewing
// needs no permission of its own.
func (reviewsAdmin) RequiredPermissions() []string {
return []string{"golem15.quizzes.access_quizzes"}
}
// openAnswers keeps the rows of open questions: only they have a grading
// status.
func openAnswers(db *gorm.DB) *gorm.DB {
return db.Where("golem15_quizzes_attempt_answers.grading_status IS NOT NULL")
}
// ListExtendQuery lists open answers only.
func (reviewsAdmin) ListExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return openAnswers(db) }
// FormExtendQuery opens open answers only. It preloads nothing: the admin
// save writes the loaded record back, and a preloaded question or attempt
// would be written with it.
func (reviewsAdmin) FormExtendQuery(_ context.Context, db *gorm.DB) *gorm.DB { return openAnswers(db) }
// FormRecordTitle names an answer by its quiz and student.
func (reviewsAdmin) FormRecordTitle(ctx context.Context, _ *gorm.DB, record any) (string, error) {
rec, ok := record.(*models.Review)
if !ok || rec == nil {
return "", nil
}
svc, err := reviewService(ctx)
if err != nil {
return "#" + strconv.FormatUint(uint64(rec.ID), 10), nil
}
item, err := svc.ReviewItem(ctx, rec.ID)
if err != nil {
return "#" + strconv.FormatUint(uint64(rec.ID), 10), nil
}
return item.QuizTitle + " · " + item.Email, nil
}
// refusedWrite is the answer to a create or a delete: open answers come
// from students.
func refusedWrite() error {
return &cabana.ForbiddenError{Message: reviewsLang + "not_created"}
}
// FormBeforeCreate refuses: open answers come from students.
func (reviewsAdmin) FormBeforeCreate(context.Context, any) error { return refusedWrite() }
// FormBeforeDelete refuses: an answer is removed with its attempt or quiz.
func (reviewsAdmin) FormBeforeDelete(context.Context, any) error { return refusedWrite() }
// FormVirtualFields are the owner's score and comment.
func (reviewsAdmin) FormVirtualFields() []string {
return []string{fieldReviewPoints, fieldReviewComment}
}
// FormRules check the virtual fields; SetScore checks the points against
// the question.
func (reviewsAdmin) FormRules(context.Context, string) map[string]string {
return map[string]string{
fieldReviewPoints: "nullable|numeric|min:0",
fieldReviewComment: "nullable|max:" + strconv.Itoa(classes.MaxReviewComment),
}
}
// FormAfterUpdate stores the owner's score and comment through SetScore in
// the save transaction. An empty form saves nothing.
func (reviewsAdmin) FormAfterUpdate(ctx context.Context, model any) error {
values, _ := cabana.VirtualFieldsFromContext(ctx)
points, comment, err := reviewValues(values)
if err != nil || points == nil {
return err
}
rec, ok := model.(*models.Review)
if !ok || rec == nil {
return errors.New("quizzes: review form model is not a review")
}
svc, err := reviewService(ctx)
if err != nil {
return err
}
_, err = svc.SetScore(ctx, rec.ID, *points, comment, classes.Reviewer{AdminID: adminID(ctx)})
return reviewError(err)
}
// reviewValues reads the virtual fields: review_points as a number or a
// numeric string (a decimal comma is accepted) and review_comment trimmed.
// No points and no comment is nothing to save (nil, nil, nil); a comment
// without points, or points that are not a number, is a
// *cabana.ValidationError on review_points.
func reviewValues(values map[string]any) (*float64, *string, error) {
var comment *string
if raw, ok := values[fieldReviewComment].(string); ok {
if c := strings.TrimSpace(raw); c != "" {
comment = &c
}
}
points, ok := numberValue(values[fieldReviewPoints])
if !ok {
return nil, nil, pointsError("points_invalid")
}
if points == nil {
if comment != nil {
return nil, nil, pointsError("points_required")
}
return nil, nil, nil
}
return points, comment, nil
}
// numberValue reads a submitted number: nil or "" is no value; a value
// that is not a finite number is false.
func numberValue(v any) (*float64, bool) {
var f float64
switch n := v.(type) {
case nil:
return nil, true
case float64:
f = n
case float32:
f = float64(n)
case int:
f = float64(n)
case int64:
f = float64(n)
case uint:
f = float64(n)
case uint64:
f = float64(n)
case json.Number:
parsed, err := n.Float64()
if err != nil {
return nil, false
}
f = parsed
case string:
s := strings.TrimSpace(n)
if s == "" {
return nil, true
}
parsed, err := strconv.ParseFloat(strings.Replace(s, ",", ".", 1), 64)
if err != nil {
return nil, false
}
f = parsed
default:
return nil, false
}
if math.IsNaN(f) || math.IsInf(f, 0) {
return nil, false
}
return &f, true
}
// pointsError is a validation error on review_points with a reviews lang
// key.
func pointsError(key string) error {
return &cabana.ValidationError{Details: map[string]any{fieldReviewPoints: []string{reviewsLang + key}}}
}
// reviewError maps the review service's refusals to admin answers: a
// rule broken by the score becomes a validation error on review_points,
// a state conflict a refusal with its message. Anything else passes.
func reviewError(err error) error {
if err == nil {
return nil
}
var invalid *classes.ValidationError
switch {
case errors.As(err, &invalid):
return pointsError("points_invalid")
case errors.Is(err, classes.ErrNoGraderScore):
return &cabana.ForbiddenError{Message: reviewsLang + "no_grader_score"}
case errors.Is(err, classes.ErrAlreadyReviewed):
return &cabana.ForbiddenError{Message: reviewsLang + "already_reviewed"}
case errors.Is(err, classes.ErrGraderManual):
return &cabana.ForbiddenError{Message: reviewsLang + "grader_manual"}
case errors.Is(err, classes.ErrNotFound):
return &cabana.ForbiddenError{Message: reviewsLang + "not_found"}
}
return err
}
// reviewService is the review service of this request.
func reviewService(ctx context.Context) (*classes.Service, error) {
if Reviews == nil {
return nil, errReviewsUnavailable
}
svc := Reviews(ctx)
if svc == nil || svc.DB == nil {
return nil, errReviewsUnavailable
}
return svc, nil
}
// adminID is the signed-in administrator of an admin request.
func adminID(ctx context.Context) uint {
if p, ok := bouncer.User(ctx); ok && p != nil {
return p.ID
}
return 0
}
// reviewer is the administrator as a review's author.
func reviewer(ctx context.Context) classes.Reviewer {
return classes.Reviewer{AdminID: adminID(ctx)}
}
// AdminRecordActions are confirm (a graded answer's score becomes final)
// and regrade (the answer goes back to the quiz's grader).
func (reviewsAdmin) AdminRecordActions() []pact.AdminRecordAction {
return []pact.AdminRecordAction{
{
Name: "confirm",
Label: reviewsLang + "confirm",
Applies: func(_ context.Context, record any) (bool, error) {
rec, ok := record.(*models.Review)
return ok && rec != nil && rec.ReviewStatus() == models.GradingGraded, nil
},
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
svc, err := reviewService(ctx)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
if _, err := svc.Confirm(ctx, uint(in.RecordID), reviewer(ctx)); err != nil {
return pact.AdminRecordActionResult{}, reviewError(err)
}
return pact.AdminRecordActionResult{Message: reviewsLang + "confirmed"}, nil
},
},
{
Name: "regrade",
Label: reviewsLang + "regrade",
Confirm: reviewsLang + "regrade_confirm",
Applies: func(ctx context.Context, record any) (bool, error) {
rec, ok := record.(*models.Review)
if !ok || rec == nil {
return false, nil
}
switch rec.ReviewStatus() {
case models.GradingPending, models.GradingGraded, models.GradingFailed:
default:
return false, nil
}
return canRegrade(ctx, rec)
},
Run: func(ctx context.Context, in pact.AdminRecordActionInput) (pact.AdminRecordActionResult, error) {
svc, err := reviewService(ctx)
if err != nil {
return pact.AdminRecordActionResult{}, err
}
if _, err := svc.Regrade(ctx, classes.ReviewScope{AnswerID: uint(in.RecordID)}); err != nil {
return pact.AdminRecordActionResult{}, reviewError(err)
}
return pact.AdminRecordActionResult{Message: reviewsLang + "regraded"}, nil
},
},
}
}
// canRegrade reports whether the answer's quiz has a grader an activated
// plugin registered (not manual).
func canRegrade(ctx context.Context, rec *models.Review) (bool, error) {
svc, err := reviewService(ctx)
if err != nil {
return false, nil
}
quiz := quizOf(rec)
if quiz == nil {
var q models.Quiz
err := svc.DB.WithContext(ctx).
Where("id = (SELECT quiz_id FROM golem15_quizzes_attempts WHERE id = ?)", rec.AttemptID).
Take(&q).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
if err != nil {
return false, err
}
quiz = &q
}
_, ok := classes.GraderFor(svc.Graders, quiz)
return ok, nil
}
// quizOf is the record's quiz when it was loaded with it.
func quizOf(rec *models.Review) *models.Quiz {
if rec.Attempt != nil && rec.Attempt.Quiz != nil {
return rec.Attempt.Quiz
}
if rec.Question != nil && rec.Question.Quiz != nil {
return rec.Question.Quiz
}
return nil
}
// AdminBulkActions is confirm: every selected graded answer gets its
// grader's score as final; other statuses are skipped.
func (reviewsAdmin) AdminBulkActions() []pact.AdminBulkAction {
return []pact.AdminBulkAction{{
Name: "confirm",
Label: reviewsLang + "confirm_selected",
Run: func(ctx context.Context, in pact.AdminBulkActionInput) (pact.AdminBulkActionResult, error) {
svc, err := reviewService(ctx)
if err != nil {
return pact.AdminBulkActionResult{}, err
}
by := reviewer(ctx)
affected := 0
for _, record := range in.Records {
rec, ok := record.(*models.Review)
if !ok || rec == nil || rec.ReviewStatus() != models.GradingGraded {
continue
}
if _, err := svc.Confirm(ctx, rec.ID, by); err != nil {
return pact.AdminBulkActionResult{}, reviewError(err)
}
affected++
}
return pact.AdminBulkActionResult{Affected: affected}, nil
},
}}
}
// reviewContextView is the view model of the _review_context partial: plain
// strings, numbers and booleans only, so the template's escaping covers
// every value (the student's text and the grader's feedback are untrusted).
type reviewContextView struct {
QuizTitle string
StudentEmail string
StudentName string
QuestionText string
QuestionPoints int
ModelAnswer string
Rubric string
MaxLength int
Text string
Status string
StatusLabel string
Grader string
GraderLabel string
HasGraderPoints bool
GraderPoints float64
GraderFeedback string
GradingError string
GradingAttempts int
GradedAt string
Points float64
ReviewComment string
Reviewed bool
ReviewedAt string
}
// PartialData builds the review context of an answer from the review
// service; an unknown partial is an error.
func (reviewsAdmin) PartialData(ctx context.Context, name string, record any) (any, error) {
if name != partialReview {
return nil, errors.New("quizzes: unknown partial " + name)
}
rec, ok := record.(*models.Review)
if !ok || rec == nil || rec.ID == 0 {
return reviewContextView{}, nil
}
svc, err := reviewService(ctx)
if err != nil {
return nil, err
}
item, err := svc.ReviewItem(ctx, rec.ID)
if err != nil {
return nil, err
}
return newReviewContextView(item, svc.Graders), nil
}
// newReviewContextView copies a review detail into the partial's view
// model.
func newReviewContextView(item *classes.ReviewDetail, reg *graders.Registry) reviewContextView {
v := reviewContextView{
QuizTitle: item.QuizTitle,
StudentEmail: item.Email,
StudentName: strings.TrimSpace(deref(item.Name) + " " + deref(item.Surname)),
QuestionText: item.QuestionText,
QuestionPoints: item.QuestionPoints,
ModelAnswer: deref(item.AnswerText),
Rubric: deref(item.Rubric),
MaxLength: item.MaxLength,
Text: item.Text,
Status: item.Status,
StatusLabel: reviewsLang + "status_" + item.Status,
Grader: deref(item.Grader),
GraderFeedback: deref(item.GraderFeedback),
GradingError: deref(item.GradingError),
GradingAttempts: item.GradingAttempts,
GradedAt: formatTime(item.GradedAt),
Points: item.Points,
ReviewComment: deref(item.ReviewComment),
Reviewed: item.Status == models.GradingReviewed,
ReviewedAt: formatTime(item.ReviewedAt),
}
if item.GraderPoints != nil {
v.HasGraderPoints, v.GraderPoints = true, *item.GraderPoints
}
v.GraderLabel = graderLabel(reg, v.Grader)
return v
}
// graderLabel is the lang key or name of a grader code: Manual's label, a
// registered grader's label, else the code itself.
func graderLabel(reg *graders.Registry, code string) string {
if code == "" {
return ""
}
if code == graders.Manual {
return graders.ManualLabel
}
if g, ok := reg.Get(code); ok {
if label := g.Label(); label != "" {
return label
}
}
return code
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
func formatTime(t *time.Time) string {
if t == nil {
return ""
}
return t.Local().Format("2006-01-02 15:04")
}
// ReviewsController returns the golem15.quizzes.reviews admin controller.
func ReviewsController() pact.AdminController { return reviewsAdmin{} }

View File

@@ -0,0 +1,48 @@
<div class="quizzes-review">
{{with .Data}}
<dl>
<dt>{{trans "golem15.quizzes::lang.reviews.quiz"}}</dt>
<dd>{{.QuizTitle}}</dd>
<dt>{{trans "golem15.quizzes::lang.reviews.student"}}</dt>
<dd>{{if .StudentName}}{{.StudentName}} <span class="help-block">{{.StudentEmail}}</span>{{else}}{{.StudentEmail}}{{end}}</dd>
<dt>{{trans "golem15.quizzes::lang.reviews.question"}} ({{.QuestionPoints}} {{trans "golem15.quizzes::lang.reviews.points_short"}})</dt>
<dd><p class="whitespace-pre-wrap">{{.QuestionText}}</p></dd>
{{if .ModelAnswer}}
<dt>{{trans "golem15.quizzes::lang.reviews.model_answer"}}</dt>
<dd><p class="whitespace-pre-wrap">{{.ModelAnswer}}</p></dd>
{{end}}
{{if .Rubric}}
<dt>{{trans "golem15.quizzes::lang.reviews.rubric"}}</dt>
<dd><p class="whitespace-pre-wrap">{{.Rubric}}</p></dd>
{{end}}
<dt>{{trans "golem15.quizzes::lang.reviews.text"}} <span class="help-block">{{trans "golem15.quizzes::lang.reviews.max_length"}}: {{.MaxLength}}</span></dt>
<dd><blockquote class="whitespace-pre-wrap">{{.Text}}</blockquote></dd>
<dt>{{trans "golem15.quizzes::lang.reviews.status"}}</dt>
<dd><strong>{{trans .StatusLabel}}</strong></dd>
{{if .Grader}}
<dt>{{trans "golem15.quizzes::lang.reviews.grader"}}</dt>
<dd>{{trans .GraderLabel}} <span class="help-block">{{trans "golem15.quizzes::lang.reviews.grading_attempts"}}: {{.GradingAttempts}}{{if .GradedAt}} · {{trans "golem15.quizzes::lang.reviews.graded_at"}}: {{.GradedAt}}{{end}}</span></dd>
{{end}}
{{if .HasGraderPoints}}
<dt>{{trans "golem15.quizzes::lang.reviews.grader_points"}}</dt>
<dd>{{.GraderPoints}}</dd>
{{end}}
{{if .GraderFeedback}}
<dt>{{trans "golem15.quizzes::lang.reviews.grader_feedback"}}</dt>
<dd><p class="whitespace-pre-wrap">{{.GraderFeedback}}</p></dd>
{{end}}
{{if .GradingError}}
<dt>{{trans "golem15.quizzes::lang.reviews.grading_error"}}</dt>
<dd><p class="whitespace-pre-wrap">{{.GradingError}}</p></dd>
{{end}}
{{if .Reviewed}}
<dt>{{trans "golem15.quizzes::lang.reviews.points"}}</dt>
<dd><strong>{{.Points}}</strong> / {{.QuestionPoints}}{{if .ReviewedAt}} <span class="help-block">{{trans "golem15.quizzes::lang.reviews.reviewed_at"}}: {{.ReviewedAt}}</span>{{end}}</dd>
{{end}}
{{if .ReviewComment}}
<dt>{{trans "golem15.quizzes::lang.reviews.review_comment"}}</dt>
<dd><p class="whitespace-pre-wrap">{{.ReviewComment}}</p></dd>
{{end}}
</dl>
{{end}}
</div>

View File

@@ -0,0 +1,16 @@
scopes:
quiz:
label: golem15.quizzes::lang.reviews.quiz
modelClass: Quiz
nameFrom: title
scope: filterByQuiz
status:
label: golem15.quizzes::lang.reviews.status
modelClass: Review
nameFrom: grading_status
scope: filterByStatus
student:
label: golem15.quizzes::lang.reviews.student
modelClass: Review
nameFrom: grading_status
scope: filterByStudent

View File

@@ -0,0 +1,15 @@
name: golem15.quizzes::lang.reviews.answer
form: ~/plugins/golem15/quizzes/models/reviews/fields.yaml
modelClass: Review
defaultRedirect: golem15/quizzes/reviews
# Open answers come from students: there is no create. Saving the form
# stores the owner's score and comment, then returns to the preview.
update:
redirect: golem15/quizzes/reviews/preview/:id
redirectClose: golem15/quizzes/reviews
# The preview screen shows the answer and offers the record actions.
preview: {}
recordActions: [confirm, regrade]

View File

@@ -0,0 +1,16 @@
list: ~/plugins/golem15/quizzes/models/reviews/columns.yaml
modelClass: Review
title: golem15.quizzes::lang.reviews.title
recordUrl: golem15/quizzes/reviews/preview/:id
recordsPerPage: 50
perPageOptions: [20, 50, 100]
showCheckboxes: true
defaultSort:
column: updated_at
direction: asc
filter: config_filter.yaml
bulkActions: [confirm]
toolbar:
buttons: []
search:
prompt: backend::lang.list.search_prompt

211
controllers/reviews_test.go Normal file
View File

@@ -0,0 +1,211 @@
package controllers
import (
"bytes"
"errors"
"html/template"
"os"
"reflect"
"slices"
"strings"
"testing"
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/pact"
)
func pointsDetail(t *testing.T, err error) string {
t.Helper()
var invalid *cabana.ValidationError
if !errors.As(err, &invalid) {
t.Fatalf("err = %v, want a cabana.ValidationError", err)
}
msgs, _ := invalid.Details[fieldReviewPoints].([]string)
if len(msgs) != 1 {
t.Fatalf("details = %v, want one message on review_points", invalid.Details)
}
return msgs[0]
}
func TestReviewValues(t *testing.T) {
points, comment, err := reviewValues(map[string]any{"review_points": "1.5", "review_comment": " dobrze "})
if err != nil || points == nil || *points != 1.5 || comment == nil || *comment != "dobrze" {
t.Errorf("1.5 + comment = %v, %v, %v", points, comment, err)
}
if points, _, err := reviewValues(map[string]any{"review_points": float64(2)}); err != nil || points == nil || *points != 2 {
t.Errorf("number 2 = %v, %v", points, err)
}
if points, _, err := reviewValues(map[string]any{"review_points": 2}); err != nil || points == nil || *points != 2 {
t.Errorf("int 2 = %v, %v", points, err)
}
if points, _, err := reviewValues(map[string]any{"review_points": "1,5"}); err != nil || points == nil || *points != 1.5 {
t.Errorf("decimal comma = %v, %v", points, err)
}
if points, comment, err := reviewValues(map[string]any{"review_points": 0.0, "review_comment": " "}); err != nil || points == nil || *points != 0 || comment != nil {
t.Errorf("zero without comment = %v, %v, %v", points, comment, err)
}
for _, values := range []map[string]any{nil, {}, {"review_points": "", "review_comment": ""}, {"review_points": nil}} {
if points, comment, err := reviewValues(values); points != nil || comment != nil || err != nil {
t.Errorf("%v = %v, %v, %v, want nothing to save", values, points, comment, err)
}
}
_, _, err = reviewValues(map[string]any{"review_comment": "bez punktów"})
if got := pointsDetail(t, err); got != reviewsLang+"points_required" {
t.Errorf("comment without points = %q", got)
}
for _, bad := range []any{"abc", "NaN", "Inf", true} {
_, _, err := reviewValues(map[string]any{"review_points": bad})
if got := pointsDetail(t, err); got != reviewsLang+"points_invalid" {
t.Errorf("%v = %q", bad, got)
}
}
}
func TestReviewErrorMapping(t *testing.T) {
if got := pointsDetail(t, reviewError(&classes.ValidationError{Problems: []string{"points"}})); got != reviewsLang+"points_invalid" {
t.Errorf("validation = %q", got)
}
for err, key := range map[error]string{
classes.ErrNoGraderScore: "no_grader_score",
classes.ErrAlreadyReviewed: "already_reviewed",
classes.ErrGraderManual: "grader_manual",
classes.ErrNotFound: "not_found",
} {
var refused *cabana.ForbiddenError
if !errors.As(reviewError(err), &refused) || refused.Message != reviewsLang+key {
t.Errorf("%v = %v, want refusal %s", err, reviewError(err), key)
}
}
other := errors.New("db down")
if got := reviewError(other); got != other {
t.Errorf("other error = %v", got)
}
if reviewError(nil) != nil {
t.Error("nil error mapped")
}
}
func TestGraderDropdown(t *testing.T) {
saved := GraderOptions
t.Cleanup(func() { GraderOptions = saved })
GraderOptions = nil
want := []pact.Option{{Value: graders.Manual, Label: "golem15.quizzes::lang.graders.manual"}}
if got := (quizzesAdmin{}).DropdownOptions("grader"); !slices.Equal(got, want) {
t.Errorf("no hook = %v, want %v", got, want)
}
GraderOptions = func() []graders.Option {
return []graders.Option{{Code: graders.Manual, Label: graders.ManualLabel}, {Code: "ai", Label: "vendor.plugin::lang.grader"}}
}
want = []pact.Option{{Value: "manual", Label: graders.ManualLabel}, {Value: "ai", Label: "vendor.plugin::lang.grader"}}
if got := (quizzesAdmin{}).DropdownOptions("grader"); !slices.Equal(got, want) {
t.Errorf("hook = %v, want %v", got, want)
}
if got := (quizzesAdmin{}).DropdownOptions("mode"); got != nil {
t.Errorf("mode = %v, want nil", got)
}
}
func TestReviewContextViewIsPlain(t *testing.T) {
typ := reflect.TypeOf(reviewContextView{})
for i := range typ.NumField() {
f := typ.Field(i)
switch f.Type.Kind() {
case reflect.String, reflect.Bool,
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
reflect.Float32, reflect.Float64:
default:
t.Errorf("field %s is %s; the view model holds strings, numbers and booleans only", f.Name, f.Type)
}
if f.Type.PkgPath() != "" {
t.Errorf("field %s has the named type %s", f.Name, f.Type)
}
}
name, surname := "Anna", "Nowak"
grader, feedback := "ai", "<b>ok</b>"
gp := 1.5
v := newReviewContextView(&classes.ReviewDetail{
ReviewRow: classes.ReviewRow{Status: "graded", QuizTitle: "Q", Email: "a@example.com", Name: &name, Surname: &surname, Text: "<script>x</script>", GraderPoints: &gp},
Grader: &grader,
GraderFeedback: &feedback,
}, nil)
if v.StudentName != "Anna Nowak" || v.StatusLabel != reviewsLang+"status_graded" || !v.HasGraderPoints || v.GraderPoints != 1.5 {
t.Errorf("view = %+v", v)
}
if v.GraderLabel != "ai" {
t.Errorf("unregistered grader label = %q, want the code", v.GraderLabel)
}
if v.Text != "<script>x</script>" || v.GraderFeedback != "<b>ok</b>" {
t.Errorf("texts are copied raw for the template to escape: %+v", v)
}
}
func TestReviewsControllerShape(t *testing.T) {
c := reviewsAdmin{}
if c.ID() != "golem15.quizzes.reviews" || c.ConfigDir() != "controllers/reviews" {
t.Errorf("controller = %s %s", c.ID(), c.ConfigDir())
}
if !slices.Equal(c.RequiredPermissions(), []string{"golem15.quizzes.access_quizzes"}) {
t.Errorf("permissions = %v", c.RequiredPermissions())
}
if !slices.Equal(c.FormVirtualFields(), []string{"review_points", "review_comment"}) {
t.Errorf("virtual fields = %v", c.FormVirtualFields())
}
var refused *cabana.ForbiddenError
if err := c.FormBeforeCreate(t.Context(), nil); !errors.As(err, &refused) {
t.Errorf("create = %v, want a refusal", err)
}
if err := c.FormBeforeDelete(t.Context(), nil); !errors.As(err, &refused) {
t.Errorf("delete = %v, want a refusal", err)
}
var names []string
for _, a := range c.AdminRecordActions() {
names = append(names, a.Name)
}
if !slices.Equal(names, []string{"confirm", "regrade"}) {
t.Errorf("record actions = %v", names)
}
if bulk := c.AdminBulkActions(); len(bulk) != 1 || bulk[0].Name != "confirm" {
t.Errorf("bulk actions = %+v", bulk)
}
if _, err := c.PartialData(t.Context(), "other", nil); err == nil {
t.Error("unknown partial gave no error")
}
}
func TestReviewContextEscapes(t *testing.T) {
src, err := os.ReadFile("reviews/_review_context.htm")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(src), "safe") || strings.Contains(string(src), "style=") {
t.Error("the partial must not mark text as safe or use inline styles")
}
tpl, err := template.New("review_context").Funcs(template.FuncMap{"trans": func(k string) string { return k }}).Parse(string(src))
if err != nil {
t.Fatalf("parse: %v", err)
}
var out bytes.Buffer
view := reviewContextView{
Text: "<script>alert(1)</script>", GraderFeedback: "<img src=x onerror=alert(1)>",
ReviewComment: "<b>c</b>", Rubric: "<i>r</i>", Grader: "ai", GraderLabel: "ai", Reviewed: true,
StatusLabel: reviewsLang + "status_reviewed",
}
if err := tpl.Execute(&out, map[string]any{"Data": view}); err != nil {
t.Fatalf("execute: %v", err)
}
html := out.String()
for _, raw := range []string{"<script>", "<img", "<b>c</b>", "<i>r</i>"} {
if strings.Contains(html, raw) {
t.Errorf("output carries %q unescaped", raw)
}
}
if !strings.Contains(html, "&lt;script&gt;") {
t.Errorf("student text not escaped: %s", html)
}
}

View File

@@ -51,6 +51,8 @@ quizzes:
navigation_comment: Applies to tests only.
max_attempts: Attempt limit
max_attempts_comment: Tests only. Leave empty for no limit.
grader: Grading of open questions
grader_comment: "Manual: you grade every answer yourself. Installed plugins add other options."
questions:
title: Questions
question: Question
@@ -59,11 +61,16 @@ questions:
type: Type
type_single: Single choice
type_multiple: Multiple choice
type_open: Open question
type_self_check: Self-check (flashcard)
category: Category
points: Points
answer_text: Model answer
answer_text_comment: Revealed for self-check questions.
answer_text_comment: Revealed for self-check questions. For an open question it is the model answer.
rubric: Grading criteria (for the grader only)
rubric_comment: Only the grader sees it, never the student. Applies to open questions.
max_length: Max. answer length
max_length_comment: Characters; empty means 2000. Applies to open questions.
explanation: Explanation
answers:
title: Answers
@@ -81,3 +88,45 @@ attempts:
score_points: Points
max_points: Max points
score_percent: Score (%)
reviews:
title: To review
answer: Open answer
context: Answer
quiz: Quiz
question: Question
student: Student
text: Student's answer
max_length: Character limit
status: Status
status_pending: waiting for a grade
status_graded: to be verified
status_failed: grading failed
status_reviewed: verified
grader: Grader
grader_points: Grader's points
grader_feedback: Grader's feedback
grading_error: Grading error
grading_attempts: Grading attempts
points: Points
points_short: pts
graded_at: Graded
reviewed_at: Verified
model_answer: Model answer
rubric: Grading criteria
review_points: Points (final)
review_points_comment: From 0 to the question's points, in steps of 0.5.
review_comment: Comment to the student
review_comment_comment: The student sees it with their answer. Saving points replaces an earlier comment.
confirm: Confirm the grade
confirm_selected: Confirm selected
confirmed: Grade confirmed.
regrade: Grade again
regrade_confirm: The answer goes back to grading and loses the grader's current result.
regraded: The answer was sent to be graded again.
points_required: Enter the points to save a grade.
points_invalid: Points must be from 0 to the question's points, in steps of 0.5.
no_grader_score: This answer has no grader's score yet; enter the points yourself.
already_reviewed: This answer is already verified.
grader_manual: This quiz is graded manually; nothing can grade the answer again.
not_found: This answer was not found.
not_created: Open answers come from students; they cannot be added or deleted here.

View File

@@ -51,6 +51,8 @@ quizzes:
navigation_comment: Dotyczy tylko testów.
max_attempts: Limit podejść
max_attempts_comment: Tylko dla testów. Zostaw puste, aby nie ograniczać.
grader: Ocenianie pytań otwartych
grader_comment: "Ręcznie: każdą odpowiedź oceniasz sam. Inne opcje dodają zainstalowane wtyczki."
questions:
title: Pytania
question: Pytanie
@@ -60,10 +62,15 @@ questions:
type_single: Jednokrotny wybór
type_multiple: Wielokrotny wybór
type_self_check: Fiszka (samoocena)
type_open: Pytanie otwarte
category: Kategoria
points: Punkty
answer_text: Wzorcowa odpowiedź
answer_text_comment: Pokazywana w fiszkach po odsłonięciu.
answer_text_comment: Pokazywana w fiszkach po odsłonięciu. W pytaniu otwartym to odpowiedź wzorcowa.
rubric: Kryteria oceny (tylko dla oceniającego)
rubric_comment: Widzi je tylko oceniający, nigdy student. Dotyczy pytań otwartych.
max_length: Maks. długość odpowiedzi
max_length_comment: Liczba znaków; puste oznacza 2000. Dotyczy pytań otwartych.
explanation: Wyjaśnienie
answers:
title: Odpowiedzi
@@ -81,3 +88,45 @@ attempts:
score_points: Punkty
max_points: Maks. punktów
score_percent: Wynik (%)
reviews:
title: Do oceny
answer: Odpowiedź otwarta
context: Odpowiedź
quiz: Quiz
question: Pytanie
student: Student
text: Odpowiedź studenta
max_length: Limit znaków
status: Status
status_pending: czeka na ocenę
status_graded: do weryfikacji
status_failed: błąd oceniania
status_reviewed: zweryfikowano
grader: Oceniający
grader_points: Punkty oceniającego
grader_feedback: Uwagi oceniającego
grading_error: Błąd oceniania
grading_attempts: Próby oceny
points: Punkty
points_short: pkt
graded_at: Oceniono
reviewed_at: Zweryfikowano
model_answer: Odpowiedź wzorcowa
rubric: Kryteria oceny
review_points: Punkty (ostateczne)
review_points_comment: Od 0 do liczby punktów pytania, co 0,5.
review_comment: Komentarz dla studenta
review_comment_comment: Student zobaczy go przy swojej odpowiedzi. Zapisanie punktów zastępuje wcześniejszy komentarz.
confirm: Zatwierdź ocenę
confirm_selected: Zatwierdź zaznaczone
confirmed: Ocena zatwierdzona.
regrade: Oceń ponownie
regrade_confirm: Odpowiedź wróci do oceniania i straci obecny wynik oceniającego.
regraded: Odpowiedź wysłana do ponownej oceny.
points_required: Podaj punkty, aby zapisać ocenę.
points_invalid: Punkty muszą mieścić się w zakresie od 0 do liczby punktów pytania, co 0,5.
no_grader_score: Ta odpowiedź nie ma jeszcze wyniku oceniającego; wpisz punkty sam.
already_reviewed: Ta odpowiedź jest już zweryfikowana.
grader_manual: Ten quiz jest oceniany ręcznie; nie ma kto ocenić odpowiedzi ponownie.
not_found: Nie znaleziono tej odpowiedzi.
not_created: Odpowiedzi otwarte pochodzą od studentów; tutaj nie można ich dodawać ani usuwać.

View File

@@ -2,6 +2,7 @@ package models
import (
"encoding/hex"
"slices"
"strconv"
"strings"
@@ -22,6 +23,8 @@ const (
ScopeByQuiz = "filterByQuiz"
ScopeByCategory = "filterByCategory"
ScopeByType = "filterByType"
ScopeByStatus = "filterByStatus"
ScopeByStudent = "filterByStudent"
)
var (
@@ -31,6 +34,8 @@ var (
_ pact.FilterOptions = Answer{}
_ pact.FilterScope = Attempt{}
_ pact.FilterOptions = Attempt{}
_ pact.FilterScope = Review{}
_ pact.FilterOptions = Review{}
)
// FilterScopes lists the admin list scopes of questions.
@@ -75,6 +80,7 @@ func (Question) FilterOptions(scope string) []pact.Option {
{Value: TypeSingle, Label: "golem15.quizzes::lang.questions.type_single"},
{Value: TypeMultiple, Label: "golem15.quizzes::lang.questions.type_multiple"},
{Value: TypeSelfCheck, Label: "golem15.quizzes::lang.questions.type_self_check"},
{Value: TypeOpen, Label: "golem15.quizzes::lang.questions.type_open"},
}
}
return nil
@@ -121,6 +127,54 @@ func (Attempt) FilterOptions(scope string) []pact.Option {
return nil
}
// FilterScopes lists the admin list scopes of the review screen.
func (Review) FilterScopes() []string { return []string{ScopeByQuiz, ScopeByStatus, ScopeByStudent} }
// FilterScope narrows the review list to the open answers of the chosen
// quizzes, grading statuses or students.
func (Review) FilterScope(name string, db *gorm.DB, value any) *gorm.DB {
values := filterValues(value)
if db == nil || len(values) == 0 {
return db
}
switch name {
case ScopeByQuiz:
return db.Where("golem15_quizzes_attempt_answers.question_id IN (SELECT id FROM golem15_quizzes_questions WHERE quiz_id IN ?)", ids(values))
case ScopeByStatus:
statuses := make([]string, 0, len(values))
for _, v := range values {
if slices.Contains(GradingStatuses, v) {
statuses = append(statuses, v)
}
}
if len(statuses) == 0 {
return db.Where("1 = 0")
}
return db.Where("golem15_quizzes_attempt_answers.grading_status IN ?", statuses)
case ScopeByStudent:
return db.Where("golem15_quizzes_attempt_answers.attempt_id IN (SELECT id FROM golem15_quizzes_attempts WHERE user_id IN ?)", ids(values))
}
return db
}
// FilterOptions serves the quizzes, the grading statuses and the students
// who have open answers.
func (Review) FilterOptions(scope string) []pact.Option {
switch scope {
case ScopeByQuiz:
return quizOptions()
case ScopeByStatus:
out := make([]pact.Option, 0, len(GradingStatuses))
for _, status := range GradingStatuses {
out = append(out, pact.Option{Value: status, Label: "golem15.quizzes::lang.reviews.status_" + status})
}
return out
case ScopeByStudent:
return studentOptions()
}
return nil
}
func filterDB() *gorm.DB {
if FilterDB == nil {
return nil
@@ -144,6 +198,32 @@ func quizOptions() []pact.Option {
return out
}
// studentOptions lists the users with at least one open answer, by email.
func studentOptions() []pact.Option {
db := filterDB()
if db == nil {
return nil
}
var rows []struct {
ID uint
Email string
}
err := db.Raw(`SELECT DISTINCT u.id AS id, COALESCE(u.email, '') AS email
FROM users u
JOIN golem15_quizzes_attempts a ON a.user_id = u.id
JOIN golem15_quizzes_attempt_answers aa ON aa.attempt_id = a.id
WHERE aa.grading_status IS NOT NULL
ORDER BY email, id`).Scan(&rows).Error
if err != nil {
return nil
}
out := make([]pact.Option, 0, len(rows))
for _, r := range rows {
out = append(out, pact.Option{Value: strconv.FormatUint(uint64(r.ID), 10), Label: r.Email})
}
return out
}
func categoryOptions() []pact.Option {
db := filterDB()
if db == nil {

View File

@@ -15,6 +15,7 @@ fields:
single: golem15.quizzes::lang.questions.type_single
multiple: golem15.quizzes::lang.questions.type_multiple
self_check: golem15.quizzes::lang.questions.type_self_check
open: golem15.quizzes::lang.questions.type_open
text:
label: golem15.quizzes::lang.questions.text
type: textarea
@@ -35,6 +36,16 @@ fields:
type: textarea
size: small
comment: golem15.quizzes::lang.questions.answer_text_comment
rubric:
label: golem15.quizzes::lang.questions.rubric
type: textarea
size: small
comment: golem15.quizzes::lang.questions.rubric_comment
max_length:
label: golem15.quizzes::lang.questions.max_length
type: number
span: left
comment: golem15.quizzes::lang.questions.max_length_comment
explanation:
label: golem15.quizzes::lang.questions.explanation
type: textarea

View File

@@ -10,6 +10,8 @@ columns:
searchable: true
mode:
label: golem15.quizzes::lang.quizzes.mode
grader:
label: golem15.quizzes::lang.quizzes.grader
is_published:
label: golem15.quizzes::lang.quizzes.is_published
type: switch

View File

@@ -65,3 +65,10 @@ fields:
type: number
span: left
comment: golem15.quizzes::lang.quizzes.max_attempts_comment
grader:
label: golem15.quizzes::lang.quizzes.grader
type: dropdown
span: right
default: manual
options: getGraderOptions
comment: golem15.quizzes::lang.quizzes.grader_comment

60
models/review.go Normal file
View File

@@ -0,0 +1,60 @@
package models
import "time"
// ReviewMorphName is Review's morph type. Review shares its table with
// AttemptAnswer, so it names itself: two models listed in Models() with the
// same morph type would stop deferred:purge.
const ReviewMorphName = "golem15.quizzes.review"
// Review is an open answer as the admin "Do oceny" screen shows it. It reads
// the same golem15_quizzes_attempt_answers table as AttemptAnswer, which
// keeps its empty Fillable for the API; the admin never writes a column
// through it either. The owner's score and comment are form virtual fields
// that the controller saves through the review service.
type Review struct {
ID uint `gorm:"column:id;primaryKey"`
AttemptID uint `gorm:"column:attempt_id"`
QuestionID uint `gorm:"column:question_id"`
TextAnswer *string `gorm:"column:text_answer"`
GradingStatus *string `gorm:"column:grading_status"`
GradingSeq int `gorm:"column:grading_seq"`
GradingAttempts int `gorm:"column:grading_attempts"`
Grader *string `gorm:"column:grader"`
GraderPoints *float64 `gorm:"column:grader_points"`
GraderFeedback *string `gorm:"column:grader_feedback"`
GradingError *string `gorm:"column:grading_error"`
GradedAt *time.Time `gorm:"column:graded_at"`
Points float64 `gorm:"column:points"`
IsCorrect bool `gorm:"column:is_correct"`
ReviewComment *string `gorm:"column:review_comment"`
ReviewedAt *time.Time `gorm:"column:reviewed_at"`
ReviewedByUserID *uint `gorm:"column:reviewed_by_user_id"`
ReviewedByAdminID *uint `gorm:"column:reviewed_by_admin_id"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
Question *Question `gorm:"foreignKey:QuestionID"`
Attempt *Attempt `gorm:"foreignKey:AttemptID"`
}
// TableName is the attempt answers table.
func (Review) TableName() string { return "golem15_quizzes_attempt_answers" }
// MorphName keeps Review apart from AttemptAnswer, which owns the table.
func (Review) MorphName() string { return ReviewMorphName }
// Fillable is empty: the admin form saves no column of an answer.
func (Review) Fillable() []string { return []string{} }
// Rules are empty: the controller's FormRules check the virtual fields.
func (Review) Rules() map[string]string { return map[string]string{} }
// ReviewStatus reports the grading status, "" when the row is not an open
// answer.
func (r Review) ReviewStatus() string {
if r.GradingStatus == nil {
return ""
}
return *r.GradingStatus
}

View File

@@ -0,0 +1,21 @@
columns:
question:
label: golem15.quizzes::lang.reviews.question
relation: question
select: text
searchable: true
grading_status:
label: golem15.quizzes::lang.reviews.status
grader_points:
label: golem15.quizzes::lang.reviews.grader_points
points:
label: golem15.quizzes::lang.reviews.points
graded_at:
label: golem15.quizzes::lang.reviews.graded_at
type: datetime
reviewed_at:
label: golem15.quizzes::lang.reviews.reviewed_at
type: datetime
updated_at:
label: golem15.quizzes::lang.common.updated_at
type: datetime

View File

@@ -0,0 +1,19 @@
# The answer itself is read-only: the review_context partial shows it from a
# curated view model. review_points and review_comment are virtual fields the
# controller saves through the review service (SetScore).
fields:
review_context:
label: golem15.quizzes::lang.reviews.context
type: partial
path: review_context
span: full
review_points:
label: golem15.quizzes::lang.reviews.review_points
type: number
span: left
comment: golem15.quizzes::lang.reviews.review_points_comment
review_comment:
label: golem15.quizzes::lang.reviews.review_comment
type: textarea
size: small
comment: golem15.quizzes::lang.reviews.review_comment_comment

View File

@@ -11,12 +11,14 @@ import (
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
"git.golem15.com/golem15/sm-quizzes-plugin/console"
"git.golem15.com/golem15/sm-quizzes-plugin/controllers"
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
"git.golem15.com/golem15/sm-quizzes-plugin/jobs"
"git.golem15.com/golem15/sm-quizzes-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/pact"
@@ -52,7 +54,7 @@ var langFS embed.FS
//go:embed views/mail
var mailFS embed.FS
//go:embed controllers/*/*.yaml models/*/*.yaml
//go:embed controllers/*/*.yaml controllers/*/*.htm models/*/*.yaml
var adminFS embed.FS
// Plugin is the golem15.quizzes plugin.
@@ -76,7 +78,8 @@ func (p *Plugin) Register(app *backpack.App) error {
}
// Boot keeps the application, collects the grader registry and points the
// admin filter choices at its database.
// admin hooks at it: the filter choices and the review screen at its
// database, the quiz grader dropdown at the registry.
//
// Grading plugins never register themselves: golem15.quizzes pulls their
// graders through graders.HasGraders, as golem15.paymentgateway pulls
@@ -93,9 +96,30 @@ func (p *Plugin) Boot(app *backpack.App) error {
gdb, _ := app.Lookup[*gorm.DB]()
return gdb
}
controllers.GraderOptions = p.graders.Options
controllers.Reviews = func(ctx context.Context) *classes.Service {
return reviewService(ctx, app)
}
return nil
}
// reviewService is the admin review screen's service: over the admin
// request's write transaction when there is one, so a review nests in the
// save, else over the application's database.
func reviewService(ctx context.Context, app *backpack.App) *classes.Service {
if tx, ok := cabana.TxFromContext(ctx); ok && tx != nil {
return classes.ForApp(app, tx)
}
if app == nil {
return nil
}
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return nil
}
return classes.ForApp(app, gdb)
}
// bootGraders builds the grader registry: manual first, then the graders of
// every other activated plugin that implements graders.HasGraders, in
// activation order. A duplicate or reserved code fails boot. The registry
@@ -235,6 +259,7 @@ func (p *Plugin) Navigation() []pact.NavigationItem {
{Code: "quizzes", Label: "golem15.quizzes::lang.quizzes.title", Icon: "icon-list", Controller: "golem15.quizzes.quizzes", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "questions", Label: "golem15.quizzes::lang.questions.title", Icon: "icon-question", Controller: "golem15.quizzes.questions", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "answers", Label: "golem15.quizzes::lang.answers.title", Icon: "icon-check-square", Controller: "golem15.quizzes.answers", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "reviews", Label: "golem15.quizzes::lang.reviews.title", Icon: "icon-check", Controller: "golem15.quizzes.reviews", Permissions: []string{"golem15.quizzes.access_quizzes"}},
{Code: "attempts", Label: "golem15.quizzes::lang.attempts.title", Icon: "icon-bar-chart", Controller: "golem15.quizzes.attempts", Permissions: []string{"golem15.quizzes.access_attempts"}},
},
}}

View File

@@ -36,8 +36,8 @@ func TestPluginActivates(t *testing.T) {
if got := p.Requires(); !slices.Equal(got, []string{"golem15.user"}) {
t.Errorf("Requires = %v, want golem15.user", got)
}
if got := len(p.(pact.HasModels).Models()); got != 5 {
t.Errorf("Models = %d, want 5", got)
if got := len(p.(pact.HasModels).Models()); got != 6 {
t.Errorf("Models = %d, want 6", got)
}
migrations := p.(pact.HasMigrations).Migrations()
if len(migrations) == 0 {

View File

@@ -18,6 +18,7 @@ func generatedModels() []any {
&models.AttemptAnswer{},
&models.Question{},
&models.Quiz{},
&models.Review{},
}
}
@@ -45,5 +46,6 @@ func generatedAdminControllers() []pact.AdminController {
controllers.AttemptsController(),
controllers.QuestionsController(),
controllers.QuizzesController(),
controllers.ReviewsController(),
}
}