fix assessment import and exams draft
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 51s
Tests / PHPUnit (push) Failing after 1m26s

This commit is contained in:
root
2026-09-12 02:39:47 -04:00
parent 4b2dd4bdf8
commit ce504c933e
7 changed files with 1397 additions and 109 deletions
+218
View File
@@ -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.");
}
}
}
}
+528 -90
View File
@@ -36,6 +36,7 @@ class ExamDraftController extends BaseController
protected string $authorFilenameColumn = 'teacher_filename';
protected string $reviewerIdColumn = 'admin_id';
protected string $reviewerCommentColumn = 'reviewer_comment';
private bool $stirlingPdfUnavailable = false;
// DB status: submitted, accepted, review needed, rejected, canceled, under review, legacy
@@ -369,11 +370,14 @@ class ExamDraftController extends BaseController
public function reviewIndex()
{
$this->syncAcademicContext();
if ($this->reviewerIdColumn !== '') {
$allDrafts = $this->examDraftModel
->select($this->draftSelectColumns())
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last')
->select('cs.class_section_name, cs.class_id, c.class_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
->join('users a', 'a.id = exam_drafts.' . $this->reviewerIdColumn, 'left')
->orderBy('exam_drafts.created_at', 'DESC')
@@ -381,57 +385,36 @@ class ExamDraftController extends BaseController
} else {
$allDrafts = $this->examDraftModel
->select($this->draftSelectColumns())
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, NULL AS admin_first, NULL AS admin_last', false)
->select('cs.class_section_name, cs.class_id, c.class_name, u.firstname AS teacher_first, u.lastname AS teacher_last, NULL AS admin_first, NULL AS admin_last', false)
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->join('classes c', 'c.id = cs.class_id', 'left')
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
->orderBy('exam_drafts.created_at', 'DESC')
->findAll();
}
if ($this->hasReviewRevisionColumn) {
$allDrafts = $this->groupReviewRevisions($allDrafts);
}
foreach ($allDrafts as &$row) {
if (empty($row['final_pdf_file'])) {
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
if ($pdf !== null) {
$row['final_pdf_file'] = $pdf;
}
$status = strtolower(trim((string) ($row['status'] ?? '')));
if ($status === 'accepted' || $status === 'legacy' || !empty($row['is_legacy'])) {
$this->ensureArchivedPdf($row, false);
}
}
unset($row);
if ($this->hasReviewRevisionColumn) {
$allDrafts = $this->groupReviewRevisions($allDrafts);
}
$classSections = $this->classSectionModel
->select('class_section_id, class_section_name')
->select('class_section_id, class_section_name, class_id')
->orderBy('class_section_name', 'ASC')
->findAll();
// Group legacy uploads (admin-uploaded accepted exams) by class_section for separate tab
$legacyByClass = [];
if ($this->hasIsLegacyColumn) {
// Keep legacy items out of the main submissions list; show them in the legacy tab only.
$drafts = [];
foreach ($allDrafts as $d) {
$isLegacy = !empty($d['is_legacy']);
if ($isLegacy) {
$cid = (int)($d['class_section_id'] ?? 0);
if (!isset($legacyByClass[$cid])) {
$legacyByClass[$cid] = [
'class_section_id' => $cid,
'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid,
'items' => [],
];
}
$legacyByClass[$cid]['items'][] = $d;
continue;
}
$drafts[] = $d;
}
} else {
// Column missing: keep behavior simple and avoid legacy tab
$drafts = $allDrafts;
}
// The submissions tab follows the selected school year. Accepted submissions
// stay visible in their year and are also copied into the final grade archive.
[$drafts, $archivedExams] = $this->partitionExamDraftsByYear($allDrafts, $this->schoolYear);
$legacyByClass = $this->groupArchivedExamsByGrade($this->latestFinalArchiveRows($archivedExams));
$legacyFlat = [];
foreach ($legacyByClass as $group) {
@@ -473,8 +456,6 @@ class ExamDraftController extends BaseController
$classData['student_count'] = $count;
$visibleClasses[$csId] = $classData;
}
uasort($visibleClasses, static fn ($a, $b): int => strcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? ''));
$classDraftGroups = [];
foreach ($drafts as $draft) {
$cid = (int) ($draft['class_section_id'] ?? 0);
@@ -483,6 +464,21 @@ class ExamDraftController extends BaseController
}
$classDraftGroups[$cid][] = $draft;
}
// Historical enrollment counts may be incomplete. Never hide a valid exam
// submission merely because its old class has no count row for that year.
foreach ($classDraftGroups as $cid => $group) {
if (isset($visibleClasses[$cid])) {
continue;
}
$classData = $classSectionsById[$cid] ?? [
'class_section_id' => $cid,
'class_section_name' => $group[0]['class_section_name'] ?? ('Class ' . $cid),
];
$classData['student_count'] = (int) ($studentCounts[$cid] ?? 0);
$visibleClasses[$cid] = $classData;
}
uasort($visibleClasses, static fn ($a, $b): int => strcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? ''));
$newSubmissionClasses = [];
foreach ($classDraftGroups as $cid => $group) {
foreach ($group as $draft) {
@@ -571,6 +567,10 @@ class ExamDraftController extends BaseController
self::FINAL_UPLOAD_DIR
);
}
if ($pdfName === null) {
return redirect()->back()->withInput()->with('error', 'The legacy exam could not be converted to PDF. Nothing was archived.');
}
$pdfFilename = pathinfo($file->getClientName(), PATHINFO_FILENAME) . '.pdf';
$basePayload = [
$this->authorIdColumn => $adminId, // store under admin user since legacy uploads are admin-only
'semester' => ucfirst(strtolower($semester)),
@@ -578,8 +578,8 @@ class ExamDraftController extends BaseController
'exam_type' => $examType,
'draft_title' => $examType,
'author_comment' => null,
'final_file' => $stored,
'final_filename' => $file->getClientName(),
'final_file' => $pdfName,
'final_filename' => $pdfFilename,
'status' => 'legacy',
'reviewed_at' => utc_now(),
'version' => 1,
@@ -590,7 +590,7 @@ class ExamDraftController extends BaseController
if ($this->hasIsLegacyColumn) {
$basePayload['is_legacy'] = 1;
}
if ($pdfName !== null && $this->hasFinalPdfColumn) {
if ($this->hasFinalPdfColumn) {
$basePayload['final_pdf_file'] = $pdfName;
}
@@ -739,6 +739,13 @@ class ExamDraftController extends BaseController
$newRow['final_file'] = $finalFile;
$newRow['final_filename'] = $finalFilename;
$pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null);
if ($status === 'accepted' && $pdfName === null) {
return redirect()->back()->withInput()->with('error', 'The accepted exam could not be converted to PDF. The review was not finalized.');
}
if ($status === 'accepted' && $pdfName !== null) {
$newRow['final_file'] = $pdfName;
$newRow['final_filename'] = pathinfo((string) $finalFilename, PATHINFO_FILENAME) . '.pdf';
}
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$newRow['final_pdf_file'] = $pdfName;
}
@@ -750,8 +757,8 @@ class ExamDraftController extends BaseController
}
return redirect()->back()->with('error', 'Unable to save the review.');
}
if ($status === 'legacy') {
$this->prepareLegacyPdfVersion($update, $draft);
if (in_array($status, ['accepted', 'legacy'], true) && !$this->prepareLegacyPdfVersion($update, $draft)) {
return redirect()->back()->withInput()->with('error', 'The final exam could not be converted to PDF. The review was not finalized.');
}
if ($this->hasIsLegacyColumn) {
$update['is_legacy'] = strtolower($status) === 'legacy' ? 1 : 0;
@@ -1284,41 +1291,47 @@ class ExamDraftController extends BaseController
$reviewRev = (int) ($row['review_revision'] ?? 0);
if ($reviewRev > 0) {
if (!isset($grouped[$key])) {
$grouped[$key] = $row;
$grouped[$key]['_is_review_row'] = true;
$grouped[$key]['review_files'] = [];
$grouped[$key]['final_version'] = null;
$grouped[$key]['_latest_review_comment'] = null;
}
$grouped[$key]['review_files'][] = [
'review_revision' => $reviewRev,
'final_file' => $row['final_file'] ?? null,
'final_filename' => $row['final_filename'] ?? null,
'status' => $row['status'] ?? null,
];
$reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null;
if ($reviewComment !== null && $reviewComment !== '') {
$latestComment = $grouped[$key]['_latest_review_comment'] ?? null;
if ($latestComment === null || $reviewRev > (int) ($latestComment['review_revision'] ?? 0)) {
$grouped[$key]['_latest_review_comment'] = [
'review_revision' => $reviewRev,
'comment' => $reviewComment,
];
if (!isset($grouped[$key])) {
$grouped[$key] = $row;
$grouped[$key]['_is_review_row'] = true;
$grouped[$key]['review_files'] = [];
$grouped[$key]['final_version'] = null;
$grouped[$key]['_latest_review_comment'] = null;
$grouped[$key]['_latest_review'] = null;
}
}
if (strtolower((string) ($row['status'] ?? '')) === 'accepted') {
$currentFinal = $grouped[$key]['final_version'] ?? null;
if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) {
$grouped[$key]['final_version'] = [
'review_revision' => $reviewRev,
'final_file' => $row['final_file'] ?? null,
'final_filename' => $row['final_filename'] ?? null,
];
$grouped[$key]['review_files'][] = [
'review_revision' => $reviewRev,
'final_file' => $row['final_file'] ?? null,
'final_filename' => $row['final_filename'] ?? null,
'status' => $row['status'] ?? null,
];
$reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null;
if ($reviewComment !== null && $reviewComment !== '') {
$latestComment = $grouped[$key]['_latest_review_comment'] ?? null;
if ($latestComment === null || $reviewRev > (int) ($latestComment['review_revision'] ?? 0)) {
$grouped[$key]['_latest_review_comment'] = [
'review_revision' => $reviewRev,
'comment' => $reviewComment,
];
}
}
$latestReview = $grouped[$key]['_latest_review'] ?? null;
if ($latestReview === null || $reviewRev > (int) ($latestReview['review_revision'] ?? 0)) {
$grouped[$key]['_latest_review'] = $row;
}
if (strtolower((string) ($row['status'] ?? '')) === 'accepted') {
$currentFinal = $grouped[$key]['final_version'] ?? null;
if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) {
$grouped[$key]['final_version'] = [
'review_revision' => $reviewRev,
'final_file' => $row['final_file'] ?? null,
'final_filename' => $row['final_filename'] ?? null,
'final_pdf_file' => $row['final_pdf_file'] ?? null,
];
}
}
continue;
}
continue;
}
if (!isset($grouped[$key])) {
$grouped[$key] = $row;
@@ -1328,9 +1341,13 @@ class ExamDraftController extends BaseController
if (!empty($grouped[$key]['_is_review_row'])) {
$reviewFiles = $grouped[$key]['review_files'] ?? [];
$finalVersion = $grouped[$key]['final_version'] ?? null;
$latestReviewComment = $grouped[$key]['_latest_review_comment'] ?? null;
$latestReview = $grouped[$key]['_latest_review'] ?? null;
$grouped[$key] = $row;
$grouped[$key]['review_files'] = $reviewFiles;
$grouped[$key]['final_version'] = $finalVersion;
$grouped[$key]['_latest_review'] = $latestReview;
if (!empty($latestReviewComment['comment'])) {
$grouped[$key]['reviewer_comment'] = $latestReviewComment['comment'];
}
@@ -1339,24 +1356,190 @@ class ExamDraftController extends BaseController
}
foreach ($grouped as &$row) {
$latestReview = $row['_latest_review'] ?? null;
if (is_array($latestReview)) {
foreach ([
'status',
'acceptance_type',
'reviewed_at',
'reviewer_id',
'admin_id',
'admin_first',
'admin_last',
'review_revision',
] as $field) {
if (array_key_exists($field, $latestReview)) {
$row[$field] = $latestReview[$field];
}
}
}
if (!empty($row['review_files']) && is_array($row['review_files'])) {
usort($row['review_files'], static fn($a, $b) => ($a['review_revision'] ?? 0) <=> ($b['review_revision'] ?? 0));
}
if (!empty($row['final_version']) && is_array($row['final_version'])) {
$row['final_file'] = $row['final_version']['final_file'] ?? $row['final_file'] ?? null;
$row['final_filename'] = $row['final_version']['final_filename'] ?? $row['final_filename'] ?? null;
$row['final_pdf_file'] = $row['final_version']['final_pdf_file'] ?? $row['final_pdf_file'] ?? null;
}
if (empty($row['reviewer_comment']) && !empty($row['_latest_review_comment']['comment'])) {
$row['reviewer_comment'] = $row['_latest_review_comment']['comment'];
}
unset($row['_is_review_row']);
unset($row['_latest_review_comment']);
unset($row['_latest_review']);
}
unset($row);
return array_values($grouped);
}
/**
* @param list<array<string,mixed>> $rows
* @return array<int,array<string,mixed>>
*/
private function groupArchivedExamsByGrade(array $rows): array
{
$groups = [];
foreach ($rows as $row) {
$gradeId = (int) ($row['class_id'] ?? 0);
if ($gradeId <= 0) {
$gradeId = (int) ($row['class_section_id'] ?? 0);
}
$gradeName = trim((string) ($row['class_name'] ?? ''));
if ($gradeName === '') {
$gradeName = preg_replace('/-[A-Z]$/i', '', (string) ($row['class_section_name'] ?? '')) ?: ('Class ' . $gradeId);
}
$normalizedGrade = strtolower($gradeName);
$gradeLabel = in_array($normalizedGrade, ['kg', 'youth', 'arabic'], true)
? $gradeName
: 'Grade ' . $gradeName;
if (!isset($groups[$gradeId])) {
$groups[$gradeId] = [
'class_id' => $gradeId,
'class_section_name' => $gradeLabel,
'items' => [],
'_item_indexes' => [],
];
}
$fileKey = (string) ($row['final_file'] ?? $row['teacher_file'] ?? '');
if ($fileKey === '') {
$fileKey = 'row:' . (int) ($row['id'] ?? 0);
}
$itemKey = implode('|', [
$fileKey,
(string) ($row['school_year'] ?? ''),
strtolower((string) ($row['semester'] ?? '')),
strtolower((string) ($row['exam_type'] ?? '')),
(int) ($row['version'] ?? 0),
]);
$sectionName = trim((string) ($row['class_section_name'] ?? ''));
if (isset($groups[$gradeId]['_item_indexes'][$itemKey])) {
$index = $groups[$gradeId]['_item_indexes'][$itemKey];
if ($sectionName !== '' && !in_array($sectionName, $groups[$gradeId]['items'][$index]['class_section_names'], true)) {
$groups[$gradeId]['items'][$index]['class_section_names'][] = $sectionName;
}
continue;
}
$row['class_section_names'] = $sectionName !== '' ? [$sectionName] : [];
$groups[$gradeId]['_item_indexes'][$itemKey] = count($groups[$gradeId]['items']);
$groups[$gradeId]['items'][] = $row;
}
foreach ($groups as &$group) {
unset($group['_item_indexes']);
usort($group['items'], static function (array $a, array $b): int {
$yearCompare = strnatcasecmp((string) ($b['school_year'] ?? ''), (string) ($a['school_year'] ?? ''));
if ($yearCompare !== 0) {
return $yearCompare;
}
return strcmp((string) ($b['reviewed_at'] ?? $b['created_at'] ?? ''), (string) ($a['reviewed_at'] ?? $a['created_at'] ?? ''));
});
}
unset($group);
uasort($groups, static fn (array $a, array $b): int => strnatcasecmp(
(string) ($a['class_section_name'] ?? ''),
(string) ($b['class_section_name'] ?? '')
));
return $groups;
}
/**
* @param list<array<string,mixed>> $rows
* @return array{0:list<array<string,mixed>>,1:list<array<string,mixed>>}
*/
private function partitionExamDraftsByYear(array $rows, string $schoolYear): array
{
$drafts = [];
$archived = [];
foreach ($rows as $row) {
$status = strtolower(trim((string) ($row['status'] ?? '')));
$isLegacy = $this->hasIsLegacyColumn && !empty($row['is_legacy']);
if ($isLegacy || $status === 'legacy') {
$archived[] = $row;
continue;
}
if ($schoolYear === '' || (string) ($row['school_year'] ?? '') === $schoolYear) {
$drafts[] = $row;
}
if ($status === 'accepted') {
$archived[] = $row;
}
}
return [$drafts, $archived];
}
/**
* Keep all manually archived records, plus only the newest accepted teacher
* version for each class/year/semester/exam line.
*
* @param list<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
private function latestFinalArchiveRows(array $rows): array
{
$legacy = [];
$latestAccepted = [];
foreach ($rows as $row) {
$status = strtolower(trim((string) ($row['status'] ?? '')));
$isLegacy = ($this->hasIsLegacyColumn && !empty($row['is_legacy'])) || $status === 'legacy';
if ($isLegacy) {
$legacy[] = $row;
continue;
}
if ($status !== 'accepted') {
continue;
}
$key = $this->examDraftLineKey($row);
$candidateRank = [
(int) ($row['version'] ?? 0),
(int) ($row['review_revision'] ?? 0),
(string) ($row['reviewed_at'] ?? $row['updated_at'] ?? ''),
(int) ($row['id'] ?? 0),
];
$currentRank = $latestAccepted[$key]['rank'] ?? null;
if ($currentRank === null || $candidateRank > $currentRank) {
$latestAccepted[$key] = ['rank' => $candidateRank, 'row' => $row];
}
}
return array_merge(
$legacy,
array_values(array_map(static fn (array $item): array => $item['row'], $latestAccepted))
);
}
private function notifyExamDraftEvent(?array $draft, string $event): void
{
if (empty($draft)) {
@@ -1441,20 +1624,168 @@ class ExamDraftController extends BaseController
}
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
$targetPath = $targetDir . '/' . $base . '.pdf';
$targetPath = $targetDir . '/' . $base . '.archive.pdf';
if (is_file($targetPath)) {
return basename($targetPath);
}
if ($this->convertWithStirlingPdf($sourcePath, $targetPath)) {
return basename($targetPath);
}
if (!function_exists('exec')) {
log_message('warning', 'ExamDraftController::convertDocToPdf skipped because exec() is unavailable.');
return is_file($targetPath) ? basename($targetPath) : null;
}
// Attempt conversion via LibreOffice if available
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
@\exec($cmd);
$officeBinary = $this->officeConverterBinary();
if ($officeBinary === null) {
log_message('warning', 'ExamDraftController::convertDocToPdf requires LibreOffice for formatting-safe conversion.');
return null;
}
$conversionToken = bin2hex(random_bytes(6));
$conversionDir = $targetDir . '/lo_' . $conversionToken;
$profileDir = $targetDir . '/lo_profile_' . $conversionToken;
if (!mkdir($conversionDir, 0755, true) && !is_dir($conversionDir)) {
return null;
}
if (!mkdir($profileDir, 0755, true) && !is_dir($profileDir)) {
@rmdir($conversionDir);
return null;
}
$cmd = escapeshellarg($officeBinary)
. ' ' . escapeshellarg('-env:UserInstallation=file://' . $profileDir)
. ' --headless --nologo --convert-to pdf --outdir '
. escapeshellarg($conversionDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
$commandOutput = [];
$exitCode = 1;
@\exec($cmd, $commandOutput, $exitCode);
$convertedPath = $conversionDir . '/' . $base . '.pdf';
if ($exitCode === 0 && is_file($convertedPath) && filesize($convertedPath) > 0) {
@rename($convertedPath, $targetPath);
}
$this->removeConversionDirectory($conversionDir, $targetDir);
$this->removeConversionDirectory($profileDir, $targetDir);
return is_file($targetPath) ? basename($targetPath) : null;
}
private function convertWithStirlingPdf(string $sourcePath, string $targetPath): bool
{
if ($this->stirlingPdfUnavailable || !function_exists('curl_init')) {
return false;
}
$baseUrl = rtrim((string) (env('STIRLING_PDF_URL') ?: ''), '/');
if ($baseUrl === '') {
$this->stirlingPdfUnavailable = true;
return false;
}
$parts = parse_url($baseUrl);
if (!is_array($parts)
|| !in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
|| empty($parts['host'])) {
$this->stirlingPdfUnavailable = true;
log_message('error', 'STIRLING_PDF_URL is invalid.');
return false;
}
$curl = curl_init($baseUrl . '/api/v1/convert/file/pdf');
if ($curl === false) {
$this->stirlingPdfUnavailable = true;
return false;
}
$headers = ['Accept: application/pdf'];
$apiKey = trim((string) (env('STIRLING_PDF_API_KEY') ?: ''));
if ($apiKey !== '') {
$headers[] = 'X-API-KEY: ' . $apiKey;
}
$mimeType = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)) === 'doc'
? 'application/msword'
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
curl_setopt_array($curl, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => [
'fileInput' => new \CURLFile(
$sourcePath,
$mimeType,
basename($sourcePath)
),
],
CURLOPT_HTTPHEADER => $headers,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 120,
]);
$body = curl_exec($curl);
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
$error = curl_error($curl);
curl_close($curl);
if (!is_string($body) || $status !== 200 || !str_starts_with($body, '%PDF') || strlen($body) < 100) {
$this->stirlingPdfUnavailable = true;
log_message('warning', 'Stirling-PDF conversion failed with HTTP {status}: {error}', [
'status' => $status,
'error' => $error,
]);
return false;
}
$temporaryPath = $targetPath . '.tmp-' . bin2hex(random_bytes(4));
if (file_put_contents($temporaryPath, $body, LOCK_EX) === false) {
return false;
}
if (!@rename($temporaryPath, $targetPath)) {
@unlink($temporaryPath);
return false;
}
return is_file($targetPath) && filesize($targetPath) > 0;
}
private function removeConversionDirectory(string $directory, string $allowedParent): void
{
$normalizedParent = rtrim(realpath($allowedParent) ?: $allowedParent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
$normalizedDirectory = realpath($directory);
if ($normalizedDirectory === false || !str_starts_with($normalizedDirectory . DIRECTORY_SEPARATOR, $normalizedParent)) {
return;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($normalizedDirectory, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($iterator as $item) {
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
}
@rmdir($normalizedDirectory);
}
private function officeConverterBinary(): ?string
{
$configured = trim((string) (env('EXAM_PDF_CONVERTER_BINARY') ?: env('exam.pdf_converter_binary') ?: ''));
$candidates = array_values(array_unique(array_filter([
$configured,
'/usr/bin/soffice',
'/usr/local/bin/soffice',
'/opt/homebrew/bin/soffice',
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
])));
foreach ($candidates as $candidate) {
if (is_file($candidate) && is_executable($candidate)) {
return $candidate;
}
}
return null;
}
private function schemaHasColumn(string $table, string $column): bool
{
try {
@@ -1477,28 +1808,134 @@ class ExamDraftController extends BaseController
$path = $this->fullUploadPath($subdir, $filename);
$base = pathinfo($path, PATHINFO_FILENAME);
$dir = pathinfo($path, PATHINFO_DIRNAME);
$pdfPath = $dir . '/' . $base . '.pdf';
$pdfPath = $dir . '/' . $base . '.archive.pdf';
return is_file($pdfPath) ? basename($pdfPath) : null;
}
private function ensurePdfExists(string $finalFilename, ?string $originalExt): ?string
{
$ext = strtolower((string)$originalExt);
if ($ext === 'pdf') {
return $this->isFormattingSafePdf($finalFilename) ? $finalFilename : null;
}
$pdfNeighbor = $this->neighborPdfIfExists($finalFilename, self::FINAL_UPLOAD_DIR);
if ($pdfNeighbor !== null) {
return $pdfNeighbor;
}
$ext = strtolower((string)$originalExt);
if ($ext === 'pdf') {
// final file itself is already pdf
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename);
return is_file($path) ? $finalFilename : null;
}
return $this->convertDocToPdf(
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename),
self::FINAL_UPLOAD_DIR
);
}
/** @param array<string,mixed> $row */
private function ensureArchivedPdf(array &$row, bool $allowConversion = true): void
{
$existingPdf = trim((string) ($row['final_pdf_file'] ?? ''));
if ($existingPdf !== '' && $this->isFormattingSafePdf($existingPdf)) {
return;
}
$updates = [];
$finalFile = trim((string) ($row['final_file'] ?? ''));
if ($existingPdf !== '' && ($sourceFile = $this->sourceDocumentBesidePdf($existingPdf)) !== null) {
$sourceExt = strtolower(pathinfo($sourceFile, PATHINFO_EXTENSION));
$displayBase = pathinfo((string) ($row['final_filename'] ?? $sourceFile), PATHINFO_FILENAME);
$finalFile = $sourceFile;
$row['final_file'] = $sourceFile;
$row['final_filename'] = $displayBase . '.' . $sourceExt;
$row['final_pdf_file'] = null;
$updates['final_file'] = $sourceFile;
$updates['final_filename'] = $row['final_filename'];
if ($this->hasFinalPdfColumn) {
$updates['final_pdf_file'] = null;
}
}
if (!$allowConversion) {
$id = (int) ($row['id'] ?? 0);
if ($id > 0 && $updates !== []) {
$this->examDraftModel->update($id, $updates);
}
return;
}
if ($finalFile === '') {
$teacherFile = $this->draftTeacherFile($row);
if (!empty($teacherFile)) {
$teacherExt = strtolower(pathinfo($teacherFile, PATHINFO_EXTENSION));
if (in_array($teacherExt, ['doc', 'docx'], true) && $this->officeConverterBinary() === null) {
return;
}
$finalFile = (string) ($this->copyDraftToFinal($teacherFile) ?? '');
if ($finalFile !== '') {
$updates['final_file'] = $finalFile;
$updates['final_filename'] = $this->draftTeacherFilename($row) ?? $teacherFile;
$row['final_file'] = $finalFile;
$row['final_filename'] = $updates['final_filename'];
}
}
}
if ($finalFile === '') {
return;
}
$pdfName = $this->ensurePdfExists($finalFile, pathinfo($finalFile, PATHINFO_EXTENSION));
if ($pdfName === null) {
$id = (int) ($row['id'] ?? 0);
if ($id > 0 && $updates !== []) {
$this->examDraftModel->update($id, $updates);
}
log_message('warning', 'Unable to create archived exam PDF for draft id {id}.', [
'id' => $id,
]);
return;
}
$row['final_pdf_file'] = $pdfName;
$pdfFilename = pathinfo((string) ($row['final_filename'] ?? $row['teacher_filename'] ?? $pdfName), PATHINFO_FILENAME) . '.pdf';
$row['final_file'] = $pdfName;
$row['final_filename'] = $pdfFilename;
$updates['final_file'] = $pdfName;
$updates['final_filename'] = $pdfFilename;
if ($this->hasFinalPdfColumn) {
$updates['final_pdf_file'] = $pdfName;
}
$id = (int) ($row['id'] ?? 0);
if ($id > 0 && $updates !== []) {
$this->examDraftModel->update($id, $updates);
}
}
private function isFormattingSafePdf(string $filename): bool
{
if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) !== 'pdf') {
return false;
}
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $filename);
if (!is_file($path)) {
return false;
}
if (str_ends_with(strtolower($filename), '.archive.pdf')) {
return true;
}
// A PDF sharing its generated storage name with a DOC/DOCX is output
// from the removed lossy converter. A directly uploaded PDF has no such
// neighboring source document and is safe to archive.
return $this->sourceDocumentBesidePdf($filename) === null;
}
private function sourceDocumentBesidePdf(string $filename): ?string
{
$base = pathinfo($filename, PATHINFO_FILENAME);
foreach (['docx', 'doc'] as $extension) {
$candidate = $base . '.' . $extension;
if (is_file($this->fullUploadPath(self::FINAL_UPLOAD_DIR, $candidate))) {
return $candidate;
}
}
return null;
}
private function normalizeExamType($value): string
{
return trim((string) $value);
@@ -1523,7 +1960,7 @@ class ExamDraftController extends BaseController
return $destName;
}
private function prepareLegacyPdfVersion(array &$update, array $draft): void
private function prepareLegacyPdfVersion(array &$update, array $draft): bool
{
$finalFile = $update['final_file'] ?? $draft['final_file'] ?? null;
if (empty($finalFile)) {
@@ -1538,12 +1975,12 @@ class ExamDraftController extends BaseController
}
}
if (empty($finalFile)) {
return;
return false;
}
$ext = strtolower(pathinfo($finalFile, PATHINFO_EXTENSION));
$pdfName = $this->ensurePdfExists($finalFile, $ext);
if ($pdfName === null) {
return;
return false;
}
$filename = $update['final_filename'] ?? $draft['final_filename'] ?? '';
$baseName = '';
@@ -1561,5 +1998,6 @@ class ExamDraftController extends BaseController
if ($this->hasFinalPdfColumn) {
$update['final_pdf_file'] = $pdfName;
}
return true;
}
}
+27 -8
View File
@@ -4,6 +4,7 @@ namespace App\Controllers\View;
use CodeIgniter\Controller;
use CodeIgniter\Exceptions\PageNotFoundException;
use Config\Database;
class FilesController extends Controller
{
@@ -287,11 +288,20 @@ class FilesController extends Controller
private function buildDraftDownloadName(string $filename, string $subdir): string
{
$db = Database::connect();
$column = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
$row = $db->table('exam_drafts ed')
$builder = $db->table('exam_drafts ed')
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left')
->where('ed.' . $column, $filename)
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left');
if ($subdir === 'finals') {
$builder->groupStart()
->where('ed.final_file', $filename);
if ($db->fieldExists('final_pdf_file', 'exam_drafts')) {
$builder->orWhere('ed.final_pdf_file', $filename);
}
$builder->groupEnd();
} else {
$builder->where('ed.' . $this->resolveExamDraftFileColumn($db), $filename);
}
$row = $builder
->limit(1)
->get()
->getRowArray();
@@ -363,12 +373,21 @@ class FilesController extends Controller
}
$db = Database::connect();
$fileColumn = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
$authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db);
$draft = $db->table('exam_drafts ed')
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id')
->where('ed.' . $fileColumn, $filename)
$builder = $db->table('exam_drafts ed')
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id');
if ($subdir === 'finals') {
$builder->groupStart()
->where('ed.final_file', $filename);
if ($db->fieldExists('final_pdf_file', 'exam_drafts')) {
$builder->orWhere('ed.final_pdf_file', $filename);
}
$builder->groupEnd();
} else {
$builder->where('ed.' . $this->resolveExamDraftFileColumn($db), $filename);
}
$draft = $builder
->limit(1)
->get()
->getRowArray();
+308
View File
@@ -0,0 +1,308 @@
<?php
namespace App\Libraries;
use DOMDocument;
use DOMElement;
use DOMNode;
use DOMXPath;
use RuntimeException;
use ZipArchive;
/**
* Extracts question/answer pairs from a Word document without executing macros
* or importing embedded content.
*/
class AssessmentDocxReader
{
/**
* @param array<int, array{id:mixed, text:mixed}> $questions
* @return array{answers: array<int, string>, missing: array<int, string>, block_count: int}
*/
public function read(string $path, array $questions): array
{
return $this->mapAnswers($this->extractBlocks($path), $questions);
}
/**
* @return list<string>
*/
public function extractBlocks(string $path): array
{
if (! is_file($path) || ! is_readable($path)) {
throw new RuntimeException("DOCX file is not readable: {$path}");
}
if (strtolower((string) pathinfo($path, PATHINFO_EXTENSION)) !== 'docx') {
throw new RuntimeException('The input file must have a .docx extension.');
}
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('The input is not a readable DOCX archive.');
}
try {
$xml = $zip->getFromName('word/document.xml');
} finally {
$zip->close();
}
if (! is_string($xml) || $xml === '') {
throw new RuntimeException('The DOCX does not contain word/document.xml.');
}
$previous = libxml_use_internal_errors(true);
try {
$document = new DOMDocument();
if (! $document->loadXML($xml, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) {
throw new RuntimeException('The DOCX document XML is invalid.');
}
} finally {
libxml_clear_errors();
libxml_use_internal_errors($previous);
}
$xpath = new DOMXPath($document);
$xpath->registerNamespace('w', 'http://schemas.openxmlformats.org/wordprocessingml/2006/main');
$body = $xpath->query('/w:document/w:body')->item(0);
if (! $body instanceof DOMElement) {
throw new RuntimeException('The DOCX has no document body.');
}
$blocks = [];
foreach ($body->childNodes as $node) {
if (! $node instanceof DOMElement) {
continue;
}
if ($node->localName === 'p') {
$this->appendBlock($blocks, $this->nodeText($xpath, $node));
continue;
}
if ($node->localName !== 'tbl') {
continue;
}
foreach ($xpath->query('.//w:tr/w:tc', $node) as $cell) {
$paragraphs = [];
foreach ($xpath->query('./w:p', $cell) as $paragraph) {
$text = trim($this->nodeText($xpath, $paragraph));
if ($text !== '') {
$paragraphs[] = $text;
}
}
$this->appendBlock($blocks, implode("\n", $paragraphs));
}
}
return $blocks;
}
/**
* @param list<string> $blocks
* @param array<int, array{id:mixed, text:mixed}> $questions
* @return array{answers: array<int, string>, missing: array<int, string>, block_count: int}
*/
public function mapAnswers(array $blocks, array $questions): array
{
$indexedQuestions = [];
foreach ($questions as $question) {
$id = (int) ($question['id'] ?? 0);
$text = trim((string) ($question['text'] ?? ''));
if ($id > 0 && $text !== '') {
$indexedQuestions[$id] = [
'text' => $text,
'tokens' => $this->tokens($text),
];
}
}
$answerParts = [];
$currentQuestionId = null;
foreach ($blocks as $block) {
$block = $this->cleanText($block);
if ($block === '') {
continue;
}
if ($currentQuestionId !== null && $this->isTerminalHeading($block)) {
break;
}
$match = $this->questionAtStart($block, $indexedQuestions);
if ($match !== null) {
$currentQuestionId = $match['id'];
$answerParts[$currentQuestionId] ??= [];
if ($match['answer'] !== '') {
$answerParts[$currentQuestionId][] = $match['answer'];
}
continue;
}
if ($currentQuestionId !== null) {
$answerParts[$currentQuestionId][] = $block;
}
}
$answers = [];
$missing = [];
foreach ($indexedQuestions as $id => $question) {
$answer = trim(implode("\n", $answerParts[$id] ?? []));
if ($answer === '') {
$missing[$id] = $question['text'];
} else {
$answers[$id] = $answer;
}
}
return ['answers' => $answers, 'missing' => $missing, 'block_count' => count($blocks)];
}
private function nodeText(DOMXPath $xpath, DOMNode $node): string
{
$text = '';
foreach ($xpath->query('.//w:t | .//w:tab | .//w:br | .//w:cr', $node) as $part) {
$text .= match ($part->localName) {
'tab' => "\t",
'br', 'cr' => "\n",
default => $part->textContent,
};
}
return $text;
}
/** @param list<string> $blocks */
private function appendBlock(array &$blocks, string $text): void
{
$text = $this->cleanText($text);
if ($text !== '') {
$blocks[] = $text;
}
}
private function cleanText(string $text): string
{
$text = str_replace(["\u{00A0}", "\r\n", "\r"], [' ', "\n", "\n"], $text);
$lines = preg_split('/\n/u', $text) ?: [];
$lines = array_map(static fn (string $line): string => trim((string) preg_replace('/[\t ]+/u', ' ', $line)), $lines);
return trim(implode("\n", array_filter($lines, static fn (string $line): bool => $line !== '')));
}
/**
* @param array<int, array{text:string, tokens:list<string>}> $questions
* @return array{id:int, answer:string}|null
*/
private function questionAtStart(string $block, array $questions): ?array
{
preg_match_all('/[\p{L}\p{N}]+(?:[\'\x{2019}][\p{L}\p{N}]+)*/u', $block, $matches, PREG_OFFSET_CAPTURE);
$blockTokens = $matches[0] ?? [];
if ($blockTokens === []) {
return null;
}
$start = 0;
if (isset($blockTokens[0]) && strtolower($blockTokens[0][0]) === 'question') {
$start = 1;
}
if (isset($blockTokens[$start]) && preg_match('/^\d+$/', $blockTokens[$start][0]) === 1) {
$start++;
}
$best = null;
foreach ($questions as $id => $question) {
$questionTokens = $question['tokens'];
if ($questionTokens === []) {
continue;
}
$lastMatchedIndex = $this->questionPrefixEnd($blockTokens, $start, $questionTokens);
if ($lastMatchedIndex === null) {
continue;
}
$lastToken = $blockTokens[$lastMatchedIndex];
$answerOffset = $lastToken[1] + strlen($lastToken[0]);
$answer = preg_replace('/^[\s?!.,:;\-\x{2013}\x{2014}\)\]]+/u', '', substr($block, $answerOffset));
$candidate = ['id' => (int) $id, 'answer' => trim((string) $answer), 'length' => count($questionTokens)];
if ($best === null || $candidate['length'] > $best['length']) {
$best = $candidate;
}
}
if ($best === null) {
return null;
}
unset($best['length']);
return $best;
}
/**
* Allows one accidentally omitted word in shorter questions and two in
* long questions while still requiring at least a 90% token match.
*
* @param array<int, array{0:string, 1:int}> $blockTokens
* @param list<string> $questionTokens
*/
private function questionPrefixEnd(array $blockTokens, int $start, array $questionTokens): ?int
{
if (! isset($blockTokens[$start]) || $this->normalizeToken($blockTokens[$start][0]) !== $questionTokens[0]) {
return null;
}
$blockIndex = $start;
$matched = 0;
$omitted = 0;
$lastMatchedIndex = null;
$allowedOmissions = count($questionTokens) >= 16 ? 2 : 1;
foreach ($questionTokens as $expected) {
if (isset($blockTokens[$blockIndex]) && $this->normalizeToken($blockTokens[$blockIndex][0]) === $expected) {
$lastMatchedIndex = $blockIndex;
$blockIndex++;
$matched++;
continue;
}
$omitted++;
if ($omitted > $allowedOmissions) {
return null;
}
}
if ($lastMatchedIndex === null || $matched < 6 || ($matched / count($questionTokens)) < 0.9) {
return null;
}
return $lastMatchedIndex;
}
private function isTerminalHeading(string $block): bool
{
$normalized = implode(' ', $this->tokens($block));
return in_array($normalized, [
'principal notes',
'interviewer notes',
'assessment notes',
'education committee notes',
'final admission decision',
], true);
}
/** @return list<string> */
private function tokens(string $text): array
{
preg_match_all('/[\p{L}\p{N}]+(?:[\'\x{2019}][\p{L}\p{N}]+)*/u', $text, $matches);
return array_map(fn (string $token): string => $this->normalizeToken($token), $matches[0] ?? []);
}
private function normalizeToken(string $token): string
{
return mb_strtolower(str_replace("\u{2019}", "'", $token));
}
}
+34 -11
View File
@@ -24,6 +24,7 @@ $renderBadge = static function (string $status, array $badges): string {
};
$fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensions));
$legacyFileAccept = $fileAccept;
?>
<div class="container-fluid px-4 py-4">
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
@@ -48,12 +49,12 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<ul class="nav nav-pills mb-3" id="examDraftTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="submissions-tab" data-bs-toggle="pill" data-bs-target="#submissions" type="button" role="tab" aria-controls="submissions" aria-selected="true">
Submissions
<?= esc($schoolYear ?: 'Current year') ?> submissions
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="legacy-tab" data-bs-toggle="pill" data-bs-target="#legacy" type="button" role="tab" aria-controls="legacy" aria-selected="false">
Legacy exams
Final &amp; legacy exams
</button>
</li>
</ul>
@@ -62,6 +63,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div class="tab-pane fade show active" id="submissions" role="tabpanel" aria-labelledby="submissions-tab">
<div class="card mb-4">
<div class="card-body">
<p class="text-muted small mb-3">All teacher submissions for the selected school year are shown here, including accepted exams.</p>
<?php if (empty($visibleClasses)): ?>
<p class="text-muted mb-0">No classes with enrolled students are available for this term.</p>
<?php else: ?>
@@ -204,6 +206,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div class="mt-2">
<?php $fileInputId = 'review_file_' . (int) ($draft['id'] ?? 0); ?>
<input type="file" name="final_file" id="<?= esc($fileInputId) ?>" class="form-control form-control-sm" accept="<?= esc($fileAccept) ?>" form="<?= esc($formId) ?>">
<div class="form-text">Accepted DOC/DOCX files are converted to PDF with Stirling-PDF; uploaded PDFs are preserved as-is.</div>
<button type="submit" class="btn btn-sm btn-outline-primary mt-2" form="<?= esc($formId) ?>">Upload review file</button>
</div>
</td>
@@ -274,7 +277,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div>
<strong>Upload old / legacy exam</strong>
<div class="small text-muted">Store historic exams as accepted records.</div>
<div class="small text-muted">Store historic exams in the grade-based final archive.</div>
</div>
<span class="badge bg-primary-subtle text-primary">Admin only</span>
</div>
@@ -309,8 +312,8 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
</div>
<div class="col-12">
<label class="form-label small">Upload file</label>
<input type="file" name="old_exam_file" class="form-control" accept="<?= esc($fileAccept) ?>" required>
<div class="form-text">Allowed: <?= esc(implode(', ', $allowedExtensions)) ?> • Max <?= number_format($maxUploadBytes / 1024 / 1024, 0) ?> MB</div>
<input type="file" name="old_exam_file" class="form-control" accept="<?= esc($legacyFileAccept) ?>" required>
<div class="form-text">DOC/DOCX files are converted with Stirling-PDF and saved in the archive as PDF • Max <?= number_format($maxUploadBytes / 1024 / 1024, 0) ?> MB</div>
</div>
<div class="col-12">
<button type="submit" class="btn btn-primary">Upload legacy exam</button>
@@ -322,14 +325,20 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<div class="card mb-4">
<div class="card-body">
<?php if (empty($legacyByClass)): ?>
<p class="text-muted mb-0">No legacy exams uploaded yet.</p>
<p class="text-muted mb-0">No final or legacy exams are available yet.</p>
<?php else: ?>
<?php foreach ($legacyByClass as $group): ?>
<div class="mb-4">
<h6 class="mb-2"><?= esc($group['class_section_name'] ?? 'Class') ?></h6>
<div class="list-group">
<?php foreach ($group['items'] ?? [] as $item): ?>
<?php $lst = strtolower((string) ($item['status'] ?? '')); ?>
<?php
$lst = strtolower((string) ($item['status'] ?? ''));
$archivePdfFile = trim((string) ($item['final_pdf_file'] ?? ''));
if ($archivePdfFile === '' && strtolower(pathinfo((string) ($item['final_file'] ?? ''), PATHINFO_EXTENSION)) === 'pdf') {
$archivePdfFile = (string) $item['final_file'];
}
?>
<div class="list-group-item d-flex justify-content-between align-items-start flex-wrap gap-2">
<div class="me-3">
<div class="fw-semibold"><?= esc($item['draft_title'] ?? 'Legacy exam') ?></div>
@@ -337,14 +346,28 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<?= esc($item['exam_type'] ?? 'N/A') ?> •
<?= esc($item['semester'] ?? '') ?> <?= esc($item['school_year'] ?? '') ?>
</div>
<?php if (!empty($item['class_section_names'])): ?>
<div class="small text-muted">Sections: <?= esc(implode(', ', $item['class_section_names'])) ?></div>
<?php endif; ?>
<div class="small mt-1"><?= $renderBadge($lst, $statusBadges) ?></div>
</div>
<div class="text-end d-flex flex-wrap gap-2 justify-content-end">
<?php if (!empty($item['final_file'])): ?>
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" rel="noopener">View</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" rel="noopener" download>Download</a>
<?php if ($archivePdfFile !== ''): ?>
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $archivePdfFile) ?>" target="_blank" rel="noopener">View PDF</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('exam-drafts/files/final/' . $archivePdfFile) ?>" target="_blank" rel="noopener" download>Download PDF</a>
<?php else: ?>
<span class="text-muted small">File missing</span>
<div class="text-start">
<div class="text-danger small mb-2">A formatting-safe PDF is required.</div>
<?= form_open_multipart($reviewActionUrl, ['class' => 'd-flex flex-wrap gap-2 align-items-center']) ?>
<?= csrf_field() ?>
<input type="hidden" name="draft_id" value="<?= (int) ($item['id'] ?? 0) ?>">
<input type="hidden" name="review_status" value="accepted">
<input type="hidden" name="acceptance_type" value="<?= esc($item['acceptance_type'] ?? 'as_is') ?>">
<input type="file" name="final_file" class="form-control form-control-sm" accept=".pdf,application/pdf" required>
<button type="submit" class="btn btn-sm btn-primary">Upload replacement PDF</button>
<?= form_close() ?>
<div class="form-text">Upload a PDF exported from Word if automatic conversion is unavailable.</div>
</div>
<?php endif; ?>
</div>
</div>
@@ -0,0 +1,188 @@
<?php
namespace Tests\App\Controllers\View;
use App\Controllers\View\ExamDraftController;
use CodeIgniter\Test\CIUnitTestCase;
use ReflectionClass;
use ReflectionMethod;
final class ExamDraftControllerGroupingTest extends CIUnitTestCase
{
public function testLatestReviewedRevisionControlsStatusAndFinalFile(): void
{
$rows = [
$this->examRow([
'id' => 12,
'status' => 'accepted',
'review_revision' => 2,
'acceptance_type' => 'minor_edits',
'reviewed_at' => '2026-05-02 12:00:00',
'final_file' => 'final-v2.docx',
'final_filename' => 'Grade 3 Final.docx',
'final_pdf_file' => 'final-v2.pdf',
]),
$this->examRow([
'id' => 11,
'status' => 'under review',
'review_revision' => 1,
'reviewed_at' => '2026-05-01 12:00:00',
'final_file' => 'review-v1.docx',
]),
$this->examRow([
'id' => 10,
'status' => 'submitted',
'review_revision' => 0,
'teacher_file' => 'teacher-v1.docx',
]),
];
$grouped = $this->invoke('groupReviewRevisions', [$rows]);
$this->assertCount(1, $grouped);
$this->assertSame('accepted', $grouped[0]['status']);
$this->assertSame(2, $grouped[0]['review_revision']);
$this->assertSame('minor_edits', $grouped[0]['acceptance_type']);
$this->assertSame('final-v2.docx', $grouped[0]['final_file']);
$this->assertSame('final-v2.pdf', $grouped[0]['final_pdf_file']);
$this->assertCount(2, $grouped[0]['review_files']);
}
public function testGradeArchiveCombinesLegacyCopiesAcrossSections(): void
{
$rows = [
$this->examRow([
'id' => 1,
'class_id' => 3,
'class_name' => '3',
'class_section_id' => 30,
'class_section_name' => '3',
'school_year' => '2024-2025',
'status' => 'legacy',
'final_file' => 'grade-3-final.pdf',
]),
$this->examRow([
'id' => 2,
'class_id' => 3,
'class_name' => '3',
'class_section_id' => 31,
'class_section_name' => '3-A',
'school_year' => '2024-2025',
'status' => 'legacy',
'final_file' => 'grade-3-final.pdf',
]),
];
$groups = $this->invoke('groupArchivedExamsByGrade', [$rows]);
$this->assertArrayHasKey(3, $groups);
$this->assertSame('Grade 3', $groups[3]['class_section_name']);
$this->assertCount(1, $groups[3]['items']);
$this->assertSame(['3', '3-A'], $groups[3]['items'][0]['class_section_names']);
}
public function testSubmissionsAreScopedToYearWhileAcceptedRecordsAreAlsoArchived(): void
{
$rows = [
$this->examRow(['id' => 1, 'school_year' => '2025-2026', 'status' => 'submitted']),
$this->examRow(['id' => 2, 'school_year' => '2024-2025', 'status' => 'under review']),
$this->examRow(['id' => 3, 'school_year' => '2025-2026', 'status' => 'accepted']),
$this->examRow(['id' => 4, 'school_year' => '2024-2025', 'status' => 'legacy']),
];
[$drafts, $archived] = $this->invoke('partitionExamDraftsByYear', [$rows, '2025-2026']);
$this->assertSame([1, 3], array_column($drafts, 'id'));
$this->assertSame([3, 4], array_column($archived, 'id'));
}
public function testFinalArchiveKeepsOnlyLatestAcceptedTeacherVersion(): void
{
$rows = [
$this->examRow(['id' => 10, 'status' => 'accepted', 'version' => 1, 'review_revision' => 3]),
$this->examRow(['id' => 11, 'status' => 'accepted', 'version' => 2, 'review_revision' => 1]),
$this->examRow(['id' => 12, 'status' => 'legacy', 'version' => 1]),
];
$archive = $this->invoke('latestFinalArchiveRows', [$rows]);
$this->assertSame([12, 11], array_column($archive, 'id'));
}
public function testFinalArchiveLinksArePdfOnly(): void
{
$view = file_get_contents(ROOTPATH . 'app/Views/administrator/exam_drafts.php') ?: '';
$this->assertStringContainsString("$" . "archivePdfFile = trim((string) ($" . "item['final_pdf_file']", $view);
$this->assertStringContainsString('View PDF', $view);
$this->assertStringContainsString('Download PDF', $view);
$this->assertStringContainsString('Upload replacement PDF', $view);
$this->assertStringContainsString('automatic conversion is unavailable', $view);
}
public function testLossyPhpWordPdfFallbackIsNotUsedForFinalArchive(): void
{
$controller = file_get_contents(ROOTPATH . 'app/Controllers/View/ExamDraftController.php') ?: '';
$this->assertStringNotContainsString('PDF_RENDERER_DOMPDF', $controller);
$this->assertStringContainsString('EXAM_PDF_CONVERTER_BINARY', $controller);
$this->assertStringContainsString('STIRLING_PDF_URL', $controller);
$this->assertStringContainsString('/api/v1/convert/file/pdf', $controller);
$this->assertStringContainsString('-env:UserInstallation=file://', $controller);
$this->assertStringNotContainsString('Legacy exams must be uploaded as PDF', $controller);
}
public function testGeneratedPdfBesideDocxIsRejectedAsLossy(): void
{
$directory = WRITEPATH . 'uploads/exams/finals';
$base = 'conversion-safety-' . uniqid('', true);
$pdf = $directory . '/' . $base . '.pdf';
$docx = $directory . '/' . $base . '.docx';
$archivePdf = $directory . '/' . $base . '.archive.pdf';
file_put_contents($pdf, '%PDF unsafe');
file_put_contents($docx, 'source');
file_put_contents($archivePdf, '%PDF safe');
try {
$this->assertFalse($this->invoke('isFormattingSafePdf', [basename($pdf)]));
$this->assertTrue($this->invoke('isFormattingSafePdf', [basename($archivePdf)]));
unlink($docx);
$this->assertTrue($this->invoke('isFormattingSafePdf', [basename($pdf)]));
} finally {
foreach ([$pdf, $docx, $archivePdf] as $path) {
if (is_file($path)) {
unlink($path);
}
}
}
}
/** @return array<string,mixed> */
private function examRow(array $overrides): array
{
return array_merge([
'id' => 1,
'teacher_id' => 20,
'class_section_id' => 30,
'class_section_name' => '3',
'semester' => 'Spring',
'school_year' => '2025-2026',
'exam_type' => 'Final Exam',
'version' => 1,
'review_revision' => 0,
'status' => 'submitted',
'final_file' => null,
'final_filename' => null,
'final_pdf_file' => null,
], $overrides);
}
private function invoke(string $method, array $arguments): mixed
{
$controller = (new ReflectionClass(ExamDraftController::class))->newInstanceWithoutConstructor();
$reflection = new ReflectionMethod($controller, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($controller, $arguments);
}
}
@@ -0,0 +1,94 @@
<?php
namespace Tests\App\Libraries;
use App\Libraries\AssessmentDocxReader;
use CodeIgniter\Test\CIUnitTestCase;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\IOFactory;
final class AssessmentDocxReaderTest extends CIUnitTestCase
{
private const QUESTIONS = [
['id' => 11, 'text' => 'What is the students Islamic education background?'],
['id' => 12, 'text' => 'What is the students Arabic competency level?'],
['id' => 13, 'text' => 'Any other questions / concerns that the parents may have?'],
];
public function testMapsNumberedParagraphQuestionsAndMultilineAnswers(): void
{
$result = (new AssessmentDocxReader())->mapAnswers([
'Student intake form',
'1. What is the student\'s Islamic education background?',
'Two years at home.',
'Weekly mosque classes.',
'2) What is the students Arabic competency level? Beginner reader',
'3. Any other questions / concerns that the parents may have?',
'None.',
], self::QUESTIONS);
$this->assertSame("Two years at home.\nWeekly mosque classes.", $result['answers'][11]);
$this->assertSame('Beginner reader', $result['answers'][12]);
$this->assertSame('None.', $result['answers'][13]);
$this->assertSame([], $result['missing']);
}
public function testReadsParagraphsAndTableCellsFromDocx(): void
{
$phpWord = new PhpWord();
$section = $phpWord->addSection();
$section->addText(self::QUESTIONS[0]['text']);
$section->addText('Studied for one year.');
$table = $section->addTable();
$table->addRow();
$table->addCell()->addText(self::QUESTIONS[1]['text']);
$table->addCell()->addText('Conversational');
$path = tempnam(sys_get_temp_dir(), 'assessment-docx-');
$this->assertNotFalse($path);
$docxPath = $path . '.docx';
try {
IOFactory::createWriter($phpWord, 'Word2007')->save($docxPath);
$result = (new AssessmentDocxReader())->read($docxPath, array_slice(self::QUESTIONS, 0, 2));
} finally {
@unlink($path);
@unlink($docxPath);
}
$this->assertSame('Studied for one year.', $result['answers'][11]);
$this->assertSame('Conversational', $result['answers'][12]);
}
public function testReportsQuestionsWithoutAnswers(): void
{
$result = (new AssessmentDocxReader())->mapAnswers([
self::QUESTIONS[0]['text'],
'An answer.',
], self::QUESTIONS);
$this->assertSame([11 => 'An answer.'], $result['answers']);
$this->assertArrayHasKey(12, $result['missing']);
$this->assertArrayHasKey(13, $result['missing']);
}
public function testAllowsAnOmittedQuestionWordAndStopsAtAdministrativeNotes(): void
{
$questions = [
['id' => 21, 'text' => 'If surnames of enrolled students are different, please confirm if they are all siblings; if NOT, then please specify their relationship to each other:'],
self::QUESTIONS[2],
];
$result = (new AssessmentDocxReader())->mapAnswers([
'If surnames of enrolled students are different, please confirm if they are all siblings; if NOT, then please their relationship to each other:',
'N/A',
'Any other questions / concerns that the parents may have:',
'Question 1: Please focus on completing the curriculum.',
'Principal Notes:',
'This must not become part of the parent answer.',
], $questions);
$this->assertSame('N/A', $result['answers'][21]);
$this->assertSame('Question 1: Please focus on completing the curriculum.', $result['answers'][13]);
$this->assertStringNotContainsString('Principal Notes', $result['answers'][13]);
$this->assertSame([], $result['missing']);
}
}