fic exam draft submission
This commit is contained in:
@@ -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']);
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class AddReviewRevisionToExamDrafts extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
if (! $this->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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<string> Stored in `status` column */
|
||||
public const STATUSES = [
|
||||
'submitted',
|
||||
'accepted',
|
||||
'review needed',
|
||||
'rejected',
|
||||
'canceled',
|
||||
'under review',
|
||||
'legacy',
|
||||
];
|
||||
|
||||
/** @var list<string> 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<string, string>
|
||||
*/
|
||||
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',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
<?= $this->extend('layout/management_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<?php
|
||||
$statusBadges = $statusBadges ?? [];
|
||||
$drafts = $drafts ?? [];
|
||||
$legacyByClass = $legacyByClass ?? [];
|
||||
$classSections = $classSections ?? [];
|
||||
$examTypes = $examTypes ?? [];
|
||||
$schoolYear = $schoolYear ?? '';
|
||||
$semester = $semester ?? '';
|
||||
$maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
|
||||
$allowedExtensions = $allowedExtensions ?? ['doc', 'docx', 'pdf'];
|
||||
|
||||
$renderBadge = static function (string $status, array $badges): string {
|
||||
$b = $badges[$status] ?? ['label' => $status, 'class' => 'bg-secondary text-white'];
|
||||
$style = !empty($b['style']) ? ' style="' . esc($b['style']) . '"' : '';
|
||||
return '<span class="badge ' . esc($b['class']) . '"' . $style . '>' . esc($b['label']) . '</span>';
|
||||
};
|
||||
|
||||
$fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensions));
|
||||
?>
|
||||
<div class="container-fluid px-4 py-4">
|
||||
<div class="d-flex flex-column flex-lg-row justify-content-between align-items-lg-center gap-3 mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Exam Draft Submissions</h1>
|
||||
<h1 class="h3 mb-1">Exam draft submissions</h1>
|
||||
<p class="text-muted mb-0 small">
|
||||
<?= esc($semester ?: 'Semester') ?> <?= esc($schoolYear ?: '') ?>
|
||||
</p>
|
||||
@@ -15,13 +34,9 @@
|
||||
</div>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success">
|
||||
<?= esc(session()->getFlashdata('success')) ?>
|
||||
</div>
|
||||
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||
<?php elseif (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger">
|
||||
<?= esc(session()->getFlashdata('error')) ?>
|
||||
</div>
|
||||
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<ul class="nav nav-pills mb-3" id="examDraftTabs" role="tablist">
|
||||
@@ -32,10 +47,11 @@
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="legacy-tab" data-bs-toggle="pill" data-bs-target="#legacy" type="button" role="tab" aria-controls="legacy" aria-selected="false">
|
||||
Legacy Exams
|
||||
Legacy exams
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="examDraftTabsContent">
|
||||
<div class="tab-pane fade show active" id="submissions" role="tabpanel" aria-labelledby="submissions-tab">
|
||||
<div class="card mb-4">
|
||||
@@ -49,13 +65,13 @@
|
||||
<tr>
|
||||
<th>Teacher</th>
|
||||
<th>Class</th>
|
||||
<th>Title / Type</th>
|
||||
<th>Title & AuthorNote</th>
|
||||
<th>Version</th>
|
||||
<th>Submitted</th>
|
||||
<th>Date-Time</th>
|
||||
<th>Status</th>
|
||||
<th>Files</th>
|
||||
<th>PDF</th>
|
||||
<th>Review</th>
|
||||
<th>Reviewer Action</th>
|
||||
<th>Final version</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -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'] ?? '';
|
||||
?>
|
||||
<tr>
|
||||
<td><?= esc($teacherName) ?></td>
|
||||
@@ -76,16 +94,34 @@
|
||||
<td>
|
||||
<strong><?= esc($draft['draft_title'] ?? 'Untitled') ?></strong>
|
||||
<div class="small text-muted"><?= esc($draft['exam_type'] ?? 'N/A') ?></div>
|
||||
<?php if (!empty($draft['description'])): ?>
|
||||
<div class="small text-muted"><?= esc($draft['description']) ?></div>
|
||||
<?php if ($authorNote !== ''): ?>
|
||||
<div class="small mt-1">
|
||||
<span class="text-muted fw-semibold">Author:</span>
|
||||
<?= esc($authorNote) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if ($reviewerNote !== ''): ?>
|
||||
<div class="small mt-1 border-top pt-1">
|
||||
<span class="text-muted fw-semibold">Reviewer:</span>
|
||||
<?= esc($reviewerNote) ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-nowrap">v<?= esc($draft['version'] ?? 1) ?></td>
|
||||
<td class="text-nowrap"><?= esc((!empty($draft['created_at']) ? local_datetime($draft['created_at'], 'M j, Y g:i A') : '—')) ?></td>
|
||||
<td class="text-nowrap">v<?= esc((string) ($draft['version'] ?? 1)) ?></td>
|
||||
<td class="text-nowrap"><?= esc(!empty($draft['created_at']) ? local_datetime($draft['created_at'], 'M j, Y g:i A') : '—') ?></td>
|
||||
<td>
|
||||
<span class="badge <?= esc($statusInfo['class']) ?>">
|
||||
<?= esc($statusInfo['label']) ?>
|
||||
<?php
|
||||
$badgeData = $statusBadges[$st] ?? ['label' => $st, 'class' => 'bg-secondary text-white'];
|
||||
$badgeStyle = !empty($badgeData['style']) ? ' style="' . esc($badgeData['style']) . '"' : '';
|
||||
?>
|
||||
<span class="badge <?= esc($badgeData['class']) ?> js-status-badge" data-status="<?= esc($st) ?>"<?= $badgeStyle ?>>
|
||||
<?= esc($badgeData['label']) ?>
|
||||
</span>
|
||||
<div class="small text-muted mt-1 js-acceptance-note">
|
||||
<?php if ($st === 'accepted' && !empty($draft['acceptance_type'])): ?>
|
||||
<?= $draft['acceptance_type'] === 'minor_edits' ? 'Accepted w/ minor edits' : 'Accepted as is' ?>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if (!empty($draft['reviewed_at'])): ?>
|
||||
<div class="small text-muted mt-1">
|
||||
Reviewed <?= esc(local_datetime($draft['reviewed_at'], 'M j, Y g:i A')) ?>
|
||||
@@ -96,70 +132,92 @@
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php $formId = 'review_form_' . (int) ($draft['id'] ?? 0); ?>
|
||||
<?php $revNumber = max(1, (int) ($draft['version'] ?? 1)); ?>
|
||||
<?php if (!empty($draft['teacher_file'])): ?>
|
||||
<div>
|
||||
<a href="<?= base_url('exam-drafts/files/teacher/' . $draft['teacher_file']) ?>" target="_blank">
|
||||
<?= esc($draft['teacher_filename'] ?? 'Submitted draft') ?>
|
||||
<a href="<?= base_url('exam-drafts/files/teacher/' . $draft['teacher_file']) ?>" target="_blank" rel="noopener">
|
||||
<?= esc('Ver' . $revNumber . ' ' . ($draft['teacher_filename'] ?? 'Submitted draft')) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">No teacher file</span>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($draft['final_file'])): ?>
|
||||
<?php if (!empty($draft['final_file']) && strtolower((string) ($draft['status'] ?? '')) === 'accepted'): ?>
|
||||
<div class="mt-2">
|
||||
<a href="<?= base_url('exam-drafts/files/final/' . $draft['final_file']) ?>" target="_blank" class="link-success small">
|
||||
<?= esc($draft['final_filename'] ?? 'Final draft') ?>
|
||||
<a href="<?= base_url('exam-drafts/files/final/' . $draft['final_file']) ?>" target="_blank" rel="noopener" class="link-success small">
|
||||
<?= esc('Final ' . ($draft['final_filename'] ?? 'Final draft')) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($draft['review_files'])): ?>
|
||||
<div class="mt-2">
|
||||
<?php
|
||||
$reviewLinks = [];
|
||||
foreach ($draft['review_files'] as $rf) {
|
||||
$rev = max(1, (int) ($rf['review_revision'] ?? 1));
|
||||
$name = $rf['final_filename'] ?? 'Review file';
|
||||
$file = $rf['final_file'] ?? '';
|
||||
if ($file !== '') {
|
||||
$reviewLinks[] = '<a href="' . esc(base_url('exam-drafts/files/final/' . $file)) . '" target="_blank" rel="noopener" class="link-success small">' . esc('Ver' . $revNumber . '_' . $rev . ' ' . $name) . '</a>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?= !empty($reviewLinks) ? implode(' | ', $reviewLinks) : '' ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="mt-2">
|
||||
<?php $fileInputId = 'review_file_' . (int) ($draft['id'] ?? 0); ?>
|
||||
<input type="file" name="final_file" id="<?= esc($fileInputId) ?>" class="form-control form-control-sm" accept="<?= esc($fileAccept) ?>" form="<?= esc($formId) ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary mt-2" form="<?= esc($formId) ?>">Upload review file</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
<td style="min-width: 280px;">
|
||||
<?= form_open_multipart(base_url('administrator/exam-drafts/review'), ['class' => 'vstack gap-2', 'id' => $formId]) ?>
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="draft_id" value="<?= (int) ($draft['id'] ?? 0) ?>">
|
||||
<?php $selectedStatus = ''; ?>
|
||||
<select name="review_status" class="form-select form-select-sm" required>
|
||||
<option value="" <?= $selectedStatus === '' ? 'selected' : '' ?> disabled>— Select status —</option>
|
||||
<option value="accepted" <?= $selectedStatus === 'accepted' ? 'selected' : '' ?>>Accepted</option>
|
||||
<option value="legacy" <?= $selectedStatus === 'legacy' ? 'selected' : '' ?>>Legacy</option>
|
||||
<option value="canceled" <?= $selectedStatus === 'canceled' ? 'selected' : '' ?>>Canceled</option>
|
||||
<option value="rejected" <?= $selectedStatus === 'rejected' ? 'selected' : '' ?>>Rejected</option>
|
||||
<option value="review needed" <?= $selectedStatus === 'review needed' ? 'selected' : '' ?>>Review needed</option>
|
||||
<option value="under review" <?= $selectedStatus === 'under review' ? 'selected' : '' ?>>Under review</option>
|
||||
</select>
|
||||
<div class="small js-acceptance-group d-none">
|
||||
<span class="d-block mb-1">As is / Minor edits</span>
|
||||
<?php $acc = (string) ($draft['acceptance_type'] ?? ''); ?>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="acceptance_type" value="as_is" id="as_is_<?= (int) ($draft['id'] ?? 0) ?>" <?= $acc !== 'minor_edits' ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="as_is_<?= (int) ($draft['id'] ?? 0) ?>">As is</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input" type="radio" name="acceptance_type" value="minor_edits" id="minor_<?= (int) ($draft['id'] ?? 0) ?>" <?= $acc === 'minor_edits' ? 'checked' : '' ?>>
|
||||
<label class="form-check-label" for="minor_<?= (int) ($draft['id'] ?? 0) ?>">Minor edits</label>
|
||||
</div>
|
||||
</div>
|
||||
<textarea name="reviewer_comment" class="form-control form-control-sm js-review-comment" rows="3" placeholder="Feedback for the teacher"><?= esc($draft['reviewer_comment'] ?? $draft['admin_comments'] ?? '') ?></textarea>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<span class="text-muted small js-review-status"></span>
|
||||
</div>
|
||||
<?= form_close() ?>
|
||||
</td>
|
||||
<td class="text-nowrap" style="min-width: 220px;">
|
||||
<?php
|
||||
$pdfFile = $draft['final_pdf_file'] ?? null;
|
||||
// Fallback: if final_file itself is a pdf, use it
|
||||
if (empty($pdfFile) && !empty($draft['final_file']) && strtolower(pathinfo($draft['final_file'], PATHINFO_EXTENSION)) === 'pdf') {
|
||||
$pdfFile = $draft['final_file'];
|
||||
}
|
||||
?>
|
||||
<?php if (!empty($pdfFile)): ?>
|
||||
<a href="<?= base_url('exam-drafts/files/final/' . $pdfFile) ?>" target="_blank" class="link-success small">
|
||||
PDF
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">—</span>
|
||||
<div class="mt-2 d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $pdfFile) ?>" target="_blank" rel="noopener">View</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('exam-drafts/files/final/' . $pdfFile) ?>" target="_blank" rel="noopener" download>Download</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<form method="post" action="<?= base_url('/administrator/exam-drafts/review') ?>" enctype="multipart/form-data">
|
||||
<?= csrf_field() ?>
|
||||
<input type="hidden" name="draft_id" value="<?= esc($draft['id']) ?>">
|
||||
<div class="mb-2">
|
||||
<textarea
|
||||
name="admin_comments"
|
||||
rows="2"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Optional note for the teacher"
|
||||
><?= esc($draft['admin_comments'] ?? '') ?></textarea>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="review_status" class="form-select form-select-sm">
|
||||
<?php foreach ($statusOptions as $option): ?>
|
||||
<option value="<?= esc($option) ?>" <?= ($option === ($draft['status'] ?? '')) ? 'selected' : '' ?>>
|
||||
<?= esc(ucfirst($option)) ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Upload final draft</label>
|
||||
<input type="file" name="final_file" class="form-control form-control-sm">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100">
|
||||
Save review
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
@@ -169,40 +227,38 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-pane fade" id="legacy" role="tabpanel" aria-labelledby="legacy-tab">
|
||||
<div class="card border-primary-subtle mb-3">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong>Upload Old / Legacy Exam</strong>
|
||||
<div class="small text-muted">Store historic exams as finalized records.</div>
|
||||
<strong>Upload old / legacy exam</strong>
|
||||
<div class="small text-muted">Store historic exams as accepted records.</div>
|
||||
</div>
|
||||
<span class="badge bg-primary-subtle text-primary">Admin only</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="<?= base_url('/administrator/exam-drafts/upload-legacy') ?>" enctype="multipart/form-data" class="row g-3">
|
||||
<?= form_open_multipart(base_url('administrator/exam-drafts/upload-legacy'), ['class' => 'row g-3']) ?>
|
||||
<?= csrf_field() ?>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Class Sections</label>
|
||||
<select name="class_section_ids[]" class="form-select" multiple required>
|
||||
<label class="form-label small">Class sections</label>
|
||||
<select name="class_section_ids[]" class="form-select" multiple required size="6">
|
||||
<?php foreach ($classSections as $cs): ?>
|
||||
<option value="<?= esc($cs['class_section_id']) ?>"><?= esc($cs['class_section_name']) ?></option>
|
||||
<option value="<?= esc($cs['class_section_id'] ?? '') ?>"><?= esc($cs['class_section_name'] ?? '') ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
<div class="form-text">Hold Ctrl (Windows) or Command (Mac) to select multiple sections.</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">School Year</label>
|
||||
<label class="form-label small">School year</label>
|
||||
<input type="text" name="school_year" class="form-control" value="<?= esc($schoolYear) ?>" placeholder="2025-2026" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Semester</label>
|
||||
<select name="semester" class="form-select" required>
|
||||
<option value="Fall" <?= ($semester === 'Fall') ? 'selected' : '' ?>>Fall</option>
|
||||
<option value="Spring" <?= ($semester === 'Spring') ? 'selected' : '' ?>>Spring</option>
|
||||
</select>
|
||||
<input type="text" name="semester" class="form-control" value="<?= esc($semester) ?>" placeholder="Fall / Spring" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Exam Type</label>
|
||||
<label class="form-label small">Exam type</label>
|
||||
<select name="exam_type" class="form-select">
|
||||
<option value="">— Select type —</option>
|
||||
<?php foreach ($examTypes as $type): ?>
|
||||
@@ -211,14 +267,14 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small">Upload File</label>
|
||||
<input type="file" name="old_exam_file" class="form-control" accept=".doc,.docx,.pdf" required>
|
||||
<label class="form-label small">Upload file</label>
|
||||
<input type="file" name="old_exam_file" class="form-control" accept="<?= esc($fileAccept) ?>" required>
|
||||
<div class="form-text">Allowed: <?= esc(implode(', ', $allowedExtensions)) ?> • Max <?= number_format($maxUploadBytes / 1024 / 1024, 0) ?> MB</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Upload Legacy Exam</button>
|
||||
<button type="submit" class="btn btn-primary">Upload legacy exam</button>
|
||||
</div>
|
||||
</form>
|
||||
<?= form_close() ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -231,20 +287,21 @@
|
||||
<div class="mb-4">
|
||||
<h6 class="mb-2"><?= esc($group['class_section_name'] ?? 'Class') ?></h6>
|
||||
<div class="list-group">
|
||||
<?php foreach ($group['items'] as $item): ?>
|
||||
<div class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<?php foreach ($group['items'] ?? [] as $item): ?>
|
||||
<?php $lst = strtolower((string) ($item['status'] ?? '')); ?>
|
||||
<div class="list-group-item d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||
<div class="me-3">
|
||||
<div class="fw-semibold"><?= esc($item['draft_title'] ?? 'Legacy Exam') ?></div>
|
||||
<div class="fw-semibold"><?= esc($item['draft_title'] ?? 'Legacy exam') ?></div>
|
||||
<div class="small text-muted">
|
||||
<?= esc($item['exam_type'] ?? 'N/A') ?> •
|
||||
<?= esc($item['semester'] ?? '') ?> <?= esc($item['school_year'] ?? '') ?>
|
||||
</div>
|
||||
<div class="small mt-1"><?= $renderBadge($lst, $statusBadges) ?></div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="text-end d-flex flex-wrap gap-2 justify-content-end">
|
||||
<?php if (!empty($item['final_file'])): ?>
|
||||
<a href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" class="btn btn-sm btn-outline-primary">
|
||||
Download
|
||||
</a>
|
||||
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" rel="noopener">View</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="<?= base_url('exam-drafts/files/final/' . $item['final_file']) ?>" target="_blank" rel="noopener" download>Download</a>
|
||||
<?php else: ?>
|
||||
<span class="text-muted small">File missing</span>
|
||||
<?php endif; ?>
|
||||
@@ -260,6 +317,175 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const statusBadges = <?= json_encode($statusBadges, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
const csrfTokenName = <?= json_encode(csrf_token(), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
const csrfCookieNames = <?= json_encode(array_values(array_filter([
|
||||
config('Security')->csrfCookieName ?? null,
|
||||
config('Security')->cookieName ?? null,
|
||||
'csrf_cookie_name',
|
||||
])), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
|
||||
const readCookie = (name) => {
|
||||
const match = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/[$()*+.?[\\\]^{|}-]/g, '\\$&') + '=([^;]*)'));
|
||||
return match ? decodeURIComponent(match[1]) : '';
|
||||
};
|
||||
|
||||
const syncCsrfToken = (form) => {
|
||||
let tokenValue = '';
|
||||
csrfCookieNames.some((name) => {
|
||||
tokenValue = readCookie(name);
|
||||
return tokenValue !== '';
|
||||
});
|
||||
if (!tokenValue && form) {
|
||||
const tokenInput = form.querySelector(`input[name="${csrfTokenName}"]`);
|
||||
if (tokenInput && tokenInput.value) {
|
||||
tokenValue = tokenInput.value;
|
||||
}
|
||||
}
|
||||
if (!tokenValue || !form) {
|
||||
return tokenValue;
|
||||
}
|
||||
const tokenInput = form.querySelector(`input[name="${csrfTokenName}"]`);
|
||||
if (tokenInput) {
|
||||
tokenInput.value = tokenValue;
|
||||
}
|
||||
return tokenValue;
|
||||
};
|
||||
const updateRow = (row) => {
|
||||
const statusSelect = row.querySelector('select[name="review_status"]');
|
||||
const acceptanceGroup = row.querySelector('.js-acceptance-group');
|
||||
if (!statusSelect || !acceptanceGroup) {
|
||||
return;
|
||||
}
|
||||
acceptanceGroup.classList.toggle('d-none', statusSelect.value !== 'accepted');
|
||||
};
|
||||
|
||||
const updateStatusUI = (row) => {
|
||||
const statusSelect = row.querySelector('select[name="review_status"]');
|
||||
const badge = row.querySelector('.js-status-badge');
|
||||
if (!statusSelect || !badge || statusSelect.value === '') {
|
||||
return;
|
||||
}
|
||||
const status = statusSelect.value;
|
||||
const badgeData = statusBadges[status] || { label: status, class: 'bg-secondary text-white' };
|
||||
badge.textContent = badgeData.label || status;
|
||||
badge.className = `badge ${badgeData.class} js-status-badge`;
|
||||
if (badgeData.style) {
|
||||
badge.setAttribute('style', badgeData.style);
|
||||
} else {
|
||||
badge.removeAttribute('style');
|
||||
}
|
||||
badge.dataset.status = status;
|
||||
|
||||
const acceptanceNote = row.querySelector('.js-acceptance-note');
|
||||
if (acceptanceNote) {
|
||||
if (status === 'accepted') {
|
||||
const acc = row.querySelector('input[name="acceptance_type"]:checked');
|
||||
acceptanceNote.textContent = acc && acc.value === 'minor_edits'
|
||||
? 'Accepted w/ minor edits'
|
||||
: 'Accepted as is';
|
||||
} else {
|
||||
acceptanceNote.textContent = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const saveRow = (row) => {
|
||||
const form = row.querySelector('form');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
const statusSelect = form.querySelector('select[name="review_status"]');
|
||||
if (!statusSelect || statusSelect.value === '') {
|
||||
return;
|
||||
}
|
||||
const statusLabel = form.querySelector('.js-review-status');
|
||||
const data = new FormData(form);
|
||||
data.delete('final_file');
|
||||
data.delete('old_exam_file');
|
||||
const csrfToken = syncCsrfToken(form);
|
||||
if (csrfToken) {
|
||||
data.set(csrfTokenName, csrfToken);
|
||||
}
|
||||
if (statusLabel) {
|
||||
statusLabel.textContent = 'Saving...';
|
||||
}
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
},
|
||||
})
|
||||
.then((resp) => {
|
||||
if (!resp.ok) {
|
||||
throw new Error('Save failed');
|
||||
}
|
||||
syncCsrfToken(form);
|
||||
updateStatusUI(row);
|
||||
if (statusLabel) {
|
||||
statusLabel.textContent = 'Saved';
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (statusLabel && statusLabel.textContent === 'Saved') {
|
||||
statusLabel.textContent = '';
|
||||
}
|
||||
}, 1500);
|
||||
})
|
||||
.catch(() => {
|
||||
if (statusLabel) {
|
||||
statusLabel.textContent = 'Error saving';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const debounce = (fn, wait) => {
|
||||
let timer;
|
||||
return (...args) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), wait);
|
||||
};
|
||||
};
|
||||
|
||||
const debouncedSave = debounce(saveRow, 500);
|
||||
|
||||
document.querySelectorAll('.exam-drafts-table tbody tr').forEach(updateRow);
|
||||
|
||||
document.addEventListener('change', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
const row = target.closest('tr');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
if (target instanceof HTMLSelectElement && target.name === 'review_status') {
|
||||
updateRow(row);
|
||||
debouncedSave(row);
|
||||
return;
|
||||
}
|
||||
if (target instanceof HTMLInputElement && target.name === 'acceptance_type') {
|
||||
debouncedSave(row);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('input', (event) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLTextAreaElement) || target.name !== 'reviewer_comment') {
|
||||
return;
|
||||
}
|
||||
const row = target.closest('tr');
|
||||
if (row) {
|
||||
debouncedSave(row);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<?= $this->section('styles') ?>
|
||||
|
||||
+210
-13
@@ -1,34 +1,231 @@
|
||||
<?= $this->extend('layout/main_layout') ?>
|
||||
<?= $this->section('content') ?>
|
||||
<?php
|
||||
$statusBadges = $statusBadges ?? [];
|
||||
$drafts = $drafts ?? [];
|
||||
$legacyExams = $legacyExams ?? [];
|
||||
$assignments = $assignments ?? [];
|
||||
$selectedClassSection = $selectedClassSection ?? 0;
|
||||
$examTypes = $examTypes ?? [];
|
||||
$schoolYear = $schoolYear ?? '';
|
||||
$semester = $semester ?? '';
|
||||
$maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
|
||||
?>
|
||||
<div class="container-xxl py-5">
|
||||
<div class="container">
|
||||
<h1>Drafts</h1>
|
||||
<?php if (!empty($draftMessages)): ?>
|
||||
<h1 class="mb-4">Exam drafts</h1>
|
||||
|
||||
<?php if (session()->getFlashdata('success')): ?>
|
||||
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
||||
<?php endif; ?>
|
||||
<?php if (session()->getFlashdata('error')): ?>
|
||||
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<?= form_open_multipart(base_url('teacher/exam-drafts'), ['class' => 'row g-3']) ?>
|
||||
<?= csrf_field() ?>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label" for="exam_type">Exam type <span class="text-danger">*</span></label>
|
||||
<select name="exam_type" id="exam_type" class="form-select" required>
|
||||
<option value="">— Select —</option>
|
||||
<?php foreach ($examTypes as $t): ?>
|
||||
<option value="<?= esc($t) ?>" <?= old('exam_type') === $t ? 'selected' : '' ?>><?= esc($t) ?></option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<label class="form-label" for="author_comment">Author comment</label>
|
||||
<textarea name="author_comment" id="author_comment" class="form-control" rows="2" placeholder="Optional note for reviewers"><?= esc(old('author_comment') ?? '') ?></textarea>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<label class="form-label" for="draft_file">File (Word) <span class="text-danger">*</span></label>
|
||||
<input type="file" name="draft_file" id="draft_file" class="form-control" accept=".doc,.docx" required>
|
||||
<div class="form-text">Max <?= esc(number_format($maxUploadBytes / 1048576, 1)) ?> MB.</div>
|
||||
</div>
|
||||
<div class="col-12 d-flex flex-wrap gap-2">
|
||||
<button type="submit" class="btn btn-primary">Submit for review</button>
|
||||
</div>
|
||||
<?= form_close() ?>
|
||||
<?php if ($schoolYear !== '' || $semester !== ''): ?>
|
||||
<p class="text-muted small mb-0 mt-2">School year: <?= esc($schoolYear) ?> · Semester: <?= esc($semester) ?></p>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php
|
||||
$renderBadge = static function (string $status, array $badges): string {
|
||||
$b = $badges[$status] ?? ['label' => $status, 'class' => 'bg-secondary text-white'];
|
||||
$style = !empty($b['style']) ? ' style="' . esc($b['style']) . '"' : '';
|
||||
return '<span class="badge ' . esc($b['class']) . ' js-status-badge" data-status="' . esc($status) . '"' . $style . '>' . esc($b['label']) . '</span>';
|
||||
};
|
||||
?>
|
||||
|
||||
<h2 class="h5 mb-3">Your submissions</h2>
|
||||
<?php if (!empty($drafts)): ?>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<table class="table table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Subject</th>
|
||||
<th>Date</th>
|
||||
<th>Actions</th>
|
||||
<th>Class</th>
|
||||
<th>Title / type</th>
|
||||
<th>Ver.</th>
|
||||
<th>Status</th>
|
||||
<th>File link</th>
|
||||
<th>Reviewer comment</th>
|
||||
<th>Last Update</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($draftMessages as $message): ?>
|
||||
<tr>
|
||||
<td><?= esc($message['subject']) ?></td>
|
||||
<td><?= esc(!empty($message['created_at']) ? local_datetime($message['created_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
<?php foreach ($drafts as $d): ?>
|
||||
<?php
|
||||
$st = strtolower((string) ($d['status'] ?? ''));
|
||||
$badgeHtml = $renderBadge($st, $statusBadges);
|
||||
?>
|
||||
<tr data-draft-id="<?= (int) ($d['id'] ?? 0) ?>">
|
||||
<td><?= esc($d['class_section_name'] ?? '') ?></td>
|
||||
<td><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
|
||||
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
|
||||
<td><?= $badgeHtml ?></td>
|
||||
<td>
|
||||
<a href="<?= base_url('messages/edit/' . $message['id']) ?>">Edit</a> |
|
||||
<a href="<?= base_url('messages/delete/' . $message['id']) ?>">Delete</a>
|
||||
<?php $revNumber = max(1, (int) ($d['version'] ?? 1)); ?>
|
||||
<?php if (!empty($d['teacher_file'])): ?>
|
||||
<div>
|
||||
<a href="<?= base_url('exam-drafts/files/teacher/' . $d['teacher_file']) ?>" target="_blank" rel="noopener">
|
||||
<?= esc('Ver' . $revNumber . ' ' . ($d['teacher_filename'] ?? 'Submitted draft')) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($d['final_file'])): ?>
|
||||
<div class="mt-1">
|
||||
<a href="<?= base_url('exam-drafts/files/final/' . $d['final_file']) ?>" target="_blank" rel="noopener" class="link-success small">
|
||||
<?= esc('Final ' . ($d['final_filename'] ?? 'Final draft')) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (!empty($d['review_files'])): ?>
|
||||
<div class="mt-1">
|
||||
<?php
|
||||
$reviewLinks = [];
|
||||
foreach ($d['review_files'] as $rf) {
|
||||
$rev = max(1, (int) ($rf['review_revision'] ?? 1));
|
||||
$name = $rf['final_filename'] ?? 'Review file';
|
||||
$file = $rf['final_file'] ?? '';
|
||||
if ($file !== '') {
|
||||
$reviewLinks[] = '<a href="' . esc(base_url('exam-drafts/files/final/' . $file)) . '" target="_blank" rel="noopener" class="link-success small">' . esc('Ver' . $revNumber . '_' . $rev . ' ' . $name) . '</a>';
|
||||
}
|
||||
}
|
||||
?>
|
||||
<?= !empty($reviewLinks) ? implode(' | ', $reviewLinks) : '' ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (empty($d['teacher_file']) && empty($d['final_file'])): ?>
|
||||
—
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-nowrap"><?= esc($d['reviewer_comment'] ?? $d['admin_comments'] ?? '—') ?></td>
|
||||
<td><?= esc(!empty($d['updated_at']) ? local_datetime($d['updated_at'], 'm-d-Y H:i') : '') ?></td>
|
||||
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php else: ?>
|
||||
<p>No drafts found.</p>
|
||||
<p class="text-muted">No submissions yet.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($legacyExams)): ?>
|
||||
<h2 class="h5 mt-5 mb-3">Previous exams (legacy)</h2>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Class</th>
|
||||
<th>Title</th>
|
||||
<th>Ver.</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($legacyExams as $d): ?>
|
||||
<?php $st = strtolower((string) ($d['status'] ?? '')); ?>
|
||||
<tr>
|
||||
<td><?= esc($d['class_section_name'] ?? '') ?></td>
|
||||
<td><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
|
||||
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
|
||||
<td><?= $renderBadge($st, $statusBadges) ?></td>
|
||||
<td>
|
||||
<?php if (!empty($d['is_printable'])): ?>
|
||||
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('teacher/exam-drafts/print/' . (int) ($d['id'] ?? 0)) ?>" target="_blank" rel="noopener">Print</a>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?= $this->endSection() ?>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const statusBadges = <?= json_encode($statusBadges, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP | JSON_HEX_QUOT) ?>;
|
||||
const rows = new Map();
|
||||
document.querySelectorAll('tr[data-draft-id]').forEach((row) => {
|
||||
const id = row.getAttribute('data-draft-id');
|
||||
if (id) {
|
||||
rows.set(id, row);
|
||||
}
|
||||
});
|
||||
|
||||
const updateRow = (row, status, acceptanceType) => {
|
||||
const badge = row.querySelector('.js-status-badge');
|
||||
if (badge) {
|
||||
const badgeData = statusBadges[status] || { label: status, class: 'bg-secondary text-white' };
|
||||
badge.textContent = badgeData.label || status;
|
||||
badge.className = `badge ${badgeData.class} js-status-badge`;
|
||||
if (badgeData.style) {
|
||||
badge.setAttribute('style', badgeData.style);
|
||||
} else {
|
||||
badge.removeAttribute('style');
|
||||
}
|
||||
badge.dataset.status = status;
|
||||
}
|
||||
const note = row.querySelector('.js-acceptance-note');
|
||||
if (note) {
|
||||
if (status === 'accepted' && acceptanceType) {
|
||||
note.textContent = acceptanceType === 'minor_edits' ? 'With minor edits' : 'As is';
|
||||
} else {
|
||||
note.textContent = '—';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const poll = () => {
|
||||
fetch('<?= base_url('teacher/exam-drafts/status') ?>', { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
|
||||
.then((resp) => resp.ok ? resp.json() : null)
|
||||
.then((data) => {
|
||||
if (!data || !Array.isArray(data.drafts)) {
|
||||
return;
|
||||
}
|
||||
data.drafts.forEach((draft) => {
|
||||
const row = rows.get(String(draft.id));
|
||||
if (row) {
|
||||
updateRow(row, draft.status, draft.acceptance_type);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
if (rows.size > 0) {
|
||||
poll();
|
||||
setInterval(poll, 15000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user