fic exam draft submission

This commit is contained in:
root
2026-04-08 19:45:47 -04:00
parent 2611730ec6
commit 112d073235
12 changed files with 1298 additions and 174 deletions
+661 -69
View File
@@ -23,9 +23,16 @@ class ExamDraftController extends BaseController
protected string $semester;
protected bool $hasFinalPdfColumn = false;
protected bool $hasIsLegacyColumn = false;
protected bool $hasAcceptanceTypeColumn = false;
protected bool $hasAdminIdColumn = false;
protected bool $hasReviewRevisionColumn = false;
protected string $authorIdColumn = 'teacher_id';
protected string $authorFileColumn = 'teacher_file';
protected string $authorFilenameColumn = 'teacher_filename';
protected string $reviewerIdColumn = 'admin_id';
protected string $reviewerCommentColumn = 'reviewer_comment';
// DB enum: draft, submitted, reviewed, finalized, rejected
// (string literals used to avoid excess constants)
// DB status: submitted, accepted, review needed, rejected, canceled, under review, legacy
protected const TEACHER_UPLOAD_DIR = 'exams/drafts';
protected const FINAL_UPLOAD_DIR = 'exams/finals';
@@ -55,6 +62,18 @@ class ExamDraftController extends BaseController
$this->semester = (string) ($this->configModel->getConfig('semester') ?? '');
$this->hasFinalPdfColumn = $this->schemaHasColumn('exam_drafts', 'final_pdf_file');
$this->hasIsLegacyColumn = $this->schemaHasColumn('exam_drafts', 'is_legacy');
$this->hasAcceptanceTypeColumn = $this->schemaHasColumn('exam_drafts', 'acceptance_type');
$this->hasAdminIdColumn = $this->schemaHasColumn('exam_drafts', 'admin_id');
$this->hasReviewRevisionColumn = $this->schemaHasColumn('exam_drafts', 'review_revision');
$this->authorIdColumn = $this->schemaHasColumn('exam_drafts', 'teacher_id') ? 'teacher_id' : 'author_id';
$this->authorFileColumn = $this->schemaHasColumn('exam_drafts', 'teacher_file') ? 'teacher_file' : 'author_file';
$this->authorFilenameColumn = $this->schemaHasColumn('exam_drafts', 'teacher_filename') ? 'teacher_filename' : 'author_filename';
$this->reviewerIdColumn = $this->hasAdminIdColumn
? 'admin_id'
: ($this->schemaHasColumn('exam_drafts', 'reviewer_id') ? 'reviewer_id' : '');
$this->reviewerCommentColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comment')
? 'reviewer_comment'
: ($this->schemaHasColumn('exam_drafts', 'admin_comments') ? 'admin_comments' : '');
helper(['form', 'url', 'date']);
}
@@ -73,7 +92,10 @@ class ExamDraftController extends BaseController
}
$allDrafts = $this->examDraftModel
->where('teacher_id', $teacherId)
->select($this->draftSelectColumns())
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_id', 'left')
->where($this->authorIdColumn, $teacherId)
->orderBy('created_at', 'DESC')
->findAll();
@@ -96,10 +118,12 @@ class ExamDraftController extends BaseController
if (!empty($classSectionIds)) {
$legacyExams = $this->examDraftModel
->select('exam_drafts.*, cs.class_section_name')
->select($this->draftSelectColumns())
->select('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.status', 'legacy')
->where('exam_drafts.final_file IS NOT NULL', null, false)
->orderBy('cs.class_section_name', 'ASC')
->orderBy('exam_drafts.created_at', 'DESC')
@@ -110,9 +134,18 @@ class ExamDraftController extends BaseController
$drafts = $allDrafts;
}
$printablePool = array_merge($drafts, $legacyExams);
$printableIds = $this->printableDraftIds($printablePool);
if ($this->hasReviewRevisionColumn) {
$drafts = $this->groupReviewRevisions($drafts);
$legacyExams = $this->groupReviewRevisions($legacyExams);
}
$drafts = $this->attachPrintableFlags($drafts, $printableIds);
$legacyExams = $this->attachPrintableFlags($legacyExams, $printableIds);
$validation = session()->getFlashdata('validation') ?? $this->validator;
return view('teacher/exam_drafts', [
return view('teacher/drafts', [
'assignments' => $assignments,
'selectedClassSection' => $selectedClass,
'drafts' => $drafts,
@@ -123,6 +156,7 @@ class ExamDraftController extends BaseController
'semester' => $this->semester,
'maxUploadBytes' => self::MAX_UPLOAD_BYTES,
'validation' => $validation,
'printableDraftIds' => $printableIds,
]);
}
@@ -135,7 +169,7 @@ class ExamDraftController extends BaseController
$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'));
$authorComment = trim((string) $this->request->getPost('author_comment'));
if ($classSectionId <= 0) {
return redirect()->back()->withInput()->with('error', 'Select a class section before submitting.');
@@ -152,12 +186,8 @@ class ExamDraftController extends BaseController
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.');
}
}
$saveAsDraft = $this->request->getPost('action') === 'draft'
|| !empty($this->request->getPost('save_as_draft'));
$file = $this->request->getFile('draft_file');
$teacherFile = null;
@@ -174,38 +204,122 @@ class ExamDraftController extends BaseController
}
$existingDraft = $this->examDraftModel
->where('teacher_id', $teacherId)
->select($this->draftSelectColumns())
->where($this->authorIdColumn, $teacherId)
->where('class_section_id', $classSectionId)
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->orderBy('version', 'DESC')
->first();
$title = $examType ?: 'Exam Draft';
if ($saveAsDraft) {
if ($teacherFile === null && (empty($existingDraft) || empty($this->draftTeacherFile($existingDraft)))) {
return redirect()->back()->withInput()->with('error', 'Upload a file to save a draft.');
}
if (!empty($existingDraft) && strtolower((string) ($existingDraft['status'] ?? '')) === 'draft') {
$draftRowId = (int) ($existingDraft['id'] ?? 0);
$updateDraft = [
'exam_type' => $examType ?: null,
'draft_title' => $title,
'author_comment' => $authorComment === '' ? null : $authorComment,
'status' => 'draft',
];
$this->applyAuthorFilePayload($updateDraft, $teacherFile, $teacherFilename);
if ($this->examDraftModel->update($draftRowId, $updateDraft)) {
$this->notifyExamDraftEvent($this->examDraftModel->find($draftRowId), 'draft_saved');
return redirect()->to('/teacher/exam-drafts')->with('success', 'Draft saved.');
}
return redirect()->back()->withInput()->with('error', 'Unable to update the draft.');
}
$nextVersion = 1;
$previousId = null;
if (!empty($existingDraft)) {
$currentVersion = (int) ($existingDraft['version'] ?? 0);
if ($currentVersion <= 0) {
$currentVersion = 1;
}
$nextVersion = $currentVersion + 1;
$previousId = (int) ($existingDraft['id'] ?? 0) ?: null;
}
$payload = [
$this->authorIdColumn => $teacherId,
'class_section_id' => $classSectionId,
'semester' => $this->semester,
'school_year' => $this->schoolYear,
'exam_type' => $examType ?: null,
'draft_title' => $title,
'author_comment' => $authorComment === '' ? null : $authorComment,
'status' => 'draft',
'version' => $nextVersion,
'previous_draft_id' => $previousId,
];
$this->applyAuthorFilePayload($payload, $teacherFile, $teacherFilename);
if ($this->examDraftModel->insert($payload)) {
$newDraftId = (int) $this->examDraftModel->getInsertID();
$this->notifyExamDraftEvent($newDraftId > 0 ? $this->examDraftModel->find($newDraftId) : null, 'draft_saved');
return redirect()->to('/teacher/exam-drafts')->with('success', 'Draft saved.');
}
return redirect()->back()->withInput()->with('error', 'Unable to save the draft.');
}
$existingStatus = strtolower((string) ($existingDraft['status'] ?? ''));
if ($examType === '') {
return redirect()->back()->withInput()->with('error', 'Select an exam type before submitting.');
}
if ($teacherFile === null) {
return redirect()->back()->withInput()->with('error', 'Upload a DOC/DOCX file to submit the exam draft.');
}
// Submit for review: overwrite only if latest is already submitted; otherwise create a new version.
if (!empty($existingDraft) && $existingStatus === 'submitted') {
$draftRowId = (int) ($existingDraft['id'] ?? 0);
$updateSubmit = [
'exam_type' => $examType ?: null,
'draft_title' => $title,
'author_comment' => $authorComment === '' ? null : $authorComment,
'status' => 'submitted',
];
$this->applyAuthorFilePayload($updateSubmit, $teacherFile, $teacherFilename);
if ($this->examDraftModel->update($draftRowId, $updateSubmit)) {
$saved = $this->ensureDraftStatus($draftRowId, 'submitted');
$this->notifyExamDraftEvent($saved, 'submitted');
return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.');
}
return redirect()->back()->withInput()->with('error', 'Unable to submit the exam draft.');
}
$nextVersion = 1;
$previousId = null;
if (!empty($existingDraft)) {
$nextVersion = ((int) ($existingDraft['version'] ?? 1)) + 1;
$currentVersion = (int) ($existingDraft['version'] ?? 0);
if ($currentVersion <= 0) {
$currentVersion = 1;
}
$nextVersion = $currentVersion + 1;
$previousId = (int) ($existingDraft['id'] ?? 0) ?: null;
}
$title = $examType ?: 'Exam Draft';
$payload = [
'teacher_id' => $teacherId,
$this->authorIdColumn => $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,
'author_comment' => $authorComment === '' ? null : $authorComment,
'status' => 'submitted',
'version' => $nextVersion,
'previous_draft_id' => $previousId,
];
$this->applyAuthorFilePayload($payload, $teacherFile, $teacherFilename);
if ($this->examDraftModel->insert($payload)) {
$newId = (int) $this->examDraftModel->getInsertID();
$saved = $newId > 0 ? $this->ensureDraftStatus($newId, 'submitted') : null;
$this->notifyExamDraftEvent($saved, 'submitted');
return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.');
}
@@ -214,13 +328,28 @@ class ExamDraftController extends BaseController
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();
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')
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_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')
->findAll();
} 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)
->join('classSection cs', 'cs.class_section_id = exam_drafts.class_section_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'])) {
@@ -237,7 +366,7 @@ class ExamDraftController extends BaseController
->orderBy('class_section_name', 'ASC')
->findAll();
// Group legacy uploads (admin-uploaded finalized exams) by class_section for separate tab
// 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.
@@ -263,10 +392,23 @@ class ExamDraftController extends BaseController
$drafts = $allDrafts;
}
$legacyFlat = [];
foreach ($legacyByClass as $group) {
foreach ($group['items'] ?? [] as $item) {
$legacyFlat[] = $item;
}
}
$printablePool = array_merge($drafts, $legacyFlat);
$printableIds = $this->printableDraftIds($printablePool);
$drafts = $this->attachPrintableFlags($drafts, $printableIds);
foreach ($legacyByClass as &$group) {
$group['items'] = $this->attachPrintableFlags($group['items'] ?? [], $printableIds);
}
unset($group);
return view('administrator/exam_drafts', [
'drafts' => $drafts,
'statusBadges' => $this->statusBadgeMap(),
'statusOptions' => $this->statusOptions(),
'schoolYear' => $this->schoolYear,
'semester' => $this->semester,
'allowedExtensions' => self::ADMIN_ALLOWED_EXTENSIONS,
@@ -274,6 +416,7 @@ class ExamDraftController extends BaseController
'examTypes' => $this->examTypes,
'classSections' => $classSections,
'legacyByClass' => $legacyByClass,
'printableDraftIds' => $printableIds,
]);
}
@@ -323,19 +466,21 @@ class ExamDraftController extends BaseController
);
}
$basePayload = [
'teacher_id' => $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)),
'school_year' => $schoolYear,
'exam_type' => $examType === '' ? null : $examType,
'draft_title' => $examType === '' ? 'Legacy Exam' : $examType,
'description' => null,
'author_comment' => null,
'final_file' => $stored,
'final_filename' => $file->getClientName(),
'status' => 'finalized',
'admin_id' => $adminId,
'status' => 'legacy',
'reviewed_at' => utc_now(),
'version' => 1,
];
if ($this->reviewerIdColumn !== '') {
$basePayload[$this->reviewerIdColumn] = $adminId;
}
if ($this->hasIsLegacyColumn) {
$basePayload['is_legacy'] = 1;
}
@@ -366,12 +511,12 @@ class ExamDraftController extends BaseController
return redirect()->back()->with('error', 'Invalid submission selected.');
}
$draft = $this->examDraftModel->find($draftId);
$draft = $this->findDraft($draftId);
if (empty($draft)) {
return redirect()->back()->with('error', 'Submission not found.');
}
$comments = trim((string) $this->request->getPost('admin_comments'));
$reviewerComment = trim((string) ($this->request->getPost('reviewer_comment') ?? $this->request->getPost('admin_comments')));
$statusInput = trim((string) $this->request->getPost('review_status'));
$currentStatus = (string) ($draft['status'] ?? 'draft');
$status = $this->normalizeStatus(
@@ -380,23 +525,28 @@ class ExamDraftController extends BaseController
);
$status = strtolower($status);
if (!in_array($status, $this->statusOptions(), true)) {
$status = 'reviewed';
$status = 'under review';
}
$acceptanceType = null;
if ($status === 'accepted') {
$rawAccept = strtolower(trim((string) $this->request->getPost('acceptance_type')));
$acceptanceType = in_array($rawAccept, ['as_is', 'minor_edits'], true) ? $rawAccept : 'as_is';
}
$finalFile = null;
$finalFilename = null;
$file = $this->request->getFile('final_file');
if ($file && $file->isValid() && !$file->hasMoved()) {
$stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR);
$stored = $this->storeUploadedFile($file, self::FINAL_UPLOAD_DIR, self::ADMIN_ALLOWED_EXTENSIONS);
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';
if ($status === 'accepted') {
$status = 'accepted';
}
} elseif ($file && $file->getError() !== UPLOAD_ERR_NO_FILE) {
return redirect()->back()->with('error', 'Final file upload failed.');
@@ -404,36 +554,84 @@ class ExamDraftController extends BaseController
$update = [
'status' => $status,
'admin_comments' => $comments === '' ? null : $comments,
'admin_id' => (int) (session()->get('user_id') ?? 0),
'reviewed_at' => utc_now(),
];
if ($this->reviewerCommentColumn !== '') {
$update[$this->reviewerCommentColumn] = $reviewerComment === '' ? null : $reviewerComment;
}
if ($this->reviewerIdColumn !== '') {
$update[$this->reviewerIdColumn] = (int) (session()->get('user_id') ?? 0);
}
if ($this->hasAcceptanceTypeColumn) {
$update['acceptance_type'] = $status === 'accepted' ? $acceptanceType : null;
}
if ($finalFile !== null) {
$update['final_file'] = $finalFile;
$update['final_filename'] = $finalFilename;
$baseVersion = (int) ($draft['version'] ?? 1);
if ($baseVersion <= 0) {
$baseVersion = 1;
}
$reviewRevision = $this->nextReviewRevision($draft, $baseVersion);
$newRow = [
$this->authorIdColumn => $this->draftTeacherId($draft),
'class_section_id' => (int) ($draft['class_section_id'] ?? 0),
'semester' => (string) ($draft['semester'] ?? ''),
'school_year' => (string) ($draft['school_year'] ?? ''),
'exam_type' => $draft['exam_type'] ?? null,
'draft_title' => $draft['draft_title'] ?? $draft['exam_type'] ?? 'Exam Draft',
'author_comment' => $draft['author_comment'] ?? null,
'status' => $status,
'reviewed_at' => $update['reviewed_at'],
'version' => $baseVersion,
'previous_draft_id' => (int) ($draft['id'] ?? 0) ?: null,
];
if ($this->hasReviewRevisionColumn) {
$newRow['review_revision'] = $reviewRevision;
}
if ($this->reviewerCommentColumn !== '') {
$newRow[$this->reviewerCommentColumn] = $update[$this->reviewerCommentColumn] ?? null;
}
if ($this->reviewerIdColumn !== '') {
$newRow[$this->reviewerIdColumn] = $update[$this->reviewerIdColumn] ?? null;
}
if ($this->hasAcceptanceTypeColumn) {
$newRow['acceptance_type'] = $status === 'accepted' ? $acceptanceType : null;
}
$teacherFile = $this->draftTeacherFile($draft);
if (!empty($teacherFile)) {
$newRow[$this->authorFileColumn] = $teacherFile;
$newRow[$this->authorFilenameColumn] = $this->draftTeacherFilename($draft) ?? $teacherFile;
}
if ($this->hasIsLegacyColumn && !empty($draft['is_legacy'])) {
$newRow['is_legacy'] = 1;
}
$newRow['final_file'] = $finalFile;
$newRow['final_filename'] = $finalFilename;
$pdfName = $this->ensurePdfExists($finalFile, $file ? $file->getClientExtension() : null);
if ($pdfName !== null && $this->hasFinalPdfColumn) {
$update['final_pdf_file'] = $pdfName;
$newRow['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 ($this->examDraftModel->insert($newRow)) {
$newId = (int) $this->examDraftModel->getInsertID();
$after = $newId > 0 ? $this->examDraftModel->find($newId) : null;
$this->notifyExamDraftEvent($after, 'review_' . $status);
return redirect()->back()->with('success', 'Review saved successfully.');
}
return redirect()->back()->with('error', 'Unable to save the review.');
} elseif (strtolower($status) === 'accepted' && !empty($this->draftTeacherFile($draft))) {
$copied = $this->copyDraftToFinal($this->draftTeacherFile($draft));
if ($copied !== null) {
$update['final_file'] = $copied;
$update['final_filename'] = $draft['teacher_filename'] ?? $draft['teacher_file'];
$update['final_filename'] = $this->draftTeacherFilename($draft) ?? $this->draftTeacherFile($draft);
$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
$update['is_legacy'] = strtolower($status) === 'legacy' ? 1 : 0;
}
$updated = $this->examDraftModel
@@ -442,12 +640,82 @@ class ExamDraftController extends BaseController
->update();
if ($updated) {
$after = $this->examDraftModel->find($draftId);
$this->notifyExamDraftEvent($after, 'review_' . $status);
return redirect()->back()->with('success', 'Review saved successfully.');
}
return redirect()->back()->with('error', 'Unable to save the review.');
}
public function teacherPrint(int $id)
{
$teacherId = (int) (session()->get('user_id') ?? 0);
if ($teacherId <= 0) {
return redirect()->to('/login');
}
$draft = $this->findDraft($id);
if (empty($draft) || $this->draftTeacherId($draft) !== $teacherId) {
return redirect()->to('/teacher/exam-drafts')->with('error', 'Submission not found.');
}
$all = $this->examDraftModel
->select($this->draftSelectColumns())
->where($this->authorIdColumn, $teacherId)
->findAll();
$printable = $this->printableDraftIds($all);
if (!in_array($id, $printable, true)) {
return redirect()->to('/teacher/exam-drafts')->with('error', 'Only the latest accepted revision can be printed.');
}
return $this->streamFinalPdf($draft);
}
public function teacherStatusFeed()
{
$teacherId = (int) (session()->get('user_id') ?? 0);
if ($teacherId <= 0) {
return $this->response->setStatusCode(401);
}
$drafts = $this->examDraftModel
->select($this->draftSelectColumns())
->select('id, status, acceptance_type, updated_at, reviewed_at')
->where($this->authorIdColumn, $teacherId)
->orderBy('updated_at', 'DESC')
->findAll();
$payload = [
'drafts' => array_map(static fn(array $row): array => [
'id' => (int) ($row['id'] ?? 0),
'status' => strtolower((string) ($row['status'] ?? '')),
'acceptance_type' => (string) ($row['acceptance_type'] ?? ''),
'updated_at' => (string) ($row['updated_at'] ?? ''),
'reviewed_at' => (string) ($row['reviewed_at'] ?? ''),
], $drafts),
];
return $this->response->setJSON($payload);
}
public function adminPrint(int $id)
{
$adminId = (int) (session()->get('user_id') ?? 0);
if ($adminId <= 0) {
return redirect()->to('/login');
}
$draft = $this->findDraft($id);
if (empty($draft)) {
return redirect()->to('/administrator/exam-drafts')->with('error', 'Submission not found.');
}
$all = $this->examDraftModel->select($this->draftSelectColumns())->findAll();
$printable = $this->printableDraftIds($all);
if (!in_array($id, $printable, true)) {
return redirect()->to('/administrator/exam-drafts')->with('error', 'Only the latest accepted revision can be printed.');
}
return $this->streamFinalPdf($draft);
}
private function resolveSelectedClassSection(array $assignments): int
{
$candidate = (int) ($this->request->getGet('class_section_id') ?? session()->get('class_section_id') ?? 0);
@@ -461,41 +729,188 @@ class ExamDraftController extends BaseController
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',
'under review' => [
'label' => 'Under review',
'class' => 'bg-warning text-dark',
],
'review needed' => [
'label' => 'Review needed',
'class' => 'text-dark',
'style' => 'background-color:#fd7e14;',
],
'accepted' => [
'label' => 'Accepted',
'class' => 'bg-success text-white',
],
'legacy' => [
'label' => 'Legacy',
'class' => 'bg-secondary text-white',
],
'rejected' => [
'label' => 'Rejected',
'class' => 'bg-danger text-white',
],
'canceled' => [
'label' => 'Canceled',
'class' => 'bg-danger text-white',
],
];
}
private function draftSelectColumns(): string
{
$columns = ['exam_drafts.*'];
if ($this->authorIdColumn !== 'teacher_id') {
$columns[] = 'exam_drafts.' . $this->authorIdColumn . ' AS teacher_id';
}
if ($this->authorFileColumn !== 'teacher_file') {
$columns[] = 'exam_drafts.' . $this->authorFileColumn . ' AS teacher_file';
}
if ($this->authorFilenameColumn !== 'teacher_filename') {
$columns[] = 'exam_drafts.' . $this->authorFilenameColumn . ' AS teacher_filename';
}
return implode(', ', $columns);
}
private function applyAuthorFilePayload(array &$payload, ?string $file, ?string $filename): void
{
if ($file === null) {
return;
}
$payload[$this->authorFileColumn] = $file;
$payload[$this->authorFilenameColumn] = $filename;
}
private function draftTeacherId(array $draft): int
{
if (array_key_exists('teacher_id', $draft)) {
return (int) $draft['teacher_id'];
}
return (int) ($draft[$this->authorIdColumn] ?? 0);
}
private function draftTeacherFile(array $draft): ?string
{
if (array_key_exists('teacher_file', $draft)) {
return $draft['teacher_file'] ?? null;
}
return $draft[$this->authorFileColumn] ?? null;
}
private function draftTeacherFilename(array $draft): ?string
{
if (array_key_exists('teacher_filename', $draft)) {
return $draft['teacher_filename'] ?? null;
}
return $draft[$this->authorFilenameColumn] ?? null;
}
private function findDraft(int $id): ?array
{
return $this->examDraftModel
->select($this->draftSelectColumns())
->where('id', $id)
->first();
}
private function ensureDraftStatus(int $id, string $status): ?array
{
$saved = $this->examDraftModel->find($id);
if (empty($saved)) {
return null;
}
$current = strtolower((string) ($saved['status'] ?? ''));
if ($current !== $status) {
$this->examDraftModel->update($id, ['status' => $status]);
$saved = $this->examDraftModel->find($id);
}
return $saved;
}
private function nextDraftVersion(array $draft): int
{
$query = $this->examDraftModel
->select('version')
->where($this->authorIdColumn, $this->draftTeacherId($draft))
->where('class_section_id', (int) ($draft['class_section_id'] ?? 0))
->where('semester', (string) ($draft['semester'] ?? ''))
->where('school_year', (string) ($draft['school_year'] ?? ''));
$examType = $draft['exam_type'] ?? null;
if ($examType === null || $examType === '') {
$query = $query->where('exam_type', null);
} else {
$query = $query->where('exam_type', $examType);
}
$latest = $query->orderBy('version', 'DESC')->first();
$current = (int) ($latest['version'] ?? 0);
if ($current <= 0) {
$current = (int) ($draft['version'] ?? 0);
}
if ($current <= 0) {
$current = 1;
}
return $current + 1;
}
private function nextReviewRevision(array $draft, int $baseVersion): int
{
if (!$this->hasReviewRevisionColumn) {
return 1;
}
$query = $this->examDraftModel
->select('review_revision')
->where($this->authorIdColumn, $this->draftTeacherId($draft))
->where('class_section_id', (int) ($draft['class_section_id'] ?? 0))
->where('semester', (string) ($draft['semester'] ?? ''))
->where('school_year', (string) ($draft['school_year'] ?? ''))
->where('version', $baseVersion);
$examType = $draft['exam_type'] ?? null;
if ($examType === null || $examType === '') {
$query = $query->where('exam_type', null);
} else {
$query = $query->where('exam_type', $examType);
}
$latest = $query->orderBy('review_revision', 'DESC')->first();
$current = (int) ($latest['review_revision'] ?? 0);
if ($current <= 0) {
$current = (int) ($draft['review_revision'] ?? 0);
}
return $current + 1;
}
private function statusOptions(): array
{
return ['draft', 'submitted', 'reviewed', 'finalized', 'rejected'];
return ['submitted', 'accepted', 'review needed', 'rejected', 'canceled', 'under review', 'legacy'];
}
private function normalizeStatus(string $input, string $default): string
{
$input = strtolower(trim($input));
$aliases = [
'pending' => 'submitted', // legacy value
'final' => 'finalized',
'approved' => 'finalized', // legacy value
'submitted' => 'submitted',
'under review' => 'under review',
'under_review' => 'under review',
'reviewed' => 'review needed',
'review needed' => 'review needed',
'review_needed' => 'review needed',
'accepted' => 'accepted',
'approved' => 'accepted',
'final' => 'accepted',
'legacy' => 'legacy',
'archived' => 'legacy',
'cancelled' => 'canceled',
'cancel' => 'canceled',
'rejected' => 'rejected',
'draft' => 'submitted',
];
if (isset($aliases[$input])) {
$input = $aliases[$input];
@@ -503,6 +918,183 @@ class ExamDraftController extends BaseController
return in_array($input, $this->statusOptions(), true) ? $input : $default;
}
/**
* One printable row per exam line: max(version) among accepted rows.
*
* @param list<array<string,mixed>> $rows
* @return list<int>
*/
private function printableDraftIds(array $rows): array
{
$best = [];
foreach ($rows as $row) {
if (strtolower((string) ($row['status'] ?? '')) !== 'accepted') {
continue;
}
$key = $this->examDraftLineKey($row);
$ver = (int) ($row['version'] ?? 0);
$id = (int) ($row['id'] ?? 0);
if ($id <= 0) {
continue;
}
if (!isset($best[$key]) || $ver > $best[$key]['version']) {
$best[$key] = ['version' => $ver, 'id' => $id];
}
}
return array_values(array_map(static fn(array $g): int => $g['id'], $best));
}
/**
* @param list<array<string,mixed>> $rows
* @param list<int> $printableIds
* @return list<array<string,mixed>>
*/
private function attachPrintableFlags(array $rows, array $printableIds): array
{
foreach ($rows as &$row) {
$row['is_printable'] = in_array((int) ($row['id'] ?? 0), $printableIds, true);
}
unset($row);
return $rows;
}
private function examDraftLineKey(array $row): string
{
return implode('|', [
$this->draftTeacherId($row),
(int) ($row['class_section_id'] ?? 0),
(string) ($row['school_year'] ?? ''),
strtolower(trim((string) ($row['semester'] ?? ''))),
(string) ($row['exam_type'] ?? ''),
]);
}
private function examDraftVersionKey(array $row): string
{
return implode('|', [
$this->draftTeacherId($row),
(int) ($row['class_section_id'] ?? 0),
(string) ($row['school_year'] ?? ''),
strtolower(trim((string) ($row['semester'] ?? ''))),
(string) ($row['exam_type'] ?? ''),
(int) ($row['version'] ?? 0),
]);
}
/**
* @param list<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
private function groupReviewRevisions(array $rows): array
{
$grouped = [];
foreach ($rows as $row) {
$key = $this->examDraftVersionKey($row);
$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]['review_files'][] = [
'review_revision' => $reviewRev,
'final_file' => $row['final_file'] ?? null,
'final_filename' => $row['final_filename'] ?? null,
'status' => $row['status'] ?? 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,
];
}
}
continue;
}
if (!isset($grouped[$key])) {
$grouped[$key] = $row;
$grouped[$key]['review_files'] = [];
continue;
}
if (!empty($grouped[$key]['_is_review_row'])) {
$reviewFiles = $grouped[$key]['review_files'] ?? [];
$grouped[$key] = $row;
$grouped[$key]['review_files'] = $reviewFiles;
unset($grouped[$key]['_is_review_row']);
}
}
foreach ($grouped as &$row) {
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;
}
unset($row['_is_review_row']);
}
unset($row);
return array_values($grouped);
}
private function notifyExamDraftEvent(?array $draft, string $event): void
{
if (empty($draft)) {
return;
}
$id = (int) ($draft['id'] ?? 0);
$version = (int) ($draft['version'] ?? 1);
log_message('info', "Exam draft notification [{$event}] id={$id} version={$version}");
}
private function streamFinalPdf(array $draft)
{
$storedName = !empty($draft['final_pdf_file'])
? (string) $draft['final_pdf_file']
: (string) ($draft['final_file'] ?? '');
if ($storedName === '') {
return redirect()->back()->with('error', 'No final file is available for printing.');
}
$ext = strtolower(pathinfo($storedName, PATHINFO_EXTENSION));
if ($ext !== 'pdf') {
$pdfName = $this->ensurePdfExists($storedName, $ext);
if ($pdfName !== null) {
$storedName = $pdfName;
}
}
$path = $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $storedName);
if (!is_file($path)) {
return redirect()->back()->with('error', 'The file could not be found.');
}
$downloadName = $draft['final_filename'] ?? 'exam.pdf';
if (strtolower(pathinfo($downloadName, PATHINFO_EXTENSION)) !== 'pdf') {
$downloadName = pathinfo($downloadName, PATHINFO_FILENAME) . '.pdf';
}
$body = file_get_contents($path);
if ($body === false) {
return redirect()->back()->with('error', 'Unable to read the file.');
}
return $this->response
->setContentType('application/pdf')
->setHeader('Content-Disposition', 'inline; filename="' . basename($downloadName) . '"')
->setBody($body);
}
private function storeUploadedFile(UploadedFile $file, string $subdir, array $allowedExtensions = self::ALLOWED_EXTENSIONS): ?string
{
$ext = strtolower($file->getClientExtension());