diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 57a2926..fad6d96 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -363,6 +363,7 @@ $routes->get('/teacher/teacher_contactus', 'View\TeacherController::contactusTea $routes->get('/teacher/exam-drafts', 'View\ExamDraftController::teacherIndex', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']); $routes->post('/teacher/exam-drafts', 'View\ExamDraftController::teacherStore', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']); +$routes->get('/teacher/exam-drafts/status', 'View\ExamDraftController::teacherStatusFeed', ['filter' => 'auth:teacher,teacher_assistant,teacher_dashboard,read']); diff --git a/app/Controllers/View/ExamDraftController.php b/app/Controllers/View/ExamDraftController.php index 43f167a..a32524b 100644 --- a/app/Controllers/View/ExamDraftController.php +++ b/app/Controllers/View/ExamDraftController.php @@ -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> $rows + * @return list + */ + 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> $rows + * @param list $printableIds + * @return list> + */ + 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> $rows + * @return list> + */ + 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()); diff --git a/app/Controllers/View/FilesController.php b/app/Controllers/View/FilesController.php index 31a0494..ea1462c 100644 --- a/app/Controllers/View/FilesController.php +++ b/app/Controllers/View/FilesController.php @@ -271,8 +271,8 @@ class FilesController extends Controller private function buildDraftDownloadName(string $filename, string $subdir): string { - $column = $subdir === 'finals' ? 'final_file' : 'teacher_file'; $db = Database::connect(); + $column = $subdir === 'finals' ? 'final_file' : $this->resolveExamDraftFileColumn($db); $row = $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') @@ -301,4 +301,20 @@ class FilesController extends Controller $value = trim($value, '_'); return $value === '' ? 'Exam' : mb_strtolower($value); } + + private function resolveExamDraftFileColumn($db): string + { + try { + $fields = $db->getFieldNames('exam_drafts'); + if (in_array('teacher_file', $fields, true)) { + return 'teacher_file'; + } + if (in_array('author_file', $fields, true)) { + return 'author_file'; + } + } catch (\Throwable $e) { + log_message('error', 'FilesController::resolveExamDraftFileColumn error: ' . $e->getMessage()); + } + return 'teacher_file'; + } } diff --git a/app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php b/app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php new file mode 100644 index 0000000..52427fe --- /dev/null +++ b/app/Database/Migrations/2026-01-27-220000_AddReviewRevisionToExamDrafts.php @@ -0,0 +1,30 @@ +db->fieldExists('review_revision', 'exam_drafts')) { + $this->forge->addColumn('exam_drafts', [ + 'review_revision' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => false, + 'default' => 0, + 'after' => 'version', + ], + ]); + } + } + + public function down() + { + if ($this->db->fieldExists('review_revision', 'exam_drafts')) { + $this->forge->dropColumn('exam_drafts', 'review_revision'); + } + } +} diff --git a/app/Models/ExamDraftModel.php b/app/Models/ExamDraftModel.php index 1044245..80244f8 100644 --- a/app/Models/ExamDraftModel.php +++ b/app/Models/ExamDraftModel.php @@ -8,19 +8,50 @@ class ExamDraftModel extends Model { protected $table = 'exam_drafts'; protected $primaryKey = 'id'; + protected $returnType = 'array'; + protected $useTimestamps = true; + protected $createdField = 'created_at'; + protected $updatedField = 'updated_at'; + protected bool $updateOnlyChanged = false; + + /** @var list Stored in `status` column */ + public const STATUSES = [ + 'submitted', + 'accepted', + 'review needed', + 'rejected', + 'canceled', + 'under review', + 'legacy', + ]; + + /** @var list Stored in `acceptance_type` when status is finalized */ + public const ACCEPTANCE_TYPES = [ + 'as_is', + 'minor_edits', + ]; + protected $allowedFields = [ 'teacher_id', + 'author_id', 'class_section_id', 'semester', 'school_year', 'exam_type', 'draft_title', + 'author_comment', 'description', 'teacher_file', 'teacher_filename', + 'author_file', + 'author_filename', 'status', + 'acceptance_type', + 'review_revision', + 'reviewer_id', 'admin_id', 'is_legacy', + 'reviewer_comment', 'admin_comments', 'reviewed_at', 'final_file', @@ -29,9 +60,40 @@ class ExamDraftModel extends Model 'version', 'previous_draft_id', ]; - protected $returnType = 'array'; - protected $useTimestamps = true; - protected $createdField = 'created_at'; - protected $updatedField = 'updated_at'; - protected bool $updateOnlyChanged = false; // force updates even if CI thinks nothing changed + + /** + * Applied on insert/update when validation runs (e.g. $model->insert($data, true)). + * Uses if_exist so partial updates still validate only keys present in $data. + */ + protected $validationRules = [ + 'status' => 'if_exist|in_list[submitted,accepted,review needed,rejected,canceled,under review,legacy]', + 'acceptance_type' => 'if_exist|permit_empty|in_list[as_is,minor_edits]', + 'author_id' => 'if_exist|is_natural_no_zero', + 'class_section_id' => 'if_exist|is_natural_no_zero', + 'version' => 'if_exist|is_natural_no_zero', + ]; + + protected $validationMessages = [ + 'status' => [ + 'in_list' => 'Invalid exam draft status.', + ], + 'acceptance_type' => [ + 'in_list' => 'Acceptance must be as_is or minor_edits.', + ], + ]; + + /** + * @var array + */ + protected array $casts = [ + 'teacher_id' => 'int', + 'author_id' => 'int', + 'class_section_id' => 'int', + 'reviewer_id' => '?int', + 'admin_id' => '?int', + 'review_revision' => 'int', + 'version' => 'int', + 'previous_draft_id' => '?int', + 'is_legacy' => 'boolean', + ]; } diff --git a/app/Views/administrator/exam_drafts.php b/app/Views/administrator/exam_drafts.php index fc03e51..c7f6b37 100644 --- a/app/Views/administrator/exam_drafts.php +++ b/app/Views/administrator/exam_drafts.php @@ -1,9 +1,28 @@ extend('layout/management_layout') ?> section('content') ?> + $status, 'class' => 'bg-secondary text-white']; + $style = !empty($b['style']) ? ' style="' . esc($b['style']) . '"' : ''; + return '' . esc($b['label']) . ''; +}; + +$fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensions)); +?>
-

Exam Draft Submissions

+

Exam draft submissions

@@ -15,13 +34,9 @@
getFlashdata('success')): ?> -
- getFlashdata('success')) ?> -
+
getFlashdata('success')) ?>
getFlashdata('error')): ?> -
- getFlashdata('error')) ?> -
+
getFlashdata('error')) ?>
+
@@ -49,13 +65,13 @@ Teacher Class - Title / Type + Title & AuthorNote Version - Submitted + Date-Time Status Files - PDF - Review + Reviewer Action + Final version @@ -64,11 +80,13 @@ $teacherName = trim(($draft['teacher_first'] ?? '') . ' ' . ($draft['teacher_last'] ?? '')); if ($teacherName === '') { $teacherName = ($draft['admin_id'] ?? null) === ($draft['teacher_id'] ?? null) - ? 'Admin Upload' + ? 'Admin upload' : 'User #' . ($draft['teacher_id'] ?? 'N/A'); } - $statusInfo = $statusBadges[$draft['status'] ?? 'pending'] ?? ['label' => 'Unknown', 'class' => 'bg-secondary text-white']; + $st = strtolower((string) ($draft['status'] ?? '')); $adminName = trim(($draft['admin_first'] ?? '') . ' ' . ($draft['admin_last'] ?? '')); + $authorNote = $draft['author_comment'] ?? $draft['description'] ?? ''; + $reviewerNote = $draft['reviewer_comment'] ?? $draft['admin_comments'] ?? ''; ?> @@ -76,16 +94,34 @@
- -
+ +
+ Author: + +
+ + +
+ Reviewer: + +
- v - + v + - - + $st, 'class' => 'bg-secondary text-white']; + $badgeStyle = !empty($badgeData['style']) ? ' style="' . esc($badgeData['style']) . '"' : ''; + ?> + > + +
+ + + +
Reviewed @@ -96,70 +132,92 @@ + + No teacher file - + + +
+ ' . esc('Ver' . $revNumber . '_' . $rev . ' ' . $name) . ''; + } + } + ?> + +
+ +
+ + + +
- + + 'vstack gap-2', 'id' => $formId]) ?> + + + + +
+ As is / Minor edits + +
+ > + +
+
+ > + +
+
+ +
+ +
+ + + - - PDF - - - +
+ View + Download +
- -
- - -
- -
-
- - -
-
- - -
- -
- @@ -169,40 +227,38 @@
+
- Upload Old / Legacy Exam -
Store historic exams as finalized records.
+ Upload old / legacy exam +
Store historic exams as accepted records.
Admin only
-
+ 'row g-3']) ?>
- - - +
Hold Ctrl (Windows) or Command (Mac) to select multiple sections.
- +
- +
- +
- - + +
Allowed: • Max MB
- +
-
+
@@ -231,20 +287,21 @@
- -
+ + +
-
+
+
-
+
- - Download - + View + Download File missing @@ -260,6 +317,175 @@
+ endSection() ?> section('styles') ?> diff --git a/app/Views/teacher/drafts.php b/app/Views/teacher/drafts.php index a479ce9..417c365 100644 --- a/app/Views/teacher/drafts.php +++ b/app/Views/teacher/drafts.php @@ -1,34 +1,231 @@ extend('layout/main_layout') ?> section('content') ?> +
-

Drafts

- +

Exam drafts

+ + getFlashdata('success')): ?> +
getFlashdata('success')) ?>
+ + getFlashdata('error')): ?> +
getFlashdata('error')) ?>
+ + +
+
+ 'row g-3']) ?> + +
+ + +
+
+ + +
+
+ + +
Max MB.
+
+
+ +
+ + +

School year: · Semester:

+ +
+
+ + $status, 'class' => 'bg-secondary text-white']; + $style = !empty($b['style']) ? ' style="' . esc($b['style']) . '"' : ''; + return '' . esc($b['label']) . ''; + }; + ?> + +

Your submissions

+
- +
- - - + + + + + + + - - - - + + + + + + + + + +
SubjectDateActionsClassTitle / typeVer.StatusFile linkReviewer commentLast Update
- Edit | - Delete + + +
+ + + +
+ + +
+ + + +
+ + +
+ ' . esc('Ver' . $revNumber . '_' . $rev . ' ' . $name) . ''; + } + } + ?> + +
+ + + — +
-

No drafts found.

+

No submissions yet.

+ + + +

Previous exams (legacy)

+
+ + + + + + + + + + + + + + + + + + + + + + +
ClassTitleVer.StatusActions
+ + Print + +
+
+
endSection() ?> + + diff --git a/writable/uploads/exams/drafts/1769574007_85708d166c498a8f0eac.docx b/writable/uploads/exams/drafts/1769574007_85708d166c498a8f0eac.docx deleted file mode 100644 index 7509ccc..0000000 Binary files a/writable/uploads/exams/drafts/1769574007_85708d166c498a8f0eac.docx and /dev/null differ diff --git a/writable/uploads/exams/finals/1769650456_c71750b30750acec2743.docx b/writable/uploads/exams/finals/1769650456_c71750b30750acec2743.docx deleted file mode 100644 index 7509ccc..0000000 Binary files a/writable/uploads/exams/finals/1769650456_c71750b30750acec2743.docx and /dev/null differ diff --git a/writable/uploads/exams/finals/1769650456_c71750b30750acec2743.pdf b/writable/uploads/exams/finals/1769650456_c71750b30750acec2743.pdf deleted file mode 100644 index 871dd94..0000000 Binary files a/writable/uploads/exams/finals/1769650456_c71750b30750acec2743.pdf and /dev/null differ diff --git a/writable/uploads/exams/finals/final_697ab9249ff2c3.31256061.docx b/writable/uploads/exams/finals/final_697ab9249ff2c3.31256061.docx deleted file mode 100644 index 7509ccc..0000000 Binary files a/writable/uploads/exams/finals/final_697ab9249ff2c3.31256061.docx and /dev/null differ diff --git a/writable/uploads/exams/finals/final_697ab9249ff2c3.31256061.pdf b/writable/uploads/exams/finals/final_697ab9249ff2c3.31256061.pdf deleted file mode 100644 index 58b3fc8..0000000 Binary files a/writable/uploads/exams/finals/final_697ab9249ff2c3.31256061.pdf and /dev/null differ