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