Files
alrahma_sunday_school/app/Commands/ImportAssessmentDocx.php
T
root ce504c933e
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 51s
Tests / PHPUnit (push) Failing after 1m26s
fix assessment import and exams draft
2026-09-12 02:39:47 -04:00

219 lines
10 KiB
PHP

<?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.");
}
}
}
}