From ce504c933e6b2920d93de59ed1e4dade7018608d Mon Sep 17 00:00:00 2001 From: root Date: Sat, 12 Sep 2026 02:39:47 -0400 Subject: [PATCH] fix assessment import and exams draft --- app/Commands/ImportAssessmentDocx.php | 218 ++++++ app/Controllers/View/ExamDraftController.php | 618 +++++++++++++++--- app/Controllers/View/FilesController.php | 35 +- app/Libraries/AssessmentDocxReader.php | 308 +++++++++ app/Views/administrator/exam_drafts.php | 45 +- .../View/ExamDraftControllerGroupingTest.php | 188 ++++++ .../Libraries/AssessmentDocxReaderTest.php | 94 +++ 7 files changed, 1397 insertions(+), 109 deletions(-) create mode 100644 app/Commands/ImportAssessmentDocx.php create mode 100644 app/Libraries/AssessmentDocxReader.php create mode 100644 tests/app/Controllers/View/ExamDraftControllerGroupingTest.php create mode 100644 tests/app/Libraries/AssessmentDocxReaderTest.php diff --git a/app/Commands/ImportAssessmentDocx.php b/app/Commands/ImportAssessmentDocx.php new file mode 100644 index 0000000..69b9ece --- /dev/null +++ b/app/Commands/ImportAssessmentDocx.php @@ -0,0 +1,218 @@ + --student-id --form-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."); + } + } + } +} diff --git a/app/Controllers/View/ExamDraftController.php b/app/Controllers/View/ExamDraftController.php index 827b285..1ea14df 100644 --- a/app/Controllers/View/ExamDraftController.php +++ b/app/Controllers/View/ExamDraftController.php @@ -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> $rows + * @return array> + */ + 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> $rows + * @return array{0:list>,1:list>} + */ + 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> $rows + * @return list> + */ + 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 $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; } } diff --git a/app/Controllers/View/FilesController.php b/app/Controllers/View/FilesController.php index 7d47218..4d6f8df 100644 --- a/app/Controllers/View/FilesController.php +++ b/app/Controllers/View/FilesController.php @@ -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(); diff --git a/app/Libraries/AssessmentDocxReader.php b/app/Libraries/AssessmentDocxReader.php new file mode 100644 index 0000000..aa33837 --- /dev/null +++ b/app/Libraries/AssessmentDocxReader.php @@ -0,0 +1,308 @@ + $questions + * @return array{answers: array, missing: array, block_count: int} + */ + public function read(string $path, array $questions): array + { + return $this->mapAnswers($this->extractBlocks($path), $questions); + } + + /** + * @return list + */ + 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 $blocks + * @param array $questions + * @return array{answers: array, missing: array, 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 $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}> $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 $blockTokens + * @param list $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 */ + 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)); + } +} diff --git a/app/Views/administrator/exam_drafts.php b/app/Views/administrator/exam_drafts.php index 402cc5e..4df89d1 100644 --- a/app/Views/administrator/exam_drafts.php +++ b/app/Views/administrator/exam_drafts.php @@ -24,6 +24,7 @@ $renderBadge = static function (string $status, array $badges): string { }; $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensions)); +$legacyFileAccept = $fileAccept; ?>
@@ -48,12 +49,12 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi @@ -62,6 +63,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
+

All teacher submissions for the selected school year are shown here, including accepted exams.

No classes with enrolled students are available for this term.

@@ -204,6 +206,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
+
Accepted DOC/DOCX files are converted to PDF with Stirling-PDF; uploaded PDFs are preserved as-is.
@@ -274,7 +277,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
Upload old / legacy exam -
Store historic exams as accepted records.
+
Store historic exams in the grade-based final archive.
Admin only
@@ -309,8 +312,8 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
- -
Allowed: • Max MB
+ +
DOC/DOCX files are converted with Stirling-PDF and saved in the archive as PDF • Max MB
@@ -322,14 +325,20 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
-

No legacy exams uploaded yet.

+

No final or legacy exams are available yet.

- +
@@ -337,14 +346,28 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
+ +
Sections:
+
- - View - Download + + View PDF + Download PDF - File missing +
+
A formatting-safe PDF is required.
+ 'd-flex flex-wrap gap-2 align-items-center']) ?> + + + + + + + +
Upload a PDF exported from Word if automatic conversion is unavailable.
+
diff --git a/tests/app/Controllers/View/ExamDraftControllerGroupingTest.php b/tests/app/Controllers/View/ExamDraftControllerGroupingTest.php new file mode 100644 index 0000000..38dcc11 --- /dev/null +++ b/tests/app/Controllers/View/ExamDraftControllerGroupingTest.php @@ -0,0 +1,188 @@ +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 */ + 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); + } +} diff --git a/tests/app/Libraries/AssessmentDocxReaderTest.php b/tests/app/Libraries/AssessmentDocxReaderTest.php new file mode 100644 index 0000000..1a6316d --- /dev/null +++ b/tests/app/Libraries/AssessmentDocxReaderTest.php @@ -0,0 +1,94 @@ + 11, 'text' => 'What is the student’s Islamic education background?'], + ['id' => 12, 'text' => 'What is the student’s 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 student’s 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']); + } +}