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:
Jakub Zych
2026-11-09 17:48:11 +01:00
parent ef38b81fd9
commit 297780cb8e
19 changed files with 893 additions and 48 deletions

View File

@@ -0,0 +1,108 @@
// Code generated by summer make. DO NOT EDIT.
package updates
import (
"fmt"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
// AddOpenQuestionsAndGrading returns the 20261109180000_add_open_questions_and_grading gormigrate entry.
// It adds the open question type (answered in free text, graded against the
// model answer and an owner-only rubric), the quiz grader and the grading
// and review state of each attempt answer. Every existing question, quiz and
// answer keeps today's behaviour: the grader is manual, no question is open
// and every existing answer row has no grading state.
func AddOpenQuestionsAndGrading() *gormigrate.Migration {
return &gormigrate.Migration{
ID: "20261109180000_add_open_questions_and_grading",
Migrate: func(tx *gorm.DB) error {
// The original type CHECK is an unnamed inline constraint, so
// it is found by the column it checks rather than by an assumed
// name. Its definition cannot be matched on "IN": Postgres
// stores it as type = ANY (ARRAY[...]).
for _, stmt := range []string{
`DO $$
DECLARE c RECORD;
BEGIN
FOR c IN
SELECT con.conname FROM pg_constraint con
JOIN pg_attribute att ON att.attrelid = con.conrelid AND att.attname = 'type'
WHERE con.conrelid = 'golem15_quizzes_questions'::regclass
AND con.contype = 'c'
AND con.conkey = ARRAY[att.attnum]
LOOP
EXECUTE format('ALTER TABLE golem15_quizzes_questions DROP CONSTRAINT %I', c.conname);
END LOOP;
END $$`,
`ALTER TABLE golem15_quizzes_questions
ADD CONSTRAINT golem15_quizzes_questions_type_check CHECK (type IN ('single', 'multiple', 'self_check', 'open')),
ADD COLUMN rubric TEXT NULL,
ADD COLUMN max_length INTEGER NULL CHECK (max_length IS NULL OR max_length BETWEEN 1 AND 20000)`,
`ALTER TABLE golem15_quizzes_quizzes
ADD COLUMN grader VARCHAR(64) NOT NULL DEFAULT 'manual'`,
`ALTER TABLE golem15_quizzes_attempt_answers
ADD COLUMN text_answer TEXT NULL,
ADD COLUMN grading_status VARCHAR(20) NULL CHECK (grading_status IS NULL OR grading_status IN ('pending', 'graded', 'failed', 'reviewed')),
ADD COLUMN grading_seq INTEGER NOT NULL DEFAULT 0,
ADD COLUMN grading_attempts INTEGER NOT NULL DEFAULT 0,
ADD COLUMN grader VARCHAR(64) NULL,
ADD COLUMN grader_points DOUBLE PRECISION NULL,
ADD COLUMN grader_feedback TEXT NULL,
ADD COLUMN grading_error TEXT NULL,
ADD COLUMN graded_at TIMESTAMPTZ NULL,
ADD COLUMN review_comment TEXT NULL,
ADD COLUMN reviewed_at TIMESTAMPTZ NULL,
ADD COLUMN reviewed_by_user_id INTEGER NULL,
ADD COLUMN reviewed_by_admin_id INTEGER NULL`,
`CREATE INDEX golem15_quizzes_attempt_answers_grading_index ON golem15_quizzes_attempt_answers (grading_status)
WHERE grading_status IN ('pending', 'graded', 'failed')`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
var n int64
if err := tx.Raw(`SELECT COUNT(*) FROM golem15_quizzes_questions WHERE type = 'open'`).Scan(&n).Error; err != nil {
return err
}
if n > 0 {
return fmt.Errorf("quizzes: rollback refused: %d open questions exist; delete them first", n)
}
for _, stmt := range []string{
`DROP INDEX IF EXISTS golem15_quizzes_attempt_answers_grading_index`,
`ALTER TABLE golem15_quizzes_attempt_answers
DROP COLUMN IF EXISTS reviewed_by_admin_id,
DROP COLUMN IF EXISTS reviewed_by_user_id,
DROP COLUMN IF EXISTS reviewed_at,
DROP COLUMN IF EXISTS review_comment,
DROP COLUMN IF EXISTS graded_at,
DROP COLUMN IF EXISTS grading_error,
DROP COLUMN IF EXISTS grader_feedback,
DROP COLUMN IF EXISTS grader_points,
DROP COLUMN IF EXISTS grader,
DROP COLUMN IF EXISTS grading_attempts,
DROP COLUMN IF EXISTS grading_seq,
DROP COLUMN IF EXISTS grading_status,
DROP COLUMN IF EXISTS text_answer`,
`ALTER TABLE golem15_quizzes_quizzes DROP COLUMN IF EXISTS grader`,
`ALTER TABLE golem15_quizzes_questions
DROP COLUMN IF EXISTS max_length,
DROP COLUMN IF EXISTS rubric,
DROP CONSTRAINT IF EXISTS golem15_quizzes_questions_type_check`,
`ALTER TABLE golem15_quizzes_questions
ADD CONSTRAINT golem15_quizzes_questions_type_check CHECK (type IN ('single', 'multiple', 'self_check'))`,
} {
if err := tx.Exec(stmt).Error; err != nil {
return err
}
}
return nil
},
}
}