fix assessment import and exams draft
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
namespace App\Commands;
|
||||
|
||||
use App\Libraries\AssessmentDocxReader;
|
||||
use CodeIgniter\CLI\BaseCommand;
|
||||
use CodeIgniter\CLI\CLI;
|
||||
use Config\Database;
|
||||
use Throwable;
|
||||
|
||||
class ImportAssessmentDocx extends BaseCommand
|
||||
{
|
||||
protected $group = 'Assessments';
|
||||
protected $name = 'assessment:import-docx';
|
||||
protected $description = 'Import new-student assessment answers from a DOCX file.';
|
||||
protected $usage = 'php spark assessment:import-docx <file.docx> --student-id <id> --form-id <id> [--dry-run] [--overwrite] [--complete]';
|
||||
protected $arguments = [
|
||||
'file' => 'Path to the DOCX containing the seeded questions and their answers.',
|
||||
];
|
||||
protected $options = [
|
||||
'--student-id' => 'Required database ID of the student.',
|
||||
'--form-id' => 'Required assessment form ID.',
|
||||
'--dry-run' => 'Parse and validate without changing the database.',
|
||||
'--overwrite' => 'Allow replacement of existing non-empty answers.',
|
||||
'--complete' => 'Mark the assessment completed; requires all form questions to have answers.',
|
||||
];
|
||||
|
||||
public function run(array $params)
|
||||
{
|
||||
$path = trim((string) ($params[0] ?? ''));
|
||||
$studentId = $this->positiveOption('student-id');
|
||||
$formId = $this->positiveOption('form-id');
|
||||
$dryRun = CLI::getOption('dry-run') !== null;
|
||||
$overwrite = CLI::getOption('overwrite') !== null;
|
||||
$complete = CLI::getOption('complete') !== null;
|
||||
|
||||
if ($path === '' || $studentId === null || $formId === null) {
|
||||
CLI::error('File, --student-id, and --form-id are required.');
|
||||
CLI::write($this->usage);
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
|
||||
try {
|
||||
$realPath = realpath($path);
|
||||
if ($realPath === false) {
|
||||
throw new \RuntimeException("DOCX file does not exist: {$path}");
|
||||
}
|
||||
|
||||
$db = Database::connect();
|
||||
$this->assertTables($db);
|
||||
|
||||
$student = $db->table('students')->select('id, school_id, firstname, lastname')->where('id', $studentId)->get()->getRowArray();
|
||||
if ($student === null) {
|
||||
throw new \RuntimeException("Student {$studentId} was not found.");
|
||||
}
|
||||
|
||||
$form = $db->table('assessment_forms f')
|
||||
->select('f.id, f.name, f.pool_id, f.status, f.school_year, p.name AS pool_name')
|
||||
->join('question_pools p', 'p.id = f.pool_id')
|
||||
->where('f.id', $formId)->get()->getRowArray();
|
||||
if ($form === null) {
|
||||
throw new \RuntimeException("Assessment form {$formId} was not found.");
|
||||
}
|
||||
if ((string) $form['pool_name'] !== 'New Student Assessment') {
|
||||
throw new \RuntimeException('The selected form does not belong to the New Student Assessment pool.');
|
||||
}
|
||||
|
||||
$questions = $db->table('assessment_form_questions fq')
|
||||
->select('q.id, q.text, fq.order_index')
|
||||
->join('assessment_questions q', 'q.id = fq.question_id')
|
||||
->where('fq.form_id', $formId)
|
||||
->orderBy('fq.order_index', 'ASC')->get()->getResultArray();
|
||||
if ($questions === []) {
|
||||
throw new \RuntimeException('The selected assessment form has no questions.');
|
||||
}
|
||||
|
||||
$parsed = (new AssessmentDocxReader())->read($realPath, $questions);
|
||||
CLI::write(sprintf(
|
||||
'Student: #%d %s %s (%s)',
|
||||
$studentId,
|
||||
(string) $student['firstname'],
|
||||
(string) $student['lastname'],
|
||||
(string) ($student['school_id'] ?? 'no school ID')
|
||||
));
|
||||
CLI::write(sprintf('Form: #%d %s [%s]', $formId, (string) $form['name'], (string) ($form['school_year'] ?? '')));
|
||||
CLI::write(sprintf('Parsed %d document blocks; matched %d of %d questions.', $parsed['block_count'], count($parsed['answers']), count($questions)));
|
||||
foreach ($questions as $question) {
|
||||
$questionId = (int) $question['id'];
|
||||
$length = mb_strlen($parsed['answers'][$questionId] ?? '');
|
||||
CLI::write(sprintf(' [%s] question_id=%d answer_chars=%d', $length > 0 ? 'matched' : 'missing', $questionId, $length));
|
||||
}
|
||||
|
||||
if ($parsed['answers'] === []) {
|
||||
throw new \RuntimeException('No answers were matched. Check that the DOCX contains the exact seeded question text.');
|
||||
}
|
||||
if ($complete && $parsed['missing'] !== []) {
|
||||
throw new \RuntimeException('--complete cannot be used while one or more form questions are missing answers.');
|
||||
}
|
||||
|
||||
$assessment = $db->table('student_assessments')->where(['student_id' => $studentId, 'form_id' => $formId])->get()->getRowArray();
|
||||
$existingAnswers = [];
|
||||
if ($assessment !== null) {
|
||||
foreach ($db->table('student_answers')->where('student_assessment_id', (int) $assessment['id'])->get()->getResultArray() as $row) {
|
||||
$existingAnswers[(int) $row['question_id']] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$conflicts = [];
|
||||
foreach ($parsed['answers'] as $questionId => $answer) {
|
||||
$old = trim((string) ($existingAnswers[$questionId]['answer_value'] ?? ''));
|
||||
if ($old !== '' && $old !== $answer) {
|
||||
$conflicts[] = $questionId;
|
||||
}
|
||||
}
|
||||
if ($conflicts !== [] && $dryRun) {
|
||||
CLI::write('Dry run found ' . count($conflicts) . ' existing answer(s) that differ; an actual import will require --overwrite.', 'yellow');
|
||||
} elseif ($conflicts !== [] && ! $overwrite) {
|
||||
throw new \RuntimeException('Existing answers differ for question ID(s) ' . implode(', ', $conflicts) . '; rerun with --overwrite after reviewing the dry run.');
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
CLI::write('Dry run complete; no database rows were changed.', 'yellow');
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$db->transBegin();
|
||||
try {
|
||||
if ($assessment === null) {
|
||||
$db->table('student_assessments')->insert([
|
||||
'form_id' => $formId,
|
||||
'student_id' => $studentId,
|
||||
'status' => $complete ? 'completed' : 'in_progress',
|
||||
'assigned_at' => $now,
|
||||
'started_at' => $now,
|
||||
'submitted_at' => $complete ? $now : null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$assessmentId = (int) $db->insertID();
|
||||
} else {
|
||||
$assessmentId = (int) $assessment['id'];
|
||||
}
|
||||
|
||||
foreach ($parsed['answers'] as $questionId => $answer) {
|
||||
$payload = ['answer_value' => $answer, 'updated_at' => $now];
|
||||
if (isset($existingAnswers[$questionId])) {
|
||||
if (trim((string) $existingAnswers[$questionId]['answer_value']) !== $answer) {
|
||||
$db->table('student_answers')->where('id', (int) $existingAnswers[$questionId]['id'])->update($payload);
|
||||
}
|
||||
} else {
|
||||
$db->table('student_answers')->insert($payload + [
|
||||
'student_assessment_id' => $assessmentId,
|
||||
'question_id' => $questionId,
|
||||
'created_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($assessment !== null) {
|
||||
$statusUpdate = ['updated_at' => $now];
|
||||
if ($complete && (string) $assessment['status'] !== 'graded') {
|
||||
$statusUpdate += ['status' => 'completed', 'submitted_at' => $now];
|
||||
} elseif ((string) $assessment['status'] === 'not_started') {
|
||||
$statusUpdate += ['status' => 'in_progress', 'started_at' => $now];
|
||||
}
|
||||
$db->table('student_assessments')->where('id', $assessmentId)->update($statusUpdate);
|
||||
}
|
||||
|
||||
if ($db->transStatus() === false) {
|
||||
throw new \RuntimeException('The database rejected one or more imported rows.');
|
||||
}
|
||||
$db->transCommit();
|
||||
} catch (Throwable $e) {
|
||||
$db->transRollback();
|
||||
throw $e;
|
||||
}
|
||||
|
||||
CLI::write(sprintf('Imported %d answer(s) into student assessment #%d.', count($parsed['answers']), $assessmentId), 'green');
|
||||
if ($parsed['missing'] !== []) {
|
||||
CLI::write(count($parsed['missing']) . ' unanswered question(s) were left unchanged.', 'yellow');
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
CLI::error($e->getMessage());
|
||||
return EXIT_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
private function positiveOption(string $name): ?int
|
||||
{
|
||||
$rawValue = CLI::getOption($name);
|
||||
|
||||
// CodeIgniter 4.7 does not split --option=value. Support that common
|
||||
// spelling as well as its native --option value form.
|
||||
if ($rawValue === null || $rawValue === true) {
|
||||
$prefix = '--' . $name . '=';
|
||||
foreach ($_SERVER['argv'] ?? [] as $argument) {
|
||||
if (is_string($argument) && str_starts_with($argument, $prefix)) {
|
||||
$rawValue = substr($argument, strlen($prefix));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$value = filter_var($rawValue, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||
return $value === false ? null : (int) $value;
|
||||
}
|
||||
|
||||
private function assertTables($db): void
|
||||
{
|
||||
foreach (['students', 'question_pools', 'assessment_questions', 'assessment_forms', 'assessment_form_questions', 'student_assessments', 'student_answers'] as $table) {
|
||||
if (! $db->tableExists($table)) {
|
||||
throw new \RuntimeException("Required table {$table} does not exist; run the migrations first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user