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 (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||||
"git.golem15.com/golem15/summercms/modules/cabana"
|
"git.golem15.com/golem15/summercms/modules/cabana"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/utils/tests"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAdminCompiles(t *testing.T) {
|
func TestAdminCompiles(t *testing.T) {
|
||||||
@@ -37,7 +41,7 @@ func TestAdminCompiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
slices.Sort(ids)
|
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) {
|
if !slices.Equal(ids, want) {
|
||||||
t.Errorf("controllers = %v, want %v", ids, want)
|
t.Errorf("controllers = %v, want %v", ids, want)
|
||||||
}
|
}
|
||||||
@@ -52,17 +56,78 @@ func TestAdminCompiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nav := p.(pact.HasNavigation).Navigation()
|
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)
|
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) {
|
func TestQuestionFilterScopes(t *testing.T) {
|
||||||
q := models.Question{}
|
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)
|
t.Errorf("type options = %v", got)
|
||||||
}
|
}
|
||||||
if got := q.FilterScope("unknown", nil, "x"); got != nil {
|
if got := q.FilterScope("unknown", nil, "x"); got != nil {
|
||||||
t.Errorf("FilterScope on a nil db = %v", got)
|
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
|
package controllers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"git.golem15.com/golem15/sm-quizzes-plugin/graders"
|
||||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
)
|
)
|
||||||
@@ -11,8 +12,17 @@ var (
|
|||||||
_ pact.AdminController = quizzesAdmin{}
|
_ pact.AdminController = quizzesAdmin{}
|
||||||
_ pact.AdminRecordSource = quizzesAdmin{}
|
_ pact.AdminRecordSource = quizzesAdmin{}
|
||||||
_ pact.AdminPermissioned = 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
|
// quizzesAdmin lists and edits quizzes. Questions have their own controller
|
||||||
// filtered by quiz.
|
// filtered by quiz.
|
||||||
//
|
//
|
||||||
@@ -33,5 +43,25 @@ func (quizzesAdmin) RequiredPermissions() []string {
|
|||||||
return []string{"golem15.quizzes.access_quizzes"}
|
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.
|
// QuizzesController returns the golem15.quizzes.quizzes admin controller.
|
||||||
func QuizzesController() pact.AdminController { return quizzesAdmin{} }
|
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.
|
navigation_comment: Applies to tests only.
|
||||||
max_attempts: Attempt limit
|
max_attempts: Attempt limit
|
||||||
max_attempts_comment: Tests only. Leave empty for no 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:
|
questions:
|
||||||
title: Questions
|
title: Questions
|
||||||
question: Question
|
question: Question
|
||||||
@@ -59,11 +61,16 @@ questions:
|
|||||||
type: Type
|
type: Type
|
||||||
type_single: Single choice
|
type_single: Single choice
|
||||||
type_multiple: Multiple choice
|
type_multiple: Multiple choice
|
||||||
|
type_open: Open question
|
||||||
type_self_check: Self-check (flashcard)
|
type_self_check: Self-check (flashcard)
|
||||||
category: Category
|
category: Category
|
||||||
points: Points
|
points: Points
|
||||||
answer_text: Model answer
|
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
|
explanation: Explanation
|
||||||
answers:
|
answers:
|
||||||
title: Answers
|
title: Answers
|
||||||
@@ -81,3 +88,45 @@ attempts:
|
|||||||
score_points: Points
|
score_points: Points
|
||||||
max_points: Max points
|
max_points: Max points
|
||||||
score_percent: Score (%)
|
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.
|
navigation_comment: Dotyczy tylko testów.
|
||||||
max_attempts: Limit podejść
|
max_attempts: Limit podejść
|
||||||
max_attempts_comment: Tylko dla testów. Zostaw puste, aby nie ograniczać.
|
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:
|
questions:
|
||||||
title: Pytania
|
title: Pytania
|
||||||
question: Pytanie
|
question: Pytanie
|
||||||
@@ -60,10 +62,15 @@ questions:
|
|||||||
type_single: Jednokrotny wybór
|
type_single: Jednokrotny wybór
|
||||||
type_multiple: Wielokrotny wybór
|
type_multiple: Wielokrotny wybór
|
||||||
type_self_check: Fiszka (samoocena)
|
type_self_check: Fiszka (samoocena)
|
||||||
|
type_open: Pytanie otwarte
|
||||||
category: Kategoria
|
category: Kategoria
|
||||||
points: Punkty
|
points: Punkty
|
||||||
answer_text: Wzorcowa odpowiedź
|
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
|
explanation: Wyjaśnienie
|
||||||
answers:
|
answers:
|
||||||
title: Odpowiedzi
|
title: Odpowiedzi
|
||||||
@@ -81,3 +88,45 @@ attempts:
|
|||||||
score_points: Punkty
|
score_points: Punkty
|
||||||
max_points: Maks. punktów
|
max_points: Maks. punktów
|
||||||
score_percent: Wynik (%)
|
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 (
|
import (
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -22,6 +23,8 @@ const (
|
|||||||
ScopeByQuiz = "filterByQuiz"
|
ScopeByQuiz = "filterByQuiz"
|
||||||
ScopeByCategory = "filterByCategory"
|
ScopeByCategory = "filterByCategory"
|
||||||
ScopeByType = "filterByType"
|
ScopeByType = "filterByType"
|
||||||
|
ScopeByStatus = "filterByStatus"
|
||||||
|
ScopeByStudent = "filterByStudent"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -31,6 +34,8 @@ var (
|
|||||||
_ pact.FilterOptions = Answer{}
|
_ pact.FilterOptions = Answer{}
|
||||||
_ pact.FilterScope = Attempt{}
|
_ pact.FilterScope = Attempt{}
|
||||||
_ pact.FilterOptions = Attempt{}
|
_ pact.FilterOptions = Attempt{}
|
||||||
|
_ pact.FilterScope = Review{}
|
||||||
|
_ pact.FilterOptions = Review{}
|
||||||
)
|
)
|
||||||
|
|
||||||
// FilterScopes lists the admin list scopes of questions.
|
// 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: TypeSingle, Label: "golem15.quizzes::lang.questions.type_single"},
|
||||||
{Value: TypeMultiple, Label: "golem15.quizzes::lang.questions.type_multiple"},
|
{Value: TypeMultiple, Label: "golem15.quizzes::lang.questions.type_multiple"},
|
||||||
{Value: TypeSelfCheck, Label: "golem15.quizzes::lang.questions.type_self_check"},
|
{Value: TypeSelfCheck, Label: "golem15.quizzes::lang.questions.type_self_check"},
|
||||||
|
{Value: TypeOpen, Label: "golem15.quizzes::lang.questions.type_open"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -121,6 +127,54 @@ func (Attempt) FilterOptions(scope string) []pact.Option {
|
|||||||
return nil
|
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 {
|
func filterDB() *gorm.DB {
|
||||||
if FilterDB == nil {
|
if FilterDB == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -144,6 +198,32 @@ func quizOptions() []pact.Option {
|
|||||||
return out
|
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 {
|
func categoryOptions() []pact.Option {
|
||||||
db := filterDB()
|
db := filterDB()
|
||||||
if db == nil {
|
if db == nil {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ fields:
|
|||||||
single: golem15.quizzes::lang.questions.type_single
|
single: golem15.quizzes::lang.questions.type_single
|
||||||
multiple: golem15.quizzes::lang.questions.type_multiple
|
multiple: golem15.quizzes::lang.questions.type_multiple
|
||||||
self_check: golem15.quizzes::lang.questions.type_self_check
|
self_check: golem15.quizzes::lang.questions.type_self_check
|
||||||
|
open: golem15.quizzes::lang.questions.type_open
|
||||||
text:
|
text:
|
||||||
label: golem15.quizzes::lang.questions.text
|
label: golem15.quizzes::lang.questions.text
|
||||||
type: textarea
|
type: textarea
|
||||||
@@ -35,6 +36,16 @@ fields:
|
|||||||
type: textarea
|
type: textarea
|
||||||
size: small
|
size: small
|
||||||
comment: golem15.quizzes::lang.questions.answer_text_comment
|
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:
|
explanation:
|
||||||
label: golem15.quizzes::lang.questions.explanation
|
label: golem15.quizzes::lang.questions.explanation
|
||||||
type: textarea
|
type: textarea
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ columns:
|
|||||||
searchable: true
|
searchable: true
|
||||||
mode:
|
mode:
|
||||||
label: golem15.quizzes::lang.quizzes.mode
|
label: golem15.quizzes::lang.quizzes.mode
|
||||||
|
grader:
|
||||||
|
label: golem15.quizzes::lang.quizzes.grader
|
||||||
is_published:
|
is_published:
|
||||||
label: golem15.quizzes::lang.quizzes.is_published
|
label: golem15.quizzes::lang.quizzes.is_published
|
||||||
type: switch
|
type: switch
|
||||||
|
|||||||
@@ -65,3 +65,10 @@ fields:
|
|||||||
type: number
|
type: number
|
||||||
span: left
|
span: left
|
||||||
comment: golem15.quizzes::lang.quizzes.max_attempts_comment
|
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/classes"
|
||||||
"git.golem15.com/golem15/sm-quizzes-plugin/console"
|
"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/graders"
|
||||||
"git.golem15.com/golem15/sm-quizzes-plugin/jobs"
|
"git.golem15.com/golem15/sm-quizzes-plugin/jobs"
|
||||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
"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/conga"
|
||||||
"git.golem15.com/golem15/summercms/modules/lagoon"
|
"git.golem15.com/golem15/summercms/modules/lagoon"
|
||||||
"git.golem15.com/golem15/summercms/modules/pact"
|
"git.golem15.com/golem15/summercms/modules/pact"
|
||||||
@@ -52,7 +54,7 @@ var langFS embed.FS
|
|||||||
//go:embed views/mail
|
//go:embed views/mail
|
||||||
var mailFS embed.FS
|
var mailFS embed.FS
|
||||||
|
|
||||||
//go:embed controllers/*/*.yaml models/*/*.yaml
|
//go:embed controllers/*/*.yaml controllers/*/*.htm models/*/*.yaml
|
||||||
var adminFS embed.FS
|
var adminFS embed.FS
|
||||||
|
|
||||||
// Plugin is the golem15.quizzes plugin.
|
// 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
|
// 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
|
// Grading plugins never register themselves: golem15.quizzes pulls their
|
||||||
// graders through graders.HasGraders, as golem15.paymentgateway pulls
|
// 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]()
|
gdb, _ := app.Lookup[*gorm.DB]()
|
||||||
return gdb
|
return gdb
|
||||||
}
|
}
|
||||||
|
controllers.GraderOptions = p.graders.Options
|
||||||
|
controllers.Reviews = func(ctx context.Context) *classes.Service {
|
||||||
|
return reviewService(ctx, app)
|
||||||
|
}
|
||||||
return nil
|
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
|
// bootGraders builds the grader registry: manual first, then the graders of
|
||||||
// every other activated plugin that implements graders.HasGraders, in
|
// every other activated plugin that implements graders.HasGraders, in
|
||||||
// activation order. A duplicate or reserved code fails boot. The registry
|
// 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: "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: "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: "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"}},
|
{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"}) {
|
if got := p.Requires(); !slices.Equal(got, []string{"golem15.user"}) {
|
||||||
t.Errorf("Requires = %v, want golem15.user", got)
|
t.Errorf("Requires = %v, want golem15.user", got)
|
||||||
}
|
}
|
||||||
if got := len(p.(pact.HasModels).Models()); got != 5 {
|
if got := len(p.(pact.HasModels).Models()); got != 6 {
|
||||||
t.Errorf("Models = %d, want 5", got)
|
t.Errorf("Models = %d, want 6", got)
|
||||||
}
|
}
|
||||||
migrations := p.(pact.HasMigrations).Migrations()
|
migrations := p.(pact.HasMigrations).Migrations()
|
||||||
if len(migrations) == 0 {
|
if len(migrations) == 0 {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func generatedModels() []any {
|
|||||||
&models.AttemptAnswer{},
|
&models.AttemptAnswer{},
|
||||||
&models.Question{},
|
&models.Question{},
|
||||||
&models.Quiz{},
|
&models.Quiz{},
|
||||||
|
&models.Review{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,5 +46,6 @@ func generatedAdminControllers() []pact.AdminController {
|
|||||||
controllers.AttemptsController(),
|
controllers.AttemptsController(),
|
||||||
controllers.QuestionsController(),
|
controllers.QuestionsController(),
|
||||||
controllers.QuizzesController(),
|
controllers.QuizzesController(),
|
||||||
|
controllers.ReviewsController(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user