fix assessment import and exams draft
This commit is contained in:
@@ -36,6 +36,7 @@ class ExamDraftController extends BaseController
|
||||
protected string $authorFilenameColumn = 'teacher_filename';
|
||||
protected string $reviewerIdColumn = 'admin_id';
|
||||
protected string $reviewerCommentColumn = 'reviewer_comment';
|
||||
private bool $stirlingPdfUnavailable = false;
|
||||
|
||||
// DB status: submitted, accepted, review needed, rejected, canceled, under review, legacy
|
||||
|
||||
@@ -369,11 +370,14 @@ class ExamDraftController extends BaseController
|
||||
|
||||
public function reviewIndex()
|
||||
{
|
||||
$this->syncAcademicContext();
|
||||
|
||||
if ($this->reviewerIdColumn !== '') {
|
||||
$allDrafts = $this->examDraftModel
|
||||
->select($this->draftSelectColumns())
|
||||
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last')
|
||||
->select('cs.class_section_name, cs.class_id, c.class_name, u.firstname AS teacher_first, u.lastname AS teacher_last, a.firstname AS admin_first, a.lastname AS admin_last')
|
||||
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
|
||||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||||
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
|
||||
->join('users a', 'a.id = exam_drafts.' . $this->reviewerIdColumn, 'left')
|
||||
->orderBy('exam_drafts.created_at', 'DESC')
|
||||
@@ -381,57 +385,36 @@ class ExamDraftController extends BaseController
|
||||
} else {
|
||||
$allDrafts = $this->examDraftModel
|
||||
->select($this->draftSelectColumns())
|
||||
->select('cs.class_section_name, u.firstname AS teacher_first, u.lastname AS teacher_last, NULL AS admin_first, NULL AS admin_last', false)
|
||||
->select('cs.class_section_name, cs.class_id, c.class_name, u.firstname AS teacher_first, u.lastname AS teacher_last, NULL AS admin_first, NULL AS admin_last', false)
|
||||
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
|
||||
->join('classes c', 'c.id = cs.class_id', 'left')
|
||||
->join('users u', 'u.id = exam_drafts.' . $this->authorIdColumn, 'left')
|
||||
->orderBy('exam_drafts.created_at', 'DESC')
|
||||
->findAll();
|
||||
}
|
||||
|
||||
if ($this->hasReviewRevisionColumn) {
|
||||
$allDrafts = $this->groupReviewRevisions($allDrafts);
|
||||
}
|
||||
|
||||
foreach ($allDrafts as &$row) {
|
||||
if (empty($row['final_pdf_file'])) {
|
||||
$pdf = $this->ensurePdfExists($row['final_file'] ?? '', pathinfo($row['final_file'] ?? '', PATHINFO_EXTENSION));
|
||||
if ($pdf !== null) {
|
||||
$row['final_pdf_file'] = $pdf;
|
||||
}
|
||||
$status = strtolower(trim((string) ($row['status'] ?? '')));
|
||||
if ($status === 'accepted' || $status === 'legacy' || !empty($row['is_legacy'])) {
|
||||
$this->ensureArchivedPdf($row, false);
|
||||
}
|
||||
}
|
||||
unset($row);
|
||||
|
||||
if ($this->hasReviewRevisionColumn) {
|
||||
$allDrafts = $this->groupReviewRevisions($allDrafts);
|
||||
}
|
||||
|
||||
$classSections = $this->classSectionModel
|
||||
->select('class_section_id, class_section_name')
|
||||
->select('class_section_id, class_section_name, class_id')
|
||||
->orderBy('class_section_name', 'ASC')
|
||||
->findAll();
|
||||
|
||||
// Group legacy uploads (admin-uploaded accepted exams) by class_section for separate tab
|
||||
$legacyByClass = [];
|
||||
if ($this->hasIsLegacyColumn) {
|
||||
// Keep legacy items out of the main submissions list; show them in the legacy tab only.
|
||||
$drafts = [];
|
||||
foreach ($allDrafts as $d) {
|
||||
$isLegacy = !empty($d['is_legacy']);
|
||||
if ($isLegacy) {
|
||||
$cid = (int)($d['class_section_id'] ?? 0);
|
||||
if (!isset($legacyByClass[$cid])) {
|
||||
$legacyByClass[$cid] = [
|
||||
'class_section_id' => $cid,
|
||||
'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid,
|
||||
'items' => [],
|
||||
];
|
||||
}
|
||||
$legacyByClass[$cid]['items'][] = $d;
|
||||
continue;
|
||||
}
|
||||
$drafts[] = $d;
|
||||
}
|
||||
} else {
|
||||
// Column missing: keep behavior simple and avoid legacy tab
|
||||
$drafts = $allDrafts;
|
||||
}
|
||||
// The submissions tab follows the selected school year. Accepted submissions
|
||||
// stay visible in their year and are also copied into the final grade archive.
|
||||
[$drafts, $archivedExams] = $this->partitionExamDraftsByYear($allDrafts, $this->schoolYear);
|
||||
|
||||
$legacyByClass = $this->groupArchivedExamsByGrade($this->latestFinalArchiveRows($archivedExams));
|
||||
|
||||
$legacyFlat = [];
|
||||
foreach ($legacyByClass as $group) {
|
||||
@@ -473,8 +456,6 @@ class ExamDraftController extends BaseController
|
||||
$classData['student_count'] = $count;
|
||||
$visibleClasses[$csId] = $classData;
|
||||
}
|
||||
uasort($visibleClasses, static fn ($a, $b): int => strcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? ''));
|
||||
|
||||
$classDraftGroups = [];
|
||||
foreach ($drafts as $draft) {
|
||||
$cid = (int) ($draft['class_section_id'] ?? 0);
|
||||
@@ -483,6 +464,21 @@ class ExamDraftController extends BaseController
|
||||
}
|
||||
$classDraftGroups[$cid][] = $draft;
|
||||
}
|
||||
// Historical enrollment counts may be incomplete. Never hide a valid exam
|
||||
// submission merely because its old class has no count row for that year.
|
||||
foreach ($classDraftGroups as $cid => $group) {
|
||||
if (isset($visibleClasses[$cid])) {
|
||||
continue;
|
||||
}
|
||||
$classData = $classSectionsById[$cid] ?? [
|
||||
'class_section_id' => $cid,
|
||||
'class_section_name' => $group[0]['class_section_name'] ?? ('Class ' . $cid),
|
||||
];
|
||||
$classData['student_count'] = (int) ($studentCounts[$cid] ?? 0);
|
||||
$visibleClasses[$cid] = $classData;
|
||||
}
|
||||
uasort($visibleClasses, static fn ($a, $b): int => strcasecmp($a['class_section_name'] ?? '', $b['class_section_name'] ?? ''));
|
||||
|
||||
$newSubmissionClasses = [];
|
||||
foreach ($classDraftGroups as $cid => $group) {
|
||||
foreach ($group as $draft) {
|
||||
@@ -571,6 +567,10 @@ class ExamDraftController extends BaseController
|
||||
self::FINAL_UPLOAD_DIR
|
||||
);
|
||||
}
|
||||
if ($pdfName === null) {
|
||||
return redirect()->back()->withInput()->with('error', 'The legacy exam could not be converted to PDF. Nothing was archived.');
|
||||
}
|
||||
$pdfFilename = pathinfo($file->getClientName(), PATHINFO_FILENAME) . '.pdf';
|
||||
$basePayload = [
|
||||
$this->authorIdColumn => $adminId, // store under admin user since legacy uploads are admin-only
|
||||
'semester' => ucfirst(strtolower($semester)),
|
||||
@@ -578,8 +578,8 @@ class ExamDraftController extends BaseController
|
||||
'exam_type' => $examType,
|
||||
'draft_title' => $examType,
|
||||
'author_comment' => null,
|
||||
'final_file' => $stored,
|
||||
'final_filename' => $file->getClientName(),
|
||||
'final_file' => $pdfName,
|
||||
'final_filename' => $pdfFilename,
|
||||
'status' => 'legacy',
|
||||
'reviewed_at' => utc_now(),
|
||||
'version' => 1,
|
||||
@@ -590,7 +590,7 @@ class ExamDraftController extends BaseController
|
||||
if ($this->hasIsLegacyColumn) {
|
||||
$basePayload['is_legacy'] = 1;
|
||||
}
|
||||
if ($pdfName !== null && $this->hasFinalPdfColumn) {
|
||||
if ($this->hasFinalPdfColumn) {
|
||||
$basePayload['final_pdf_file'] = $pdfName;
|
||||
}
|
||||
|
||||
@@ -739,6 +739,13 @@ class ExamDraftController extends BaseController
|
||||
$newRow['final_file'] = $finalFile;
|
||||
$newRow['final_filename'] = $finalFilename;
|
||||
$pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null);
|
||||
if ($status === 'accepted' && $pdfName === null) {
|
||||
return redirect()->back()->withInput()->with('error', 'The accepted exam could not be converted to PDF. The review was not finalized.');
|
||||
}
|
||||
if ($status === 'accepted' && $pdfName !== null) {
|
||||
$newRow['final_file'] = $pdfName;
|
||||
$newRow['final_filename'] = pathinfo((string) $finalFilename, PATHINFO_FILENAME) . '.pdf';
|
||||
}
|
||||
if ($pdfName !== null && $this->hasFinalPdfColumn) {
|
||||
$newRow['final_pdf_file'] = $pdfName;
|
||||
}
|
||||
@@ -750,8 +757,8 @@ class ExamDraftController extends BaseController
|
||||
}
|
||||
return redirect()->back()->with('error', 'Unable to save the review.');
|
||||
}
|
||||
if ($status === 'legacy') {
|
||||
$this->prepareLegacyPdfVersion($update, $draft);
|
||||
if (in_array($status, ['accepted', 'legacy'], true) && !$this->prepareLegacyPdfVersion($update, $draft)) {
|
||||
return redirect()->back()->withInput()->with('error', 'The final exam could not be converted to PDF. The review was not finalized.');
|
||||
}
|
||||
if ($this->hasIsLegacyColumn) {
|
||||
$update['is_legacy'] = strtolower($status) === 'legacy' ? 1 : 0;
|
||||
@@ -1284,41 +1291,47 @@ class ExamDraftController extends BaseController
|
||||
$reviewRev = (int) ($row['review_revision'] ?? 0);
|
||||
|
||||
if ($reviewRev > 0) {
|
||||
if (!isset($grouped[$key])) {
|
||||
$grouped[$key] = $row;
|
||||
$grouped[$key]['_is_review_row'] = true;
|
||||
$grouped[$key]['review_files'] = [];
|
||||
$grouped[$key]['final_version'] = null;
|
||||
$grouped[$key]['_latest_review_comment'] = null;
|
||||
}
|
||||
$grouped[$key]['review_files'][] = [
|
||||
'review_revision' => $reviewRev,
|
||||
'final_file' => $row['final_file'] ?? null,
|
||||
'final_filename' => $row['final_filename'] ?? null,
|
||||
'status' => $row['status'] ?? null,
|
||||
];
|
||||
$reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null;
|
||||
if ($reviewComment !== null && $reviewComment !== '') {
|
||||
$latestComment = $grouped[$key]['_latest_review_comment'] ?? null;
|
||||
if ($latestComment === null || $reviewRev > (int) ($latestComment['review_revision'] ?? 0)) {
|
||||
$grouped[$key]['_latest_review_comment'] = [
|
||||
'review_revision' => $reviewRev,
|
||||
'comment' => $reviewComment,
|
||||
];
|
||||
if (!isset($grouped[$key])) {
|
||||
$grouped[$key] = $row;
|
||||
$grouped[$key]['_is_review_row'] = true;
|
||||
$grouped[$key]['review_files'] = [];
|
||||
$grouped[$key]['final_version'] = null;
|
||||
$grouped[$key]['_latest_review_comment'] = null;
|
||||
$grouped[$key]['_latest_review'] = null;
|
||||
}
|
||||
}
|
||||
if (strtolower((string) ($row['status'] ?? '')) === 'accepted') {
|
||||
$currentFinal = $grouped[$key]['final_version'] ?? null;
|
||||
if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) {
|
||||
$grouped[$key]['final_version'] = [
|
||||
'review_revision' => $reviewRev,
|
||||
'final_file' => $row['final_file'] ?? null,
|
||||
'final_filename' => $row['final_filename'] ?? null,
|
||||
];
|
||||
$grouped[$key]['review_files'][] = [
|
||||
'review_revision' => $reviewRev,
|
||||
'final_file' => $row['final_file'] ?? null,
|
||||
'final_filename' => $row['final_filename'] ?? null,
|
||||
'status' => $row['status'] ?? null,
|
||||
];
|
||||
$reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null;
|
||||
if ($reviewComment !== null && $reviewComment !== '') {
|
||||
$latestComment = $grouped[$key]['_latest_review_comment'] ?? null;
|
||||
if ($latestComment === null || $reviewRev > (int) ($latestComment['review_revision'] ?? 0)) {
|
||||
$grouped[$key]['_latest_review_comment'] = [
|
||||
'review_revision' => $reviewRev,
|
||||
'comment' => $reviewComment,
|
||||
];
|
||||
}
|
||||
}
|
||||
$latestReview = $grouped[$key]['_latest_review'] ?? null;
|
||||
if ($latestReview === null || $reviewRev > (int) ($latestReview['review_revision'] ?? 0)) {
|
||||
$grouped[$key]['_latest_review'] = $row;
|
||||
}
|
||||
if (strtolower((string) ($row['status'] ?? '')) === 'accepted') {
|
||||
$currentFinal = $grouped[$key]['final_version'] ?? null;
|
||||
if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) {
|
||||
$grouped[$key]['final_version'] = [
|
||||
'review_revision' => $reviewRev,
|
||||
'final_file' => $row['final_file'] ?? null,
|
||||
'final_filename' => $row['final_filename'] ?? null,
|
||||
'final_pdf_file' => $row['final_pdf_file'] ?? null,
|
||||
];
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($grouped[$key])) {
|
||||
$grouped[$key] = $row;
|
||||
@@ -1328,9 +1341,13 @@ class ExamDraftController extends BaseController
|
||||
|
||||
if (!empty($grouped[$key]['_is_review_row'])) {
|
||||
$reviewFiles = $grouped[$key]['review_files'] ?? [];
|
||||
$finalVersion = $grouped[$key]['final_version'] ?? null;
|
||||
$latestReviewComment = $grouped[$key]['_latest_review_comment'] ?? null;
|
||||
$latestReview = $grouped[$key]['_latest_review'] ?? null;
|
||||
$grouped[$key] = $row;
|
||||
$grouped[$key]['review_files'] = $reviewFiles;
|
||||
$grouped[$key]['final_version'] = $finalVersion;
|
||||
$grouped[$key]['_latest_review'] = $latestReview;
|
||||
if (!empty($latestReviewComment['comment'])) {
|
||||
$grouped[$key]['reviewer_comment'] = $latestReviewComment['comment'];
|
||||
}
|
||||
@@ -1339,24 +1356,190 @@ class ExamDraftController extends BaseController
|
||||
}
|
||||
|
||||
foreach ($grouped as &$row) {
|
||||
$latestReview = $row['_latest_review'] ?? null;
|
||||
if (is_array($latestReview)) {
|
||||
foreach ([
|
||||
'status',
|
||||
'acceptance_type',
|
||||
'reviewed_at',
|
||||
'reviewer_id',
|
||||
'admin_id',
|
||||
'admin_first',
|
||||
'admin_last',
|
||||
'review_revision',
|
||||
] as $field) {
|
||||
if (array_key_exists($field, $latestReview)) {
|
||||
$row[$field] = $latestReview[$field];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!empty($row['review_files']) && is_array($row['review_files'])) {
|
||||
usort($row['review_files'], static fn($a, $b) => ($a['review_revision'] ?? 0) <=> ($b['review_revision'] ?? 0));
|
||||
}
|
||||
if (!empty($row['final_version']) && is_array($row['final_version'])) {
|
||||
$row['final_file'] = $row['final_version']['final_file'] ?? $row['final_file'] ?? null;
|
||||
$row['final_filename'] = $row['final_version']['final_filename'] ?? $row['final_filename'] ?? null;
|
||||
$row['final_pdf_file'] = $row['final_version']['final_pdf_file'] ?? $row['final_pdf_file'] ?? null;
|
||||
}
|
||||
if (empty($row['reviewer_comment']) && !empty($row['_latest_review_comment']['comment'])) {
|
||||
$row['reviewer_comment'] = $row['_latest_review_comment']['comment'];
|
||||
}
|
||||
unset($row['_is_review_row']);
|
||||
unset($row['_latest_review_comment']);
|
||||
unset($row['_latest_review']);
|
||||
}
|
||||
unset($row);
|
||||
|
||||
return array_values($grouped);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function groupArchivedExamsByGrade(array $rows): array
|
||||
{
|
||||
$groups = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$gradeId = (int) ($row['class_id'] ?? 0);
|
||||
if ($gradeId <= 0) {
|
||||
$gradeId = (int) ($row['class_section_id'] ?? 0);
|
||||
}
|
||||
$gradeName = trim((string) ($row['class_name'] ?? ''));
|
||||
if ($gradeName === '') {
|
||||
$gradeName = preg_replace('/-[A-Z]$/i', '', (string) ($row['class_section_name'] ?? '')) ?: ('Class ' . $gradeId);
|
||||
}
|
||||
$normalizedGrade = strtolower($gradeName);
|
||||
$gradeLabel = in_array($normalizedGrade, ['kg', 'youth', 'arabic'], true)
|
||||
? $gradeName
|
||||
: 'Grade ' . $gradeName;
|
||||
|
||||
if (!isset($groups[$gradeId])) {
|
||||
$groups[$gradeId] = [
|
||||
'class_id' => $gradeId,
|
||||
'class_section_name' => $gradeLabel,
|
||||
'items' => [],
|
||||
'_item_indexes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$fileKey = (string) ($row['final_file'] ?? $row['teacher_file'] ?? '');
|
||||
if ($fileKey === '') {
|
||||
$fileKey = 'row:' . (int) ($row['id'] ?? 0);
|
||||
}
|
||||
$itemKey = implode('|', [
|
||||
$fileKey,
|
||||
(string) ($row['school_year'] ?? ''),
|
||||
strtolower((string) ($row['semester'] ?? '')),
|
||||
strtolower((string) ($row['exam_type'] ?? '')),
|
||||
(int) ($row['version'] ?? 0),
|
||||
]);
|
||||
$sectionName = trim((string) ($row['class_section_name'] ?? ''));
|
||||
|
||||
if (isset($groups[$gradeId]['_item_indexes'][$itemKey])) {
|
||||
$index = $groups[$gradeId]['_item_indexes'][$itemKey];
|
||||
if ($sectionName !== '' && !in_array($sectionName, $groups[$gradeId]['items'][$index]['class_section_names'], true)) {
|
||||
$groups[$gradeId]['items'][$index]['class_section_names'][] = $sectionName;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
$row['class_section_names'] = $sectionName !== '' ? [$sectionName] : [];
|
||||
$groups[$gradeId]['_item_indexes'][$itemKey] = count($groups[$gradeId]['items']);
|
||||
$groups[$gradeId]['items'][] = $row;
|
||||
}
|
||||
|
||||
foreach ($groups as &$group) {
|
||||
unset($group['_item_indexes']);
|
||||
usort($group['items'], static function (array $a, array $b): int {
|
||||
$yearCompare = strnatcasecmp((string) ($b['school_year'] ?? ''), (string) ($a['school_year'] ?? ''));
|
||||
if ($yearCompare !== 0) {
|
||||
return $yearCompare;
|
||||
}
|
||||
return strcmp((string) ($b['reviewed_at'] ?? $b['created_at'] ?? ''), (string) ($a['reviewed_at'] ?? $a['created_at'] ?? ''));
|
||||
});
|
||||
}
|
||||
unset($group);
|
||||
|
||||
uasort($groups, static fn (array $a, array $b): int => strnatcasecmp(
|
||||
(string) ($a['class_section_name'] ?? ''),
|
||||
(string) ($b['class_section_name'] ?? '')
|
||||
));
|
||||
|
||||
return $groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return array{0:list<array<string,mixed>>,1:list<array<string,mixed>>}
|
||||
*/
|
||||
private function partitionExamDraftsByYear(array $rows, string $schoolYear): array
|
||||
{
|
||||
$drafts = [];
|
||||
$archived = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$status = strtolower(trim((string) ($row['status'] ?? '')));
|
||||
$isLegacy = $this->hasIsLegacyColumn && !empty($row['is_legacy']);
|
||||
if ($isLegacy || $status === 'legacy') {
|
||||
$archived[] = $row;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($schoolYear === '' || (string) ($row['school_year'] ?? '') === $schoolYear) {
|
||||
$drafts[] = $row;
|
||||
}
|
||||
if ($status === 'accepted') {
|
||||
$archived[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
return [$drafts, $archived];
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep all manually archived records, plus only the newest accepted teacher
|
||||
* version for each class/year/semester/exam line.
|
||||
*
|
||||
* @param list<array<string,mixed>> $rows
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private function latestFinalArchiveRows(array $rows): array
|
||||
{
|
||||
$legacy = [];
|
||||
$latestAccepted = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$status = strtolower(trim((string) ($row['status'] ?? '')));
|
||||
$isLegacy = ($this->hasIsLegacyColumn && !empty($row['is_legacy'])) || $status === 'legacy';
|
||||
if ($isLegacy) {
|
||||
$legacy[] = $row;
|
||||
continue;
|
||||
}
|
||||
if ($status !== 'accepted') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $this->examDraftLineKey($row);
|
||||
$candidateRank = [
|
||||
(int) ($row['version'] ?? 0),
|
||||
(int) ($row['review_revision'] ?? 0),
|
||||
(string) ($row['reviewed_at'] ?? $row['updated_at'] ?? ''),
|
||||
(int) ($row['id'] ?? 0),
|
||||
];
|
||||
$currentRank = $latestAccepted[$key]['rank'] ?? null;
|
||||
if ($currentRank === null || $candidateRank > $currentRank) {
|
||||
$latestAccepted[$key] = ['rank' => $candidateRank, 'row' => $row];
|
||||
}
|
||||
}
|
||||
|
||||
return array_merge(
|
||||
$legacy,
|
||||
array_values(array_map(static fn (array $item): array => $item['row'], $latestAccepted))
|
||||
);
|
||||
}
|
||||
|
||||
private function notifyExamDraftEvent(?array $draft, string $event): void
|
||||
{
|
||||
if (empty($draft)) {
|
||||
@@ -1441,20 +1624,168 @@ class ExamDraftController extends BaseController
|
||||
}
|
||||
|
||||
$base = pathinfo($sourcePath, PATHINFO_FILENAME);
|
||||
$targetPath = $targetDir . '/' . $base . '.pdf';
|
||||
$targetPath = $targetDir . '/' . $base . '.archive.pdf';
|
||||
if (is_file($targetPath)) {
|
||||
return basename($targetPath);
|
||||
}
|
||||
|
||||
if ($this->convertWithStirlingPdf($sourcePath, $targetPath)) {
|
||||
return basename($targetPath);
|
||||
}
|
||||
|
||||
if (!function_exists('exec')) {
|
||||
log_message('warning', 'ExamDraftController::convertDocToPdf skipped because exec() is unavailable.');
|
||||
return is_file($targetPath) ? basename($targetPath) : null;
|
||||
}
|
||||
|
||||
// Attempt conversion via LibreOffice if available
|
||||
$cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
|
||||
@\exec($cmd);
|
||||
$officeBinary = $this->officeConverterBinary();
|
||||
if ($officeBinary === null) {
|
||||
log_message('warning', 'ExamDraftController::convertDocToPdf requires LibreOffice for formatting-safe conversion.');
|
||||
return null;
|
||||
}
|
||||
|
||||
$conversionToken = bin2hex(random_bytes(6));
|
||||
$conversionDir = $targetDir . '/lo_' . $conversionToken;
|
||||
$profileDir = $targetDir . '/lo_profile_' . $conversionToken;
|
||||
if (!mkdir($conversionDir, 0755, true) && !is_dir($conversionDir)) {
|
||||
return null;
|
||||
}
|
||||
if (!mkdir($profileDir, 0755, true) && !is_dir($profileDir)) {
|
||||
@rmdir($conversionDir);
|
||||
return null;
|
||||
}
|
||||
$cmd = escapeshellarg($officeBinary)
|
||||
. ' ' . escapeshellarg('-env:UserInstallation=file://' . $profileDir)
|
||||
. ' --headless --nologo --convert-to pdf --outdir '
|
||||
. escapeshellarg($conversionDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null';
|
||||
$commandOutput = [];
|
||||
$exitCode = 1;
|
||||
@\exec($cmd, $commandOutput, $exitCode);
|
||||
|
||||
$convertedPath = $conversionDir . '/' . $base . '.pdf';
|
||||
if ($exitCode === 0 && is_file($convertedPath) && filesize($convertedPath) > 0) {
|
||||
@rename($convertedPath, $targetPath);
|
||||
}
|
||||
$this->removeConversionDirectory($conversionDir, $targetDir);
|
||||
$this->removeConversionDirectory($profileDir, $targetDir);
|
||||
|
||||
return is_file($targetPath) ? basename($targetPath) : null;
|
||||
}
|
||||
|
||||
private function convertWithStirlingPdf(string $sourcePath, string $targetPath): bool
|
||||
{
|
||||
if ($this->stirlingPdfUnavailable || !function_exists('curl_init')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$baseUrl = rtrim((string) (env('STIRLING_PDF_URL') ?: ''), '/');
|
||||
if ($baseUrl === '') {
|
||||
$this->stirlingPdfUnavailable = true;
|
||||
return false;
|
||||
}
|
||||
$parts = parse_url($baseUrl);
|
||||
if (!is_array($parts)
|
||||
|| !in_array(strtolower((string) ($parts['scheme'] ?? '')), ['http', 'https'], true)
|
||||
|| empty($parts['host'])) {
|
||||
$this->stirlingPdfUnavailable = true;
|
||||
log_message('error', 'STIRLING_PDF_URL is invalid.');
|
||||
return false;
|
||||
}
|
||||
|
||||
$curl = curl_init($baseUrl . '/api/v1/convert/file/pdf');
|
||||
if ($curl === false) {
|
||||
$this->stirlingPdfUnavailable = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
$headers = ['Accept: application/pdf'];
|
||||
$apiKey = trim((string) (env('STIRLING_PDF_API_KEY') ?: ''));
|
||||
if ($apiKey !== '') {
|
||||
$headers[] = 'X-API-KEY: ' . $apiKey;
|
||||
}
|
||||
$mimeType = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)) === 'doc'
|
||||
? 'application/msword'
|
||||
: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => [
|
||||
'fileInput' => new \CURLFile(
|
||||
$sourcePath,
|
||||
$mimeType,
|
||||
basename($sourcePath)
|
||||
),
|
||||
],
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_CONNECTTIMEOUT => 3,
|
||||
CURLOPT_TIMEOUT => 120,
|
||||
]);
|
||||
|
||||
$body = curl_exec($curl);
|
||||
$status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$error = curl_error($curl);
|
||||
curl_close($curl);
|
||||
|
||||
if (!is_string($body) || $status !== 200 || !str_starts_with($body, '%PDF') || strlen($body) < 100) {
|
||||
$this->stirlingPdfUnavailable = true;
|
||||
log_message('warning', 'Stirling-PDF conversion failed with HTTP {status}: {error}', [
|
||||
'status' => $status,
|
||||
'error' => $error,
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
$temporaryPath = $targetPath . '.tmp-' . bin2hex(random_bytes(4));
|
||||
if (file_put_contents($temporaryPath, $body, LOCK_EX) === false) {
|
||||
return false;
|
||||
}
|
||||
if (!@rename($temporaryPath, $targetPath)) {
|
||||
@unlink($temporaryPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
return is_file($targetPath) && filesize($targetPath) > 0;
|
||||
}
|
||||
|
||||
private function removeConversionDirectory(string $directory, string $allowedParent): void
|
||||
{
|
||||
$normalizedParent = rtrim(realpath($allowedParent) ?: $allowedParent, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
|
||||
$normalizedDirectory = realpath($directory);
|
||||
if ($normalizedDirectory === false || !str_starts_with($normalizedDirectory . DIRECTORY_SEPARATOR, $normalizedParent)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($normalizedDirectory, \FilesystemIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($iterator as $item) {
|
||||
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
||||
}
|
||||
@rmdir($normalizedDirectory);
|
||||
}
|
||||
|
||||
private function officeConverterBinary(): ?string
|
||||
{
|
||||
$configured = trim((string) (env('EXAM_PDF_CONVERTER_BINARY') ?: env('exam.pdf_converter_binary') ?: ''));
|
||||
$candidates = array_values(array_unique(array_filter([
|
||||
$configured,
|
||||
'/usr/bin/soffice',
|
||||
'/usr/local/bin/soffice',
|
||||
'/opt/homebrew/bin/soffice',
|
||||
'/Applications/LibreOffice.app/Contents/MacOS/soffice',
|
||||
])));
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_file($candidate) && is_executable($candidate)) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function schemaHasColumn(string $table, string $column): bool
|
||||
{
|
||||
try {
|
||||
@@ -1477,28 +1808,134 @@ class ExamDraftController extends BaseController
|
||||
$path = $this->fullUploadPath($subdir, $filename);
|
||||
$base = pathinfo($path, PATHINFO_FILENAME);
|
||||
$dir = pathinfo($path, PATHINFO_DIRNAME);
|
||||
$pdfPath = $dir . '/' . $base . '.pdf';
|
||||
$pdfPath = $dir . '/' . $base . '.archive.pdf';
|
||||
return is_file($pdfPath) ? basename($pdfPath) : null;
|
||||
}
|
||||
|
||||
private function ensurePdfExists(string $finalFilename, ?string $originalExt): ?string
|
||||
{
|
||||
$ext = strtolower((string)$originalExt);
|
||||
if ($ext === 'pdf') {
|
||||
return $this->isFormattingSafePdf($finalFilename) ? $finalFilename : null;
|
||||
}
|
||||
$pdfNeighbor = $this->neighborPdfIfExists($finalFilename, self::FINAL_UPLOAD_DIR);
|
||||
if ($pdfNeighbor !== null) {
|
||||
return $pdfNeighbor;
|
||||
}
|
||||
$ext = strtolower((string)$originalExt);
|
||||
if ($ext === 'pdf') {
|
||||
// final file itself is already pdf
|
||||
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename);
|
||||
return is_file($path) ? $finalFilename : null;
|
||||
}
|
||||
return $this->convertDocToPdf(
|
||||
$this->fullUploadPath(self::FINAL_UPLOAD_DIR, $finalFilename),
|
||||
self::FINAL_UPLOAD_DIR
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $row */
|
||||
private function ensureArchivedPdf(array &$row, bool $allowConversion = true): void
|
||||
{
|
||||
$existingPdf = trim((string) ($row['final_pdf_file'] ?? ''));
|
||||
if ($existingPdf !== '' && $this->isFormattingSafePdf($existingPdf)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
$finalFile = trim((string) ($row['final_file'] ?? ''));
|
||||
if ($existingPdf !== '' && ($sourceFile = $this->sourceDocumentBesidePdf($existingPdf)) !== null) {
|
||||
$sourceExt = strtolower(pathinfo($sourceFile, PATHINFO_EXTENSION));
|
||||
$displayBase = pathinfo((string) ($row['final_filename'] ?? $sourceFile), PATHINFO_FILENAME);
|
||||
$finalFile = $sourceFile;
|
||||
$row['final_file'] = $sourceFile;
|
||||
$row['final_filename'] = $displayBase . '.' . $sourceExt;
|
||||
$row['final_pdf_file'] = null;
|
||||
$updates['final_file'] = $sourceFile;
|
||||
$updates['final_filename'] = $row['final_filename'];
|
||||
if ($this->hasFinalPdfColumn) {
|
||||
$updates['final_pdf_file'] = null;
|
||||
}
|
||||
}
|
||||
if (!$allowConversion) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id > 0 && $updates !== []) {
|
||||
$this->examDraftModel->update($id, $updates);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ($finalFile === '') {
|
||||
$teacherFile = $this->draftTeacherFile($row);
|
||||
if (!empty($teacherFile)) {
|
||||
$teacherExt = strtolower(pathinfo($teacherFile, PATHINFO_EXTENSION));
|
||||
if (in_array($teacherExt, ['doc', 'docx'], true) && $this->officeConverterBinary() === null) {
|
||||
return;
|
||||
}
|
||||
$finalFile = (string) ($this->copyDraftToFinal($teacherFile) ?? '');
|
||||
if ($finalFile !== '') {
|
||||
$updates['final_file'] = $finalFile;
|
||||
$updates['final_filename'] = $this->draftTeacherFilename($row) ?? $teacherFile;
|
||||
$row['final_file'] = $finalFile;
|
||||
$row['final_filename'] = $updates['final_filename'];
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($finalFile === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$pdfName = $this->ensurePdfExists($finalFile, pathinfo($finalFile, PATHINFO_EXTENSION));
|
||||
if ($pdfName === null) {
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id > 0 && $updates !== []) {
|
||||
$this->examDraftModel->update($id, $updates);
|
||||
}
|
||||
log_message('warning', 'Unable to create archived exam PDF for draft id {id}.', [
|
||||
'id' => $id,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$row['final_pdf_file'] = $pdfName;
|
||||
$pdfFilename = pathinfo((string) ($row['final_filename'] ?? $row['teacher_filename'] ?? $pdfName), PATHINFO_FILENAME) . '.pdf';
|
||||
$row['final_file'] = $pdfName;
|
||||
$row['final_filename'] = $pdfFilename;
|
||||
$updates['final_file'] = $pdfName;
|
||||
$updates['final_filename'] = $pdfFilename;
|
||||
if ($this->hasFinalPdfColumn) {
|
||||
$updates['final_pdf_file'] = $pdfName;
|
||||
}
|
||||
$id = (int) ($row['id'] ?? 0);
|
||||
if ($id > 0 && $updates !== []) {
|
||||
$this->examDraftModel->update($id, $updates);
|
||||
}
|
||||
}
|
||||
|
||||
private function isFormattingSafePdf(string $filename): bool
|
||||
{
|
||||
if (strtolower(pathinfo($filename, PATHINFO_EXTENSION)) !== 'pdf') {
|
||||
return false;
|
||||
}
|
||||
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $filename);
|
||||
if (!is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
if (str_ends_with(strtolower($filename), '.archive.pdf')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// A PDF sharing its generated storage name with a DOC/DOCX is output
|
||||
// from the removed lossy converter. A directly uploaded PDF has no such
|
||||
// neighboring source document and is safe to archive.
|
||||
return $this->sourceDocumentBesidePdf($filename) === null;
|
||||
}
|
||||
|
||||
private function sourceDocumentBesidePdf(string $filename): ?string
|
||||
{
|
||||
$base = pathinfo($filename, PATHINFO_FILENAME);
|
||||
foreach (['docx', 'doc'] as $extension) {
|
||||
$candidate = $base . '.' . $extension;
|
||||
if (is_file($this->fullUploadPath(self::FINAL_UPLOAD_DIR, $candidate))) {
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeExamType($value): string
|
||||
{
|
||||
return trim((string) $value);
|
||||
@@ -1523,7 +1960,7 @@ class ExamDraftController extends BaseController
|
||||
return $destName;
|
||||
}
|
||||
|
||||
private function prepareLegacyPdfVersion(array &$update, array $draft): void
|
||||
private function prepareLegacyPdfVersion(array &$update, array $draft): bool
|
||||
{
|
||||
$finalFile = $update['final_file'] ?? $draft['final_file'] ?? null;
|
||||
if (empty($finalFile)) {
|
||||
@@ -1538,12 +1975,12 @@ class ExamDraftController extends BaseController
|
||||
}
|
||||
}
|
||||
if (empty($finalFile)) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
$ext = strtolower(pathinfo($finalFile, PATHINFO_EXTENSION));
|
||||
$pdfName = $this->ensurePdfExists($finalFile, $ext);
|
||||
if ($pdfName === null) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
$filename = $update['final_filename'] ?? $draft['final_filename'] ?? '';
|
||||
$baseName = '';
|
||||
@@ -1561,5 +1998,6 @@ class ExamDraftController extends BaseController
|
||||
if ($this->hasFinalPdfColumn) {
|
||||
$update['final_pdf_file'] = $pdfName;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user