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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
509
controllers/reviews.go
Normal 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{} }
|
||||
48
controllers/reviews/_review_context.htm
Normal file
48
controllers/reviews/_review_context.htm
Normal 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>
|
||||
16
controllers/reviews/config_filter.yaml
Normal file
16
controllers/reviews/config_filter.yaml
Normal 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
|
||||
15
controllers/reviews/config_form.yaml
Normal file
15
controllers/reviews/config_form.yaml
Normal 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]
|
||||
16
controllers/reviews/config_list.yaml
Normal file
16
controllers/reviews/config_list.yaml
Normal 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
211
controllers/reviews_test.go
Normal 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, "<script>") {
|
||||
t.Errorf("student text not escaped: %s", html)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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ć.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
60
models/review.go
Normal 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
|
||||
}
|
||||
21
models/reviews/columns.yaml
Normal file
21
models/reviews/columns.yaml
Normal 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
|
||||
19
models/reviews/fields.yaml
Normal file
19
models/reviews/fields.yaml
Normal 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
|
||||
29
plugin.go
29
plugin.go
@@ -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"}},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user