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

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