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,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
|
||||
Reference in New Issue
Block a user