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

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

View File

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

View File

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

View File

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

View File

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

60
models/review.go Normal file
View File

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

View File

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

View File

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