examDraftModel = new ExamDraftModel(); $this->teacherClassModel = new TeacherClassModel(); $this->classSectionModel = new ClassSectionModel(); $this->userModel = new UserModel(); $this->configModel = new ConfigurationModel(); $this->db = Database::connect(); $this->schoolYear = (string) ($this->configModel->getConfig('school_year') ?? ''); $this->semester = (string) ($this->configModel->getConfig('semester') ?? ''); $this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file'); $this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy'); helper(['form', 'url', 'date']); } public function teacherIndex() { $teacherId = (int) (session()->get('user_id') ?? 0); if ($teacherId <= 0) { return redirect()->to('/login'); } $assignments = $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $this->schoolYear); $selectedClass = $this->resolveSelectedClassSection($assignments); if ($selectedClass > 0) { session()->set('class_section_id', $selectedClass); } $allDrafts = $this->examDraftModel ->where('teacher_id', $teacherId) ->orderBy('created_at', 'DESC') ->findAll(); 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; } } } unset($row); $classSectionIds = array_map(static fn($a) => (int) $a['class_section_id'], $assignments); $legacyExams = []; // Guard against missing schema column in older databases if ($this->hasIsLegacyColumn) { // Keep all submissions visible; legacy ones are also surfaced in a separate tab $drafts = $allDrafts; if (!empty($classSectionIds)) { $legacyExams = $this->examDraftModel ->select('exam_drafts.*, cs.class_section_name') ->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left') ->whereIn('exam_drafts.class_section_id', $classSectionIds) ->where('exam_drafts.is_legacy', 1) ->where('exam_drafts.final_file IS NOT NULL', null, false) ->orderBy('cs.class_section_name', 'ASC') ->orderBy('exam_drafts.created_at', 'DESC') ->findAll(); } } else { // Legacy column absent, show all drafts and skip legacy tab query $drafts = $allDrafts; } $validation = session()->getFlashdata('validation') ?? $this->validator; return view('teacher/exam_drafts', [ 'assignments' => $assignments, 'selectedClassSection' => $selectedClass, 'drafts' => $drafts, 'legacyExams' => $legacyExams, 'examTypes' => $this->examTypes, 'statusBadges' => $this->statusBadgeMap(), 'schoolYear' => $this->schoolYear, 'semester' => $this->semester, 'maxUploadBytes' => self::MAX_UPLOAD_BYTES, 'validation' => $validation, ]); } public function teacherStore() { $teacherId = (int) (session()->get('user_id') ?? 0); if ($teacherId <= 0) { return redirect()->to('/login'); } $classSectionId = (int) ($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0); $examType = trim((string) $this->request->getPost('exam_type')); $description = trim((string) $this->request->getPost('description')); if ($classSectionId <= 0) { return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.'); } $assignment = $this->teacherClassModel ->where('teacher_id', $teacherId) ->where('class_section_id', $classSectionId) ->first(); if (empty($assignment)) { return redirect()->back()->withInput()->with('error', 'You are not assigned to the selected class section.'); } session()->set('class_section_id', $classSectionId); if ($classSectionId <= 0) { $classSectionId = $this->resolveSelectedClassSection($assignments); if ($classSectionId <= 0) { return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.'); } } $file = $this->request->getFile('draft_file'); $teacherFile = null; $teacherFilename = null; if ($file && $file->isValid() && !$file->hasMoved()) { $stored = $this->storeUploadedFile($file, self::TEACHER_UPLOAD_DIR); if ($stored === null) { return redirect()->back()->withInput()->with('error', 'Failed to store the uploaded file.'); } $teacherFile = $stored; $teacherFilename = $file->getClientName(); } elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) { return redirect()->back()->withInput()->with('error', 'Upload failed. Please try again.'); } $existingDraft = $this->examDraftModel ->where('teacher_id', $teacherId) ->where('class_section_id', $classSectionId) ->where('semester', $this->semester) ->where('school_year', $this->schoolYear) ->orderBy('version', 'DESC') ->first(); $nextVersion = 1; $previousId = null; if (!empty($existingDraft)) { $nextVersion = ((int) ($existingDraft['version'] ?? 1)) + 1; $previousId = (int) ($existingDraft['id'] ?? 0) ?: null; } $title = $examType ?: 'Exam Draft'; $payload = [ 'teacher_id' => $teacherId, 'class_section_id' => $classSectionId, 'semester' => $this->semester, 'school_year' => $this->schoolYear, 'exam_type' => $examType ?: null, 'draft_title' => $title, 'description' => $description === '' ? null : $description, 'teacher_file' => $teacherFile, 'teacher_filename' => $teacherFilename, 'status' => 'submitted', 'version' => $nextVersion, 'previous_draft_id' => $previousId, ]; if ($this->examDraftModel->insert($payload)) { return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.'); } return redirect()->back()->withInput()->with('error', 'Unable to save the exam draft.'); } public function adminIndex() { $allDrafts = $this->examDraftModel ->select('exam_drafts.*, cs.class_section_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('users u', 'u.id = exam_drafts.teacher_id', 'left') ->join('users a', 'a.id = exam_drafts.admin_id', 'left') ->orderBy('exam_drafts.created_at', 'DESC') ->findAll(); 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; } } } unset($row); $classSections = $this->classSectionModel ->select('class_section_id, class_section_name') ->orderBy('class_section_name', 'ASC') ->findAll(); // Group legacy uploads (admin-uploaded finalized exams) by class_section for separate tab $legacyByClass = []; if ($this->hasIsLegacyColumn) { // Keep all submissions visible; additionally surface legacy items in a separate tab $drafts = $allDrafts; foreach ($allDrafts as $d) { $isLegacy = !empty($d['is_legacy']); if (!$isLegacy) { continue; } $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; } } else { // Column missing: keep behavior simple and avoid legacy tab $drafts = $allDrafts; } return view('administrator/exam_drafts', [ 'drafts' => $drafts, 'statusBadges' => $this->statusBadgeMap(), 'statusOptions' => $this->statusOptions(), 'schoolYear' => $this->schoolYear, 'semester' => $this->semester, 'allowedExtensions' => self::ADMIN_ALLOWED_EXTENSIONS, 'maxUploadBytes' => self::MAX_UPLOAD_BYTES, 'examTypes' => $this->examTypes, 'classSections' => $classSections, 'legacyByClass' => $legacyByClass, ]); } public function adminUploadLegacy() { $adminId = (int) (session()->get('user_id') ?? 0); if ($adminId <= 0) { return redirect()->to('/login'); } $classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0); $schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear)); $semester = trim((string) ($this->request->getPost('semester') ?? $this->semester)); $examType = trim((string) $this->request->getPost('exam_type')); if ($classSectionId <= 0) { return redirect()->back()->withInput()->with('error', 'Select a class section.'); } if ($schoolYear === '') { return redirect()->back()->withInput()->with('error', 'School year is required.'); } if ($semester === '') { return redirect()->back()->withInput()->with('error', 'Semester is required.'); } $file = $this->request->getFile('old_exam_file'); if (!$file || !$file->isValid()) { return redirect()->back()->withInput()->with('error', 'A valid file is required.'); } $stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR, self::ADMIN_ALLOWED_EXTENSIONS); if ($stored === null) { return redirect()->back()->withInput()->with('error', 'File type not allowed or upload failed.'); } $payload = [ 'teacher_id' => $adminId, // store under admin user since legacy uploads are admin-only 'class_section_id' => $classSectionId, 'semester' => ucfirst(strtolower($semester)), 'school_year' => $schoolYear, 'exam_type' => $examType === '' ? null : $examType, 'draft_title' => $examType === '' ? 'Legacy Exam' : $examType, 'description' => null, 'final_file' => $stored, 'final_filename' => $file->getClientName(), 'status' => 'finalized', 'admin_id' => $adminId, 'reviewed_at' => utc_now(), 'version' => 1, ]; if ($this->hasIsLegacyColumn) { $payload['is_legacy'] = 1; } $pdfName = null; if (strtolower($file->getClientExtension()) === 'pdf') { $pdfName = $stored; } else { $pdfName = $this->convertDocToPdf( $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $stored), self::FINAL_UPLOAD_DIR ); } if ($pdfName !== null && $this->hasFinalPdfColumn) { $payload['final_pdf_file'] = $pdfName; } if ($this->examDraftModel->insert($payload)) { return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.'); } return redirect()->back()->withInput()->with('error', 'Unable to save the old exam.'); } public function adminReview() { $draftId = (int) ($this->request->getPost('draft_id') ?? 0); if ($draftId <= 0) { return redirect()->back()->with('error', 'Invalid submission selected.'); } $draft = $this->examDraftModel->find($draftId); if (empty($draft)) { return redirect()->back()->with('error', 'Submission not found.'); } $comments = trim((string) $this->request->getPost('admin_comments')); $statusInput = trim((string) $this->request->getPost('review_status')); $currentStatus = (string) ($draft['status'] ?? 'draft'); $status = $this->normalizeStatus( $statusInput !== '' ? $statusInput : $currentStatus, $currentStatus ); $status = strtolower($status); if (!in_array($status, $this->statusOptions(), true)) { $status = 'reviewed'; } $finalFile = null; $finalFilename = null; $file = $this->request->getFile('final_file'); if ($file && $file->isValid() && !$file->hasMoved()) { $stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR); if ($stored === null) { return redirect()->back()->with('error', 'Unable to store the final draft.')->withInput(); } $finalFile = $stored; $finalFilename = $file->getClientName(); // Only auto-finalize if the admin explicitly chose "finalized" // (previously any uploaded file forced finalization) if ($status === 'finalized') { $status = 'finalized'; } } elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) { return redirect()->back()->with('error', 'Final file upload failed.'); } $update = [ 'status' => $status, 'admin_comments' => $comments === '' ? null : $comments, 'admin_id' => (int) (session()->get('user_id') ?? 0), 'reviewed_at' => utc_now(), ]; if ($finalFile !== null) { $update['final_file'] = $finalFile; $update['final_filename'] = $finalFilename; $pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null); if ($pdfName !== null && $this->hasFinalPdfColumn) { $update['final_pdf_file'] = $pdfName; } } elseif (strtolower($status) === 'finalized' && !empty($draft['teacher_file'])) { // Auto-promote teacher file when admin finalizes without uploading a final $copied = $this->copyDraftToFinal($draft['teacher_file']); if ($copied !== null) { $update['final_file'] = $copied; $update['final_filename'] = $draft['teacher_filename'] ?? $draft['teacher_file']; $pdfName = $this->ensurePdfExists($copied, pathinfo($copied, PATHINFO_EXTENSION)); if ($pdfName !== null && $this->hasFinalPdfColumn) { $update['final_pdf_file'] = $pdfName; } } } if (strtolower($status) === 'finalized' && $this->hasIsLegacyColumn) { $update['is_legacy'] = 1; // finalized exams move to Previous Exams } if ($this->hasIsLegacyColumn) { // Keep existing legacy flag, do not set legacy automatically here } $updated = $this->examDraftModel ->set($update) ->where('id', $draftId) ->update(); if ($updated) { return redirect()->back()->with('success', 'Review saved successfully.'); } return redirect()->back()->with('error', 'Unable to save the review.'); } private function resolveSelectedClassSection(array $assignments): int { $candidate = (int) ($this->request->getGet('class_section_id') ?? session()->get('class_section_id') ?? 0); $validIds = array_map('intval', array_column($assignments, 'class_section_id')); if ($candidate > 0 && in_array($candidate, $validIds, true)) { return $candidate; } return $validIds[0] ?? 0; } private function statusBadgeMap(): array { return [ 'draft' => [ 'label' => 'Draft', 'class' => 'bg-secondary text-white', ], 'submitted' => [ 'label' => 'Submitted', 'class' => 'bg-warning text-dark', ], 'reviewed' => [ 'label' => 'Reviewed', 'class' => 'bg-info text-dark', ], 'finalized' => [ 'label' => 'Finalized', 'class' => 'bg-success text-white', ], 'rejected' => [ 'label' => 'Rejected', 'class' => 'bg-danger text-white', ], ]; } private function statusOptions(): array { return ['draft', 'submitted', 'reviewed', 'finalized', 'rejected']; } private function normalizeStatus(string $input, string $default): string { $input = strtolower(trim($input)); $aliases = [ 'pending' => 'submitted', // legacy value 'final' => 'finalized', 'approved' => 'finalized', // legacy value ]; if (isset($aliases[$input])) { $input = $aliases[$input]; } return in_array($input, $this->statusOptions(), true) ? $input : $default; } private function storeUploadedFile(UploadedFile $file, string $subdir, array $allowedExtensions = self::ALLOWED_EXTENSIONS): ?string { $ext = strtolower($file->getClientExtension()); if (!in_array($ext, $allowedExtensions, true)) { return null; } if ($file->getSize() > self::MAX_UPLOAD_BYTES) { return null; } $destination = WRITEPATH . 'uploads/' . trim($subdir, '/'); if (!is_dir($destination)) { mkdir($destination, 0755, true); } $filename = $file->getRandomName(); try { $file->move($destination, $filename); return $filename; } catch (\Throwable $e) { log_message('error', 'ExamDraftController::storeUploadedFile error: ' . $e->getMessage()); return null; } } private function convertDocToPdf(string $sourcePath, string $targetSubdir): ?string { // Only attempt conversion for doc/docx $ext = strtolower(pathinfo($sourcePath, PATHINFO_EXTENSION)); if (!in_array($ext, ['doc', 'docx'], true)) { return null; } $targetDir = WRITEPATH . 'uploads/' . trim($targetSubdir, '/'); if (!is_dir($targetDir)) { mkdir($targetDir, 0755, true); } $base = pathinfo($sourcePath, PATHINFO_FILENAME); $targetPath = $targetDir . '/' . $base . '.pdf'; // Attempt conversion via LibreOffice if available $cmd = 'soffice --headless --convert-to pdf --outdir ' . escapeshellarg($targetDir) . ' ' . escapeshellarg($sourcePath) . ' 2>/dev/null'; @exec($cmd); return is_file($targetPath) ? basename($targetPath) : null; } private function schemaHasColumn(string $table, string $column): bool { try { $fields = $this->db->getFieldNames($table); return in_array($column, $fields, true); } catch (\Throwable $e) { log_message('error', "Schema check failed for {$table}.{$column}: " . $e->getMessage()); return false; } } private function fullUploadPath(string $subdir, string $filename): string { return WRITEPATH . 'uploads/' . trim($subdir, '/') . '/' . $filename; } private function neighborPdfIfExists(?string $filename, string $subdir): ?string { if (empty($filename)) return null; $path = $this->fullUploadPath($subdir, $filename); $base = pathinfo($path, PATHINFO_FILENAME); $dir = pathinfo($path, PATHINFO_DIRNAME); $pdfPath = $dir . '/' . $base . '.pdf'; return is_file($pdfPath) ? basename($pdfPath) : null; } private function ensurePdfExists(string $finalFilename, ?string $originalExt): ?string { $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 ); } private function copyDraftToFinal(string $draftFilename): ?string { $source = $this->fullUploadPath(self::TEACHER_UPLOAD_DIR, $draftFilename); if (!is_file($source)) { return null; } $destinationDir = WRITEPATH . 'uploads/' . trim(self::FINAL_UPLOAD_DIR, '/'); if (!is_dir($destinationDir)) { mkdir($destinationDir, 0755, true); } $ext = pathinfo($draftFilename, PATHINFO_EXTENSION); $destName = uniqid('final_', true) . '.' . $ext; $destPath = $destinationDir . '/' . $destName; if (!@copy($source, $destPath)) { return null; } return $destName; } }