feat: add open questions with a pending grading state
- migration: open question type, rubric, max_length, quiz grader and the grading and review columns of attempt answers - open answers are checked, stored as pending and never scored on answer; a changed text resets the grading state - student JSON gains text, grading_status, grader_feedback, review_comment, max_length, needs_review and open_answers; the rubric is never serialized
This commit is contained in:
@@ -93,7 +93,7 @@ func TestCheckQuestion(t *testing.T) {
|
||||
want []string
|
||||
}{
|
||||
"no text": {classes.ImportQuestion{Type: "self_check"}, []string{"questions[0].text is required"}},
|
||||
"bad type": {classes.ImportQuestion{Text: "Q", Type: "essay"}, []string{"questions[0].type must be one of single, multiple, self_check"}},
|
||||
"bad type": {classes.ImportQuestion{Text: "Q", Type: "essay"}, []string{"questions[0].type must be one of single, multiple, self_check, open"}},
|
||||
"negative points": {classes.ImportQuestion{Text: "Q", Type: "self_check", Points: &neg}, []string{"questions[0].points must not be negative"}},
|
||||
"single two right": {classes.ImportQuestion{Text: "Q", Type: "single", Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}, {Text: "b", IsCorrect: true}}}, []string{"questions[0]: a single question needs at least two answers and exactly one correct"}},
|
||||
"multiple one item": {classes.ImportQuestion{Text: "Q", Type: "multiple", Answers: []classes.ImportAnswer{{Text: "a", IsCorrect: true}}}, []string{"questions[0]: a multiple question needs at least two answers and one or more correct"}},
|
||||
|
||||
33
classes/open.go
Normal file
33
classes/open.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package classes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
)
|
||||
|
||||
// MaxLength is the longest answer an open question accepts, in characters:
|
||||
// its MaxLength when set and positive, else models.DefaultMaxLength.
|
||||
func MaxLength(q models.Question) int {
|
||||
if q.MaxLength != nil && *q.MaxLength > 0 {
|
||||
return *q.MaxLength
|
||||
}
|
||||
return models.DefaultMaxLength
|
||||
}
|
||||
|
||||
// CheckOpenText cleans a student's answer to an open question (D-04): it
|
||||
// removes every NUL character (Postgres text refuses them) and the
|
||||
// surrounding whitespace. A blank text is a FieldError text_required and a
|
||||
// text longer than MaxLength(q) characters (runes, not bytes) is a
|
||||
// FieldError text_too_long.
|
||||
func CheckOpenText(text string, q models.Question) (string, error) {
|
||||
text = strings.TrimSpace(strings.ReplaceAll(text, "\x00", ""))
|
||||
if text == "" {
|
||||
return "", &FieldError{Field: "text", Key: "text_required"}
|
||||
}
|
||||
if utf8.RuneCountInString(text) > MaxLength(q) {
|
||||
return "", &FieldError{Field: "text", Key: "text_too_long"}
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
59
classes/open_test.go
Normal file
59
classes/open_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package classes_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/classes"
|
||||
"git.golem15.com/golem15/sm-quizzes-plugin/models"
|
||||
)
|
||||
|
||||
func TestCheckOpenText(t *testing.T) {
|
||||
q := models.Question{Type: models.TypeOpen}
|
||||
long := 3000
|
||||
wide := models.Question{Type: models.TypeOpen, MaxLength: &long}
|
||||
|
||||
fieldKey := func(err error) string {
|
||||
var fe *classes.FieldError
|
||||
if errors.As(err, &fe) && fe.Field == "text" {
|
||||
return fe.Key
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
if _, err := classes.CheckOpenText(" \n\t ", q); fieldKey(err) != "text_required" {
|
||||
t.Errorf("blank text: err = %v, want text_required", err)
|
||||
}
|
||||
if _, err := classes.CheckOpenText("\x00 \x00", q); fieldKey(err) != "text_required" {
|
||||
t.Errorf("NUL-only text: err = %v, want text_required", err)
|
||||
}
|
||||
over := strings.Repeat("a", 2001)
|
||||
if _, err := classes.CheckOpenText(over, q); fieldKey(err) != "text_too_long" {
|
||||
t.Errorf("2001 runes with default max: err = %v, want text_too_long", err)
|
||||
}
|
||||
if got, err := classes.CheckOpenText(over, wide); err != nil || got != over {
|
||||
t.Errorf("2001 runes with max 3000: %q, %v", got[:10], err)
|
||||
}
|
||||
if got, err := classes.CheckOpenText("a\x00b", q); err != nil || got != "ab" {
|
||||
t.Errorf("NUL stripped: %q, %v; want ab", got, err)
|
||||
}
|
||||
if got, err := classes.CheckOpenText(" moja odpowiedź \n", q); err != nil || got != "moja odpowiedź" {
|
||||
t.Errorf("trimmed: %q, %v", got, err)
|
||||
}
|
||||
polish := strings.Repeat("ż", 2000)
|
||||
if got, err := classes.CheckOpenText(polish, q); err != nil || got != polish {
|
||||
t.Errorf("2000 runes of ż (4000 bytes) refused: %v", err)
|
||||
}
|
||||
five := 5
|
||||
if _, err := classes.CheckOpenText("abcdef", models.Question{MaxLength: &five}); fieldKey(err) != "text_too_long" {
|
||||
t.Errorf("6 runes with max 5: err = %v, want text_too_long", err)
|
||||
}
|
||||
if classes.MaxLength(q) != models.DefaultMaxLength || classes.MaxLength(wide) != 3000 {
|
||||
t.Errorf("MaxLength = %d / %d", classes.MaxLength(q), classes.MaxLength(wide))
|
||||
}
|
||||
zero := 0
|
||||
if classes.MaxLength(models.Question{MaxLength: &zero}) != models.DefaultMaxLength {
|
||||
t.Error("MaxLength of a non-positive value should fall back to the default")
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,27 @@ func Totals(points []float64, maxPoints int) (score, percent float64) {
|
||||
return score, round(score / float64(maxPoints) * 100)
|
||||
}
|
||||
|
||||
// ClampOpenPoints keeps a grader's score of an open question in
|
||||
// 0..points (a negative points value counts as 0), rounded to the nearest
|
||||
// 0.5 (D-02). NaN and ±Inf are not a score: it returns 0, false.
|
||||
func ClampOpenPoints(p float64, points int) (float64, bool) {
|
||||
if math.IsNaN(p) || math.IsInf(p, 0) {
|
||||
return 0, false
|
||||
}
|
||||
full := float64(max(points, 0))
|
||||
p = math.Round(math.Min(math.Max(p, 0), full)*2) / 2
|
||||
if p == 0 {
|
||||
return 0, true // no negative zero in JSON
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// OpenCorrect reports whether an open answer scored p earns full marks: a
|
||||
// question with points earns is_correct only with all of them (D-02).
|
||||
func OpenCorrect(p float64, points int) bool {
|
||||
return points > 0 && p == float64(points)
|
||||
}
|
||||
|
||||
// round keeps two decimals.
|
||||
func round(f float64) float64 {
|
||||
r := math.Round(f*100) / 100
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package classes_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -130,3 +131,37 @@ func TestScoringConfigFrom(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClampOpenPoints(t *testing.T) {
|
||||
cases := []struct {
|
||||
p float64
|
||||
points int
|
||||
want float64
|
||||
ok bool
|
||||
}{
|
||||
{1.26, 2, 1.5, true},
|
||||
{-3, 2, 0, true},
|
||||
{7, 2, 2, true},
|
||||
{0.74, 3, 0.5, true},
|
||||
{0.25, 3, 0.5, true},
|
||||
{0.2, 3, 0, true},
|
||||
{2, 2, 2, true},
|
||||
{1, 0, 0, true},
|
||||
{5, -1, 0, true},
|
||||
{math.NaN(), 2, 0, false},
|
||||
{math.Inf(1), 2, 0, false},
|
||||
{math.Inf(-1), 2, 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, ok := classes.ClampOpenPoints(tc.p, tc.points)
|
||||
if got != tc.want || ok != tc.ok {
|
||||
t.Errorf("ClampOpenPoints(%v, %d) = %v, %v; want %v, %v", tc.p, tc.points, got, ok, tc.want, tc.ok)
|
||||
}
|
||||
if math.Signbit(got) {
|
||||
t.Errorf("ClampOpenPoints(%v, %d) is negative zero", tc.p, tc.points)
|
||||
}
|
||||
}
|
||||
if !classes.OpenCorrect(2, 2) || classes.OpenCorrect(1.5, 2) || classes.OpenCorrect(0, 0) {
|
||||
t.Error("OpenCorrect: want true only for full points of a question worth points")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +227,10 @@ type Started struct {
|
||||
AttemptsUsed int
|
||||
// AnsweredCount is the number of questions a resumed attempt answered.
|
||||
AnsweredCount int
|
||||
// OpenAnswers and Unreviewed count the open answers of a resumed
|
||||
// attempt, as in AttemptSummary.
|
||||
OpenAnswers int
|
||||
Unreviewed int
|
||||
}
|
||||
|
||||
// StartAttempt starts an attempt at a published quiz for the user, like
|
||||
@@ -322,11 +326,15 @@ func (s *Service) startTest(ctx context.Context, userID uint, quiz *models.Quiz)
|
||||
return err
|
||||
}
|
||||
if resume != nil {
|
||||
var answered int64
|
||||
if err := tx.Model(&models.AttemptAnswer{}).Where("attempt_id = ?", resume.ID).Count(&answered).Error; err != nil {
|
||||
counts, err := answerCounts(ctx, tx, []uint{resume.ID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
st = &Started{Attempt: resume, Quiz: quiz, Resumed: true, AttemptsUsed: int(used), AnsweredCount: int(answered)}
|
||||
c := counts[resume.ID]
|
||||
st = &Started{
|
||||
Attempt: resume, Quiz: quiz, Resumed: true, AttemptsUsed: int(used),
|
||||
AnsweredCount: c.N, OpenAnswers: c.OpenN, Unreviewed: c.UnreviewedN,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if left := AttemptsLeft(quiz, int(used)); left != nil && *left == 0 {
|
||||
@@ -403,6 +411,8 @@ type AnswerInput struct {
|
||||
QuestionID uint
|
||||
AnswerIDs []uint
|
||||
Known *bool
|
||||
// Text is the answer to an open question.
|
||||
Text *string
|
||||
}
|
||||
|
||||
// Feedback is what the user learns after answering one question.
|
||||
@@ -414,6 +424,9 @@ type Feedback struct {
|
||||
// and the serializer must drop Result, CorrectAnswerIDs and every
|
||||
// solution field of Question.
|
||||
Withheld bool
|
||||
// Answer is the stored row of an open question, with its text and
|
||||
// grading state; it is nil for every other type.
|
||||
Answer *models.AttemptAnswer
|
||||
}
|
||||
|
||||
// Answer records or overwrites the user's answer to one question of an
|
||||
@@ -423,8 +436,11 @@ type Feedback struct {
|
||||
// question belongs to the attempt (FieldError on question_id), a one_way
|
||||
// test has no answer to the question yet (ErrAnswerLocked; the attempt row
|
||||
// is locked, so the check is race-free), and the answer fits the question
|
||||
// type (FieldError on answer_ids or known). The feedback of an open test
|
||||
// attempt is Withheld.
|
||||
// type (FieldError on answer_ids, known or text). The feedback of an open
|
||||
// test attempt is Withheld.
|
||||
//
|
||||
// An open question stores its text as pending (see answerOpen) and is never
|
||||
// scored here: a grader or the owner scores it later.
|
||||
func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerInput) (*Feedback, error) {
|
||||
var fb *Feedback
|
||||
err := lagoon.Transaction(ctx, s.DB, func(ctx context.Context, tx *gorm.DB) error {
|
||||
@@ -464,6 +480,20 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
|
||||
|
||||
row := models.AttemptAnswer{AttemptID: attempt.ID, QuestionID: question.ID}
|
||||
switch question.Type {
|
||||
case models.TypeOpen:
|
||||
if in.Text == nil {
|
||||
return &FieldError{Field: "text", Key: "text_required"}
|
||||
}
|
||||
text, err := CheckOpenText(*in.Text, question)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stored, err := s.answerOpen(ctx, tx, attempt, question, text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fb = &Feedback{Question: question, Answer: stored, Withheld: Withheld(attempt)}
|
||||
return nil
|
||||
case models.TypeSelfCheck:
|
||||
if in.Known == nil {
|
||||
return &FieldError{Field: "known", Key: "known_required"}
|
||||
@@ -512,6 +542,78 @@ func (s *Service) Answer(ctx context.Context, userID, attemptID uint, in AnswerI
|
||||
return fb, nil
|
||||
}
|
||||
|
||||
// answerOpen stores a cleaned open answer in a transaction that holds the
|
||||
// attempt row lock, so reading the existing row first is race-free. The
|
||||
// same text again changes nothing. Another text is a new answer (Pitfall 1):
|
||||
// the row goes back to pending with the next grading_seq, and every grade,
|
||||
// error, review and point of the old text is cleared, so a learning
|
||||
// re-answer replaces an earlier grade or review. A first answer creates the
|
||||
// row as pending with seq 1. A new or changed text is handed to
|
||||
// enqueueGrading.
|
||||
func (s *Service) answerOpen(ctx context.Context, tx *gorm.DB, attempt *models.Attempt, question models.Question, text string) (*models.AttemptAnswer, error) {
|
||||
tx = tx.WithContext(ctx)
|
||||
now := s.now().UTC()
|
||||
var existing models.AttemptAnswer
|
||||
err := tx.Where("attempt_id = ? AND question_id = ?", attempt.ID, question.ID).Take(&existing).Error
|
||||
switch {
|
||||
case err == nil:
|
||||
if existing.TextAnswer != nil && *existing.TextAnswer == text && existing.GradingStatus != nil {
|
||||
return &existing, nil
|
||||
}
|
||||
seq := existing.GradingSeq + 1
|
||||
if err := tx.Model(&models.AttemptAnswer{}).Where("id = ?", existing.ID).Updates(map[string]any{
|
||||
"text_answer": text,
|
||||
"answer_ids": nil,
|
||||
"self_check_known": nil,
|
||||
"grading_status": models.GradingPending,
|
||||
"grading_seq": seq,
|
||||
"grading_attempts": 0,
|
||||
"grader": nil,
|
||||
"grader_points": nil,
|
||||
"grader_feedback": nil,
|
||||
"grading_error": nil,
|
||||
"graded_at": nil,
|
||||
"review_comment": nil,
|
||||
"reviewed_at": nil,
|
||||
"reviewed_by_user_id": nil,
|
||||
"reviewed_by_admin_id": nil,
|
||||
"points": 0,
|
||||
"is_correct": false,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Where("id = ?", existing.ID).Take(&existing).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enqueueGrading(ctx, tx, existing.ID, seq, attempt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &existing, nil
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
pending := models.GradingPending
|
||||
row := &models.AttemptAnswer{
|
||||
AttemptID: attempt.ID, QuestionID: question.ID,
|
||||
TextAnswer: &text, GradingStatus: &pending, GradingSeq: 1,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := tx.Create(row).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enqueueGrading(ctx, tx, row.ID, row.GradingSeq, attempt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
// enqueueGrading hands a new or changed open answer to the quiz's grader.
|
||||
func (s *Service) enqueueGrading(ctx context.Context, tx *gorm.DB, answerID uint, seq int, attempt *models.Attempt) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CorrectAnswerIDs lists the IDs of a question's correct answers in sort
|
||||
// order; it is empty for a self_check question.
|
||||
func CorrectAnswerIDs(q models.Question) []uint {
|
||||
@@ -574,6 +676,50 @@ func (s *Service) close(ctx context.Context, tx *gorm.DB, attempt *models.Attemp
|
||||
type AttemptSummary struct {
|
||||
Attempt models.Attempt
|
||||
AnsweredCount int
|
||||
// OpenAnswers counts the attempt's answers to open questions (rows
|
||||
// with a grading status).
|
||||
OpenAnswers int
|
||||
// Unreviewed counts the open answers the owner has not reviewed yet:
|
||||
// pending, graded or failed.
|
||||
Unreviewed int
|
||||
}
|
||||
|
||||
// answerCountRow is the per-attempt answer counts answerCounts reads.
|
||||
type answerCountRow struct {
|
||||
AttemptID uint
|
||||
N int
|
||||
OpenN int
|
||||
UnreviewedN int
|
||||
}
|
||||
|
||||
// answerCounts counts, per attempt, the answers, the open answers and the
|
||||
// open answers not reviewed yet.
|
||||
func answerCounts(ctx context.Context, db *gorm.DB, attemptIDs []uint) (map[uint]answerCountRow, error) {
|
||||
var rows []answerCountRow
|
||||
if err := db.WithContext(ctx).Model(&models.AttemptAnswer{}).
|
||||
Select("attempt_id, COUNT(*) AS n, "+
|
||||
"COUNT(*) FILTER (WHERE grading_status IS NOT NULL) AS open_n, "+
|
||||
"COUNT(*) FILTER (WHERE grading_status IN ('pending', 'graded', 'failed')) AS unreviewed_n").
|
||||
Where("attempt_id IN ?", attemptIDs).Group("attempt_id").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[uint]answerCountRow, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.AttemptID] = r
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// unreviewed reports whether an open answer still waits for the owner.
|
||||
func unreviewed(a *models.AttemptAnswer) bool {
|
||||
if a == nil || a.GradingStatus == nil {
|
||||
return false
|
||||
}
|
||||
switch *a.GradingStatus {
|
||||
case models.GradingPending, models.GradingGraded, models.GradingFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// QuestionResult is one question of an attempt with the user's answer.
|
||||
@@ -638,6 +784,14 @@ func (s *Service) Result(ctx context.Context, userID, attemptID uint) (*AttemptR
|
||||
byQuestion[answers[i].QuestionID] = &answers[i]
|
||||
}
|
||||
res := &AttemptResult{AttemptSummary: AttemptSummary{Attempt: *attempt, AnsweredCount: len(answers)}}
|
||||
for i := range answers {
|
||||
if answers[i].GradingStatus != nil {
|
||||
res.OpenAnswers++
|
||||
}
|
||||
if unreviewed(&answers[i]) {
|
||||
res.Unreviewed++
|
||||
}
|
||||
}
|
||||
for _, q := range questions {
|
||||
res.Questions = append(res.Questions, QuestionResult{Question: q, Answer: byQuestion[q.ID]})
|
||||
}
|
||||
@@ -668,21 +822,13 @@ func (s *Service) History(ctx context.Context, userID uint, quizSlug string, pag
|
||||
for i, a := range attempts {
|
||||
ids[i] = a.ID
|
||||
}
|
||||
type countRow struct {
|
||||
AttemptID uint
|
||||
N int
|
||||
}
|
||||
var counts []countRow
|
||||
if err := s.db(ctx).Model(&models.AttemptAnswer{}).Select("attempt_id, COUNT(*) AS n").
|
||||
Where("attempt_id IN ?", ids).Group("attempt_id").Scan(&counts).Error; err != nil {
|
||||
countBy, err := answerCounts(ctx, s.DB, ids)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
countBy := map[uint]int{}
|
||||
for _, c := range counts {
|
||||
countBy[c.AttemptID] = c.N
|
||||
}
|
||||
for _, a := range attempts {
|
||||
out = append(out, AttemptSummary{Attempt: a, AnsweredCount: countBy[a.ID]})
|
||||
c := countBy[a.ID]
|
||||
out = append(out, AttemptSummary{Attempt: a, AnsweredCount: c.N, OpenAnswers: c.OpenN, Unreviewed: c.UnreviewedN})
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user