fix assessment import and exams draft
This commit is contained in:
@@ -0,0 +1,218 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Libraries\AssessmentDocxReader;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
use Config\Database;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ImportAssessmentDocx extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Assessments';
|
||||||
|
protected $name = 'assessment:import-docx';
|
||||||
|
protected $description = 'Import new-student assessment answers from a DOCX file.';
|
||||||
|
protected $usage = 'php spark assessment:import-docx <file.docx> --student-id <id> --form-id <id> [--dry-run] [--overwrite] [--complete]';
|
||||||
|
protected $arguments = [
|
||||||
|
'file' => 'Path to the DOCX containing the seeded questions and their answers.',
|
||||||
|
];
|
||||||
|
protected $options = [
|
||||||
|
'--student-id' => 'Required database ID of the student.',
|
||||||
|
'--form-id' => 'Required assessment form ID.',
|
||||||
|
'--dry-run' => 'Parse and validate without changing the database.',
|
||||||
|
'--overwrite' => 'Allow replacement of existing non-empty answers.',
|
||||||
|
'--complete' => 'Mark the assessment completed; requires all form questions to have answers.',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$path = trim((string) ($params[0] ?? ''));
|
||||||
|
$studentId = $this->positiveOption('student-id');
|
||||||
|
$formId = $this->positiveOption('form-id');
|
||||||
|
$dryRun = CLI::getOption('dry-run') !== null;
|
||||||
|
$overwrite = CLI::getOption('overwrite') !== null;
|
||||||
|
$complete = CLI::getOption('complete') !== null;
|
||||||
|
|
||||||
|
if ($path === '' || $studentId === null || $formId === null) {
|
||||||
|
CLI::error('File, --student-id, and --form-id are required.');
|
||||||
|
CLI::write($this->usage);
|
||||||
|
return EXIT_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$realPath = realpath($path);
|
||||||
|
if ($realPath === false) {
|
||||||
|
throw new \RuntimeException("DOCX file does not exist: {$path}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$db = Database::connect();
|
||||||
|
$this->assertTables($db);
|
||||||
|
|
||||||
|
$student = $db->table('students')->select('id, school_id, firstname, lastname')->where('id', $studentId)->get()->getRowArray();
|
||||||
|
if ($student === null) {
|
||||||
|
throw new \RuntimeException("Student {$studentId} was not found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $db->table('assessment_forms f')
|
||||||
|
->select('f.id, f.name, f.pool_id, f.status, f.school_year, p.name AS pool_name')
|
||||||
|
->join('question_pools p', 'p.id = f.pool_id')
|
||||||
|
->where('f.id', $formId)->get()->getRowArray();
|
||||||
|
if ($form === null) {
|
||||||
|
throw new \RuntimeException("Assessment form {$formId} was not found.");
|
||||||
|
}
|
||||||
|
if ((string) $form['pool_name'] !== 'New Student Assessment') {
|
||||||
|
throw new \RuntimeException('The selected form does not belong to the New Student Assessment pool.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$questions = $db->table('assessment_form_questions fq')
|
||||||
|
->select('q.id, q.text, fq.order_index')
|
||||||
|
->join('assessment_questions q', 'q.id = fq.question_id')
|
||||||
|
->where('fq.form_id', $formId)
|
||||||
|
->orderBy('fq.order_index', 'ASC')->get()->getResultArray();
|
||||||
|
if ($questions === []) {
|
||||||
|
throw new \RuntimeException('The selected assessment form has no questions.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$parsed = (new AssessmentDocxReader())->read($realPath, $questions);
|
||||||
|
CLI::write(sprintf(
|
||||||
|
'Student: #%d %s %s (%s)',
|
||||||
|
$studentId,
|
||||||
|
(string) $student['firstname'],
|
||||||
|
(string) $student['lastname'],
|
||||||
|
(string) ($student['school_id'] ?? 'no school ID')
|
||||||
|
));
|
||||||
|
CLI::write(sprintf('Form: #%d %s [%s]', $formId, (string) $form['name'], (string) ($form['school_year'] ?? '')));
|
||||||
|
CLI::write(sprintf('Parsed %d document blocks; matched %d of %d questions.', $parsed['block_count'], count($parsed['answers']), count($questions)));
|
||||||
|
foreach ($questions as $question) {
|
||||||
|
$questionId = (int) $question['id'];
|
||||||
|
$length = mb_strlen($parsed['answers'][$questionId] ?? '');
|
||||||
|
CLI::write(sprintf(' [%s] question_id=%d answer_chars=%d', $length > 0 ? 'matched' : 'missing', $questionId, $length));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($parsed['answers'] === []) {
|
||||||
|
throw new \RuntimeException('No answers were matched. Check that the DOCX contains the exact seeded question text.');
|
||||||
|
}
|
||||||
|
if ($complete && $parsed['missing'] !== []) {
|
||||||
|
throw new \RuntimeException('--complete cannot be used while one or more form questions are missing answers.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$assessment = $db->table('student_assessments')->where(['student_id' => $studentId, 'form_id' => $formId])->get()->getRowArray();
|
||||||
|
$existingAnswers = [];
|
||||||
|
if ($assessment !== null) {
|
||||||
|
foreach ($db->table('student_answers')->where('student_assessment_id', (int) $assessment['id'])->get()->getResultArray() as $row) {
|
||||||
|
$existingAnswers[(int) $row['question_id']] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$conflicts = [];
|
||||||
|
foreach ($parsed['answers'] as $questionId => $answer) {
|
||||||
|
$old = trim((string) ($existingAnswers[$questionId]['answer_value'] ?? ''));
|
||||||
|
if ($old !== '' && $old !== $answer) {
|
||||||
|
$conflicts[] = $questionId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($conflicts !== [] && $dryRun) {
|
||||||
|
CLI::write('Dry run found ' . count($conflicts) . ' existing answer(s) that differ; an actual import will require --overwrite.', 'yellow');
|
||||||
|
} elseif ($conflicts !== [] && ! $overwrite) {
|
||||||
|
throw new \RuntimeException('Existing answers differ for question ID(s) ' . implode(', ', $conflicts) . '; rerun with --overwrite after reviewing the dry run.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
CLI::write('Dry run complete; no database rows were changed.', 'yellow');
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = date('Y-m-d H:i:s');
|
||||||
|
$db->transBegin();
|
||||||
|
try {
|
||||||
|
if ($assessment === null) {
|
||||||
|
$db->table('student_assessments')->insert([
|
||||||
|
'form_id' => $formId,
|
||||||
|
'student_id' => $studentId,
|
||||||
|
'status' => $complete ? 'completed' : 'in_progress',
|
||||||
|
'assigned_at' => $now,
|
||||||
|
'started_at' => $now,
|
||||||
|
'submitted_at' => $complete ? $now : null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
$assessmentId = (int) $db->insertID();
|
||||||
|
} else {
|
||||||
|
$assessmentId = (int) $assessment['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($parsed['answers'] as $questionId => $answer) {
|
||||||
|
$payload = ['answer_value' => $answer, 'updated_at' => $now];
|
||||||
|
if (isset($existingAnswers[$questionId])) {
|
||||||
|
if (trim((string) $existingAnswers[$questionId]['answer_value']) !== $answer) {
|
||||||
|
$db->table('student_answers')->where('id', (int) $existingAnswers[$questionId]['id'])->update($payload);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$db->table('student_answers')->insert($payload + [
|
||||||
|
'student_assessment_id' => $assessmentId,
|
||||||
|
'question_id' => $questionId,
|
||||||
|
'created_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($assessment !== null) {
|
||||||
|
$statusUpdate = ['updated_at' => $now];
|
||||||
|
if ($complete && (string) $assessment['status'] !== 'graded') {
|
||||||
|
$statusUpdate += ['status' => 'completed', 'submitted_at' => $now];
|
||||||
|
} elseif ((string) $assessment['status'] === 'not_started') {
|
||||||
|
$statusUpdate += ['status' => 'in_progress', 'started_at' => $now];
|
||||||
|
}
|
||||||
|
$db->table('student_assessments')->where('id', $assessmentId)->update($statusUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($db->transStatus() === false) {
|
||||||
|
throw new \RuntimeException('The database rejected one or more imported rows.');
|
||||||
|
}
|
||||||
|
$db->transCommit();
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$db->transRollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write(sprintf('Imported %d answer(s) into student assessment #%d.', count($parsed['answers']), $assessmentId), 'green');
|
||||||
|
if ($parsed['missing'] !== []) {
|
||||||
|
CLI::write(count($parsed['missing']) . ' unanswered question(s) were left unchanged.', 'yellow');
|
||||||
|
}
|
||||||
|
return EXIT_SUCCESS;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
CLI::error($e->getMessage());
|
||||||
|
return EXIT_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function positiveOption(string $name): ?int
|
||||||
|
{
|
||||||
|
$rawValue = CLI::getOption($name);
|
||||||
|
|
||||||
|
// CodeIgniter 4.7 does not split --option=value. Support that common
|
||||||
|
// spelling as well as its native --option value form.
|
||||||
|
if ($rawValue === null || $rawValue === true) {
|
||||||
|
$prefix = '--' . $name . '=';
|
||||||
|
foreach ($_SERVER['argv'] ?? [] as $argument) {
|
||||||
|
if (is_string($argument) && str_starts_with($argument, $prefix)) {
|
||||||
|
$rawValue = substr($argument, strlen($prefix));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$value = filter_var($rawValue, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
|
||||||
|
return $value === false ? null : (int) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertTables($db): void
|
||||||
|
{
|
||||||
|
foreach (['students', 'question_pools', 'assessment_questions', 'assessment_forms', 'assessment_form_questions', 'student_assessments', 'student_answers'] as $table) {
|
||||||
|
if (! $db->tableExists($table)) {
|
||||||
|
throw new \RuntimeException("Required table {$table} does not exist; run the migrations first.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ class ExamDraftController extends BaseController
|
|||||||
protected string $authorFilenameColumn = 'teacher_filename';
|
protected string $authorFilenameColumn = 'teacher_filename';
|
||||||
protected string $reviewerIdColumn = 'admin_id';
|
protected string $reviewerIdColumn = 'admin_id';
|
||||||
protected string $reviewerCommentColumn = 'reviewer_comment';
|
protected string $reviewerCommentColumn = 'reviewer_comment';
|
||||||
|
private bool $stirlingPdfUnavailable = false;
|
||||||
|
|
||||||
// DB status: submitted, accepted, review needed, rejected, canceled, under review, legacy
|
// DB status: submitted, accepted, review needed, rejected, canceled, under review, legacy
|
||||||
|
|
||||||
@@ -369,11 +370,14 @@ class ExamDraftController extends BaseController
|
|||||||
|
|
||||||
public function reviewIndex()
|
public function reviewIndex()
|
||||||
{
|
{
|
||||||
|
$this->syncAcademicContext();
|
||||||
|
|
||||||
if ($this->reviewerIdColumn !== '') {
|
if ($this->reviewerIdColumn !== '') {
|
||||||
$allDrafts = $this->examDraftModel
|
$allDrafts = $this->examDraftModel
|
||||||
->select($this->draftSelectColumns())
|
->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('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 u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
|
||||||
->join('users a', 'a.id = exam_drafts.' . $this->reviewerIdColumn, 'left')
|
->join('users a', 'a.id = exam_drafts.' . $this->reviewerIdColumn, 'left')
|
||||||
->orderBy('exam_drafts.created_at', 'DESC')
|
->orderBy('exam_drafts.created_at', 'DESC')
|
||||||
@@ -381,57 +385,36 @@ class ExamDraftController extends BaseController
|
|||||||
} else {
|
} else {
|
||||||
$allDrafts = $this->examDraftModel
|
$allDrafts = $this->examDraftModel
|
||||||
->select($this->draftSelectColumns())
|
->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('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 u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
|
||||||
->orderBy('exam_drafts.created_at', 'DESC')
|
->orderBy('exam_drafts.created_at', 'DESC')
|
||||||
->findAll();
|
->findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->hasReviewRevisionColumn) {
|
|
||||||
$allDrafts = $this->groupReviewRevisions($allDrafts);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($allDrafts as &$row) {
|
foreach ($allDrafts as &$row) {
|
||||||
if (empty($row['final_pdf_file'])) {
|
$status = strtolower(trim((string) ($row['status'] ?? '')));
|
||||||
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
|
if ($status === 'accepted' || $status === 'legacy' || !empty($row['is_legacy'])) {
|
||||||
if ($pdf !== null) {
|
$this->ensureArchivedPdf($row, false);
|
||||||
$row['final_pdf_file'] = $pdf;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unset($row);
|
unset($row);
|
||||||
|
|
||||||
|
if ($this->hasReviewRevisionColumn) {
|
||||||
|
$allDrafts = $this->groupReviewRevisions($allDrafts);
|
||||||
|
}
|
||||||
|
|
||||||
$classSections = $this->classSectionModel
|
$classSections = $this->classSectionModel
|
||||||
->select('class_section_id, class_section_name')
|
->select('class_section_id, class_section_name, class_id')
|
||||||
->orderBy('class_section_name', 'ASC')
|
->orderBy('class_section_name', 'ASC')
|
||||||
->findAll();
|
->findAll();
|
||||||
|
|
||||||
// Group legacy uploads (admin-uploaded accepted exams) by class_section for separate tab
|
// The submissions tab follows the selected school year. Accepted submissions
|
||||||
$legacyByClass = [];
|
// stay visible in their year and are also copied into the final grade archive.
|
||||||
if ($this->hasIsLegacyColumn) {
|
[$drafts, $archivedExams] = $this->partitionExamDraftsByYear($allDrafts, $this->schoolYear);
|
||||||
// Keep legacy items out of the main submissions list; show them in the legacy tab only.
|
|
||||||
$drafts = [];
|
$legacyByClass = $this->groupArchivedExamsByGrade($this->latestFinalArchiveRows($archivedExams));
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$legacyFlat = [];
|
$legacyFlat = [];
|
||||||
foreach ($legacyByClass as $group) {
|
foreach ($legacyByClass as $group) {
|
||||||
@@ -473,8 +456,6 @@ class ExamDraftController extends BaseController
|
|||||||
$classData['student_count'] = $count;
|
$classData['student_count'] = $count;
|
||||||
$visibleClasses[$csId] = $classData;
|
$visibleClasses[$csId] = $classData;
|
||||||
}
|
}
|
||||||
uasort($visibleClasses, static fn ($a, $b): int => strcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? ''));
|
|
||||||
|
|
||||||
$classDraftGroups = [];
|
$classDraftGroups = [];
|
||||||
foreach ($drafts as $draft) {
|
foreach ($drafts as $draft) {
|
||||||
$cid = (int) ($draft['class_section_id'] ?? 0);
|
$cid = (int) ($draft['class_section_id'] ?? 0);
|
||||||
@@ -483,6 +464,21 @@ class ExamDraftController extends BaseController
|
|||||||
}
|
}
|
||||||
$classDraftGroups[$cid][] = $draft;
|
$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 = [];
|
$newSubmissionClasses = [];
|
||||||
foreach ($classDraftGroups as $cid => $group) {
|
foreach ($classDraftGroups as $cid => $group) {
|
||||||
foreach ($group as $draft) {
|
foreach ($group as $draft) {
|
||||||
@@ -571,6 +567,10 @@ class ExamDraftController extends BaseController
|
|||||||
self::FINAL_UPLOAD_DIR
|
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 = [
|
$basePayload = [
|
||||||
$this->authorIdColumn => $adminId, // store under admin user since legacy uploads are admin-only
|
$this->authorIdColumn => $adminId, // store under admin user since legacy uploads are admin-only
|
||||||
'semester' => ucfirst(strtolower($semester)),
|
'semester' => ucfirst(strtolower($semester)),
|
||||||
@@ -578,8 +578,8 @@ class ExamDraftController extends BaseController
|
|||||||
'exam_type' => $examType,
|
'exam_type' => $examType,
|
||||||
'draft_title' => $examType,
|
'draft_title' => $examType,
|
||||||
'author_comment' => null,
|
'author_comment' => null,
|
||||||
'final_file' => $stored,
|
'final_file' => $pdfName,
|
||||||
'final_filename' => $file->getClientName(),
|
'final_filename' => $pdfFilename,
|
||||||
'status' => 'legacy',
|
'status' => 'legacy',
|
||||||
'reviewed_at' => utc_now(),
|
'reviewed_at' => utc_now(),
|
||||||
'version' => 1,
|
'version' => 1,
|
||||||
@@ -590,7 +590,7 @@ class ExamDraftController extends BaseController
|
|||||||
if ($this->hasIsLegacyColumn) {
|
if ($this->hasIsLegacyColumn) {
|
||||||
$basePayload['is_legacy'] = 1;
|
$basePayload['is_legacy'] = 1;
|
||||||
}
|
}
|
||||||
if ($pdfName !== null && $this->hasFinalPdfColumn) {
|
if ($this->hasFinalPdfColumn) {
|
||||||
$basePayload['final_pdf_file'] = $pdfName;
|
$basePayload['final_pdf_file'] = $pdfName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,6 +739,13 @@ class ExamDraftController extends BaseController
|
|||||||
$newRow['final_file'] = $finalFile;
|
$newRow['final_file'] = $finalFile;
|
||||||
$newRow['final_filename'] = $finalFilename;
|
$newRow['final_filename'] = $finalFilename;
|
||||||
$pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null);
|
$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) {
|
if ($pdfName !== null && $this->hasFinalPdfColumn) {
|
||||||
$newRow['final_pdf_file'] = $pdfName;
|
$newRow['final_pdf_file'] = $pdfName;
|
||||||
}
|
}
|
||||||
@@ -750,8 +757,8 @@ class ExamDraftController extends BaseController
|
|||||||
}
|
}
|
||||||
return redirect()->back()->with('error', 'Unable to save the review.');
|
return redirect()->back()->with('error', 'Unable to save the review.');
|
||||||
}
|
}
|
||||||
if ($status === 'legacy') {
|
if (in_array($status, ['accepted', 'legacy'], true) && !$this->prepareLegacyPdfVersion($update, $draft)) {
|
||||||
$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) {
|
if ($this->hasIsLegacyColumn) {
|
||||||
$update['is_legacy'] = strtolower($status) === 'legacy' ? 1 : 0;
|
$update['is_legacy'] = strtolower($status) === 'legacy' ? 1 : 0;
|
||||||
@@ -1284,41 +1291,47 @@ class ExamDraftController extends BaseController
|
|||||||
$reviewRev = (int) ($row['review_revision'] ?? 0);
|
$reviewRev = (int) ($row['review_revision'] ?? 0);
|
||||||
|
|
||||||
if ($reviewRev > 0) {
|
if ($reviewRev > 0) {
|
||||||
if (!isset($grouped[$key])) {
|
if (!isset($grouped[$key])) {
|
||||||
$grouped[$key] = $row;
|
$grouped[$key] = $row;
|
||||||
$grouped[$key]['_is_review_row'] = true;
|
$grouped[$key]['_is_review_row'] = true;
|
||||||
$grouped[$key]['review_files'] = [];
|
$grouped[$key]['review_files'] = [];
|
||||||
$grouped[$key]['final_version'] = null;
|
$grouped[$key]['final_version'] = null;
|
||||||
$grouped[$key]['_latest_review_comment'] = null;
|
$grouped[$key]['_latest_review_comment'] = null;
|
||||||
}
|
$grouped[$key]['_latest_review'] = 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,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
$grouped[$key]['review_files'][] = [
|
||||||
if (strtolower((string) ($row['status'] ?? '')) === 'accepted') {
|
'review_revision' => $reviewRev,
|
||||||
$currentFinal = $grouped[$key]['final_version'] ?? null;
|
'final_file' => $row['final_file'] ?? null,
|
||||||
if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) {
|
'final_filename' => $row['final_filename'] ?? null,
|
||||||
$grouped[$key]['final_version'] = [
|
'status' => $row['status'] ?? null,
|
||||||
'review_revision' => $reviewRev,
|
];
|
||||||
'final_file' => $row['final_file'] ?? null,
|
$reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null;
|
||||||
'final_filename' => $row['final_filename'] ?? 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])) {
|
if (!isset($grouped[$key])) {
|
||||||
$grouped[$key] = $row;
|
$grouped[$key] = $row;
|
||||||
@@ -1328,9 +1341,13 @@ class ExamDraftController extends BaseController
|
|||||||
|
|
||||||
if (!empty($grouped[$key]['_is_review_row'])) {
|
if (!empty($grouped[$key]['_is_review_row'])) {
|
||||||
$reviewFiles = $grouped[$key]['review_files'] ?? [];
|
$reviewFiles = $grouped[$key]['review_files'] ?? [];
|
||||||
|
$finalVersion = $grouped[$key]['final_version'] ?? null;
|
||||||
$latestReviewComment = $grouped[$key]['_latest_review_comment'] ?? null;
|
$latestReviewComment = $grouped[$key]['_latest_review_comment'] ?? null;
|
||||||
|
$latestReview = $grouped[$key]['_latest_review'] ?? null;
|
||||||
$grouped[$key] = $row;
|
$grouped[$key] = $row;
|
||||||
$grouped[$key]['review_files'] = $reviewFiles;
|
$grouped[$key]['review_files'] = $reviewFiles;
|
||||||
|
$grouped[$key]['final_version'] = $finalVersion;
|
||||||
|
$grouped[$key]['_latest_review'] = $latestReview;
|
||||||
if (!empty($latestReviewComment['comment'])) {
|
if (!empty($latestReviewComment['comment'])) {
|
||||||
$grouped[$key]['reviewer_comment'] = $latestReviewComment['comment'];
|
$grouped[$key]['reviewer_comment'] = $latestReviewComment['comment'];
|
||||||
}
|
}
|
||||||
@@ -1339,24 +1356,190 @@ class ExamDraftController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ($grouped as &$row) {
|
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'])) {
|
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));
|
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'])) {
|
if (!empty($row['final_version']) && is_array($row['final_version'])) {
|
||||||
$row['final_file'] = $row['final_version']['final_file'] ?? $row['final_file'] ?? null;
|
$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_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'])) {
|
if (empty($row['reviewer_comment']) && !empty($row['_latest_review_comment']['comment'])) {
|
||||||
$row['reviewer_comment'] = $row['_latest_review_comment']['comment'];
|
$row['reviewer_comment'] = $row['_latest_review_comment']['comment'];
|
||||||
}
|
}
|
||||||
unset($row['_is_review_row']);
|
unset($row['_is_review_row']);
|
||||||
unset($row['_latest_review_comment']);
|
unset($row['_latest_review_comment']);
|
||||||
|
unset($row['_latest_review']);
|
||||||
}
|
}
|
||||||
unset($row);
|
unset($row);
|
||||||
|
|
||||||
return array_values($grouped);
|
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
|
private function notifyExamDraftEvent(?array $draft, string $event): void
|
||||||
{
|
{
|
||||||
if (empty($draft)) {
|
if (empty($draft)) {
|
||||||
@@ -1441,20 +1624,168 @@ class ExamDraftController extends BaseController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
|
$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')) {
|
if (!function_exists('exec')) {
|
||||||
log_message('warning', 'ExamDraftController::convertDocToPdf skipped because exec() is unavailable.');
|
log_message('warning', 'ExamDraftController::convertDocToPdf skipped because exec() is unavailable.');
|
||||||
return is_file($targetPath) ? basename($targetPath) : null;
|
return is_file($targetPath) ? basename($targetPath) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt conversion via LibreOffice if available
|
$officeBinary = $this->officeConverterBinary();
|
||||||
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
|
if ($officeBinary === null) {
|
||||||
@\exec($cmd);
|
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;
|
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
|
private function schemaHasColumn(string $table, string $column): bool
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -1477,28 +1808,134 @@ class ExamDraftController extends BaseController
|
|||||||
$path = $this->fullUploadPath($subdir, $filename);
|
$path = $this->fullUploadPath($subdir, $filename);
|
||||||
$base = pathinfo($path, PATHINFO_FILENAME);
|
$base = pathinfo($path, PATHINFO_FILENAME);
|
||||||
$dir = pathinfo($path, PATHINFO_DIRNAME);
|
$dir = pathinfo($path, PATHINFO_DIRNAME);
|
||||||
$pdfPath = $dir . '/' . $base . '.pdf';
|
$pdfPath = $dir . '/' . $base . '.archive.pdf';
|
||||||
return is_file($pdfPath) ? basename($pdfPath) : null;
|
return is_file($pdfPath) ? basename($pdfPath) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function ensurePdfExists(string $finalFilename, ?string $originalExt): ?string
|
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);
|
$pdfNeighbor = $this->neighborPdfIfExists($finalFilename, self::FINAL_UPLOAD_DIR);
|
||||||
if ($pdfNeighbor !== null) {
|
if ($pdfNeighbor !== null) {
|
||||||
return $pdfNeighbor;
|
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(
|
return $this->convertDocToPdf(
|
||||||
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename),
|
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename),
|
||||||
self::FINAL_UPLOAD_DIR
|
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
|
private function normalizeExamType($value): string
|
||||||
{
|
{
|
||||||
return trim((string) $value);
|
return trim((string) $value);
|
||||||
@@ -1523,7 +1960,7 @@ class ExamDraftController extends BaseController
|
|||||||
return $destName;
|
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;
|
$finalFile = $update['final_file'] ?? $draft['final_file'] ?? null;
|
||||||
if (empty($finalFile)) {
|
if (empty($finalFile)) {
|
||||||
@@ -1538,12 +1975,12 @@ class ExamDraftController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (empty($finalFile)) {
|
if (empty($finalFile)) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
$ext = strtolower(pathinfo($finalFile, PATHINFO_EXTENSION));
|
$ext = strtolower(pathinfo($finalFile, PATHINFO_EXTENSION));
|
||||||
$pdfName = $this->ensurePdfExists($finalFile, $ext);
|
$pdfName = $this->ensurePdfExists($finalFile, $ext);
|
||||||
if ($pdfName === null) {
|
if ($pdfName === null) {
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
$filename = $update['final_filename'] ?? $draft['final_filename'] ?? '';
|
$filename = $update['final_filename'] ?? $draft['final_filename'] ?? '';
|
||||||
$baseName = '';
|
$baseName = '';
|
||||||
@@ -1561,5 +1998,6 @@ class ExamDraftController extends BaseController
|
|||||||
if ($this->hasFinalPdfColumn) {
|
if ($this->hasFinalPdfColumn) {
|
||||||
$update['final_pdf_file'] = $pdfName;
|
$update['final_pdf_file'] = $pdfName;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Controllers\View;
|
|||||||
|
|
||||||
use CodeIgniter\Controller;
|
use CodeIgniter\Controller;
|
||||||
use CodeIgniter\Exceptions\PageNotFoundException;
|
use CodeIgniter\Exceptions\PageNotFoundException;
|
||||||
|
use Config\Database;
|
||||||
|
|
||||||
class FilesController extends Controller
|
class FilesController extends Controller
|
||||||
{
|
{
|
||||||
@@ -287,11 +288,20 @@ class FilesController extends Controller
|
|||||||
private function buildDraftDownloadName(string $filename, string $subdir): string
|
private function buildDraftDownloadName(string $filename, string $subdir): string
|
||||||
{
|
{
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$column = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
|
$builder = $db->table('exam_drafts ed')
|
||||||
$row = $db->table('exam_drafts ed')
|
|
||||||
->select('ed.version, ed.exam_type, ed.class_section_id, cs.class_section_name')
|
->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')
|
->join('classSection cs', 'cs.class_section_id = ed.class_section_id', 'left');
|
||||||
->where('ed.' . $column, $filename)
|
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)
|
->limit(1)
|
||||||
->get()
|
->get()
|
||||||
->getRowArray();
|
->getRowArray();
|
||||||
@@ -363,12 +373,21 @@ class FilesController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
$fileColumn = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db);
|
|
||||||
$authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db);
|
$authorIdColumn = $this->resolveExamDraftAuthorIdColumn($db);
|
||||||
|
|
||||||
$draft = $db->table('exam_drafts ed')
|
$builder = $db->table('exam_drafts ed')
|
||||||
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id')
|
->select('ed.class_section_id, ed.school_year, ed.semester, ed.status, ed.' . $authorIdColumn . ' AS draft_author_id');
|
||||||
->where('ed.' . $fileColumn, $filename)
|
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)
|
->limit(1)
|
||||||
->get()
|
->get()
|
||||||
->getRowArray();
|
->getRowArray();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ $renderBadge = static function (string $status, array $badges): string {
|
|||||||
};
|
};
|
||||||
|
|
||||||
$fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensions));
|
$fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensions));
|
||||||
|
$legacyFileAccept = $fileAccept;
|
||||||
?>
|
?>
|
||||||
<div class="container-fluid px-4 py-4">
|
<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">
|
<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">
|
<ul class="nav nav-pills mb-3" id="examDraftTabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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 & legacy exams
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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="tab-pane fade show active" id="submissions" role="tabpanel" aria-labelledby="submissions-tab">
|
||||||
<div class="card mb-4">
|
<div class="card mb-4">
|
||||||
<div class="card-body">
|
<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)): ?>
|
<?php if (empty($visibleClasses)): ?>
|
||||||
<p class="text-muted mb-0">No classes with enrolled students are available for this term.</p>
|
<p class="text-muted mb-0">No classes with enrolled students are available for this term.</p>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
@@ -204,6 +206,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
|
|||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<?php $fileInputId = 'review_file_' . (int) ($draft['id'] ?? 0); ?>
|
<?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) ?>">
|
<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>
|
<button type="submit" class="btn btn-sm btn-outline-primary mt-2" form="<?= esc($formId) ?>">Upload review file</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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 class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
<div>
|
<div>
|
||||||
<strong>Upload old / legacy exam</strong>
|
<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>
|
</div>
|
||||||
<span class="badge bg-primary-subtle text-primary">Admin only</span>
|
<span class="badge bg-primary-subtle text-primary">Admin only</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,8 +312,8 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label small">Upload file</label>
|
<label class="form-label small">Upload file</label>
|
||||||
<input type="file" name="old_exam_file" class="form-control" accept="<?= esc($fileAccept) ?>" required>
|
<input type="file" name="old_exam_file" class="form-control" accept="<?= esc($legacyFileAccept) ?>" required>
|
||||||
<div class="form-text">Allowed: <?= esc(implode(', ', $allowedExtensions)) ?> • Max <?= number_format($maxUploadBytes / 1024 / 1024, 0) ?> MB</div>
|
<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>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<button type="submit" class="btn btn-primary">Upload legacy exam</button>
|
<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 mb-4">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<?php if (empty($legacyByClass)): ?>
|
<?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 else: ?>
|
||||||
<?php foreach ($legacyByClass as $group): ?>
|
<?php foreach ($legacyByClass as $group): ?>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<h6 class="mb-2"><?= esc($group['class_section_name'] ?? 'Class') ?></h6>
|
<h6 class="mb-2"><?= esc($group['class_section_name'] ?? 'Class') ?></h6>
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
<?php foreach ($group['items'] ?? [] as $item): ?>
|
<?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="list-group-item d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||||
<div class="me-3">
|
<div class="me-3">
|
||||||
<div class="fw-semibold"><?= esc($item['draft_title'] ?? 'Legacy exam') ?></div>
|
<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['exam_type'] ?? 'N/A') ?> •
|
||||||
<?= esc($item['semester'] ?? '') ?> <?= esc($item['school_year'] ?? '') ?>
|
<?= esc($item['semester'] ?? '') ?> <?= esc($item['school_year'] ?? '') ?>
|
||||||
</div>
|
</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 class="small mt-1"><?= $renderBadge($lst, $statusBadges) ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end d-flex flex-wrap gap-2 justify-content-end">
|
<div class="text-end d-flex flex-wrap gap-2 justify-content-end">
|
||||||
<?php if (!empty($item['final_file'])): ?>
|
<?php if ($archivePdfFile !== ''): ?>
|
||||||
<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-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/' . $item['final_file']) ?>" target="_blank" rel="noopener" download>Download</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: ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</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 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user