exam draft notification and submission

This commit is contained in:
root
2026-04-09 01:50:24 -04:00
parent 112d073235
commit 2543df3d33
4 changed files with 262 additions and 40 deletions
+126 -10
View File
@@ -26,6 +26,9 @@ class ExamDraftController extends BaseController
protected bool $hasAcceptanceTypeColumn = false;
protected bool $hasAdminIdColumn = false;
protected bool $hasReviewRevisionColumn = false;
protected bool $hasReviewerCommentColumn = false;
protected bool $hasReviewerCommentsColumn = false;
protected bool $hasAdminCommentsColumn = false;
protected string $authorIdColumn = 'teacher_id';
protected string $authorFileColumn = 'teacher_file';
protected string $authorFilenameColumn = 'teacher_filename';
@@ -65,6 +68,9 @@ class ExamDraftController extends BaseController
$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->hasReviewerCommentColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comment');
$this->hasReviewerCommentsColumn = $this->schemaHasColumn('exam_drafts', 'reviewer_comments');
$this->hasAdminCommentsColumn = $this->schemaHasColumn('exam_drafts', 'admin_comments');
$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';
@@ -73,7 +79,9 @@ class ExamDraftController extends BaseController
: ($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' : '');
: ($this->schemaHasColumn('exam_drafts', 'reviewer_comments')
? 'reviewer_comments'
: ($this->schemaHasColumn('exam_drafts', 'admin_comments') ? 'admin_comments' : ''));
helper(['form', 'url', 'date']);
}
@@ -116,19 +124,22 @@ class ExamDraftController extends BaseController
// Keep all submissions visible; legacy ones are also surfaced in a separate tab
$drafts = $allDrafts;
if (!empty($classSectionIds)) {
$legacyExams = $this->examDraftModel
$legacyQuery = $this->examDraftModel
->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)
->where('exam_drafts.final_file IS NOT NULL', null, false);
if (!empty($classSectionIds)) {
$legacyQuery = $legacyQuery->whereIn('exam_drafts.class_section_id', $classSectionIds);
}
$legacyExams = $legacyQuery
->orderBy('cs.class_section_name', 'ASC')
->orderBy('exam_drafts.created_at', 'DESC')
->findAll();
}
} else {
// Legacy column absent, show all drafts and skip legacy tab query
$drafts = $allDrafts;
@@ -285,6 +296,7 @@ class ExamDraftController extends BaseController
$this->applyAuthorFilePayload($updateSubmit, $teacherFile, $teacherFilename);
if ($this->examDraftModel->update($draftRowId, $updateSubmit)) {
$saved = $this->ensureDraftStatus($draftRowId, 'submitted');
$this->notifyPrincipalExamDraftSubmitted($saved, $teacherId, $classSectionId);
$this->notifyExamDraftEvent($saved, 'submitted');
return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.');
}
@@ -319,6 +331,7 @@ class ExamDraftController extends BaseController
if ($this->examDraftModel->insert($payload)) {
$newId = (int) $this->examDraftModel->getInsertID();
$saved = $newId > 0 ? $this->ensureDraftStatus($newId, 'submitted') : null;
$this->notifyPrincipalExamDraftSubmitted($saved, $teacherId, $classSectionId);
$this->notifyExamDraftEvent($saved, 'submitted');
return redirect()->to('/teacher/exam-drafts')->with('success', 'Exam draft submitted for review.');
}
@@ -556,8 +569,16 @@ class ExamDraftController extends BaseController
'status' => $status,
'reviewed_at' => utc_now(),
];
if ($this->reviewerCommentColumn !== '') {
$update[$this->reviewerCommentColumn] = $reviewerComment === '' ? null : $reviewerComment;
$existingComment = (string) ($draft['reviewer_comment'] ?? $draft['reviewer_comments'] ?? $draft['admin_comments'] ?? '');
$commentValue = $reviewerComment === '' ? null : $this->appendReviewerComment($existingComment, $reviewerComment);
if ($this->hasReviewerCommentColumn) {
$update['reviewer_comment'] = $commentValue;
}
if ($this->hasReviewerCommentsColumn) {
$update['reviewer_comments'] = $commentValue;
}
if ($this->hasAdminCommentsColumn) {
$update['admin_comments'] = $commentValue;
}
if ($this->reviewerIdColumn !== '') {
$update[$this->reviewerIdColumn] = (int) (session()->get('user_id') ?? 0);
@@ -589,8 +610,14 @@ class ExamDraftController extends BaseController
if ($this->hasReviewRevisionColumn) {
$newRow['review_revision'] = $reviewRevision;
}
if ($this->reviewerCommentColumn !== '') {
$newRow[$this->reviewerCommentColumn] = $update[$this->reviewerCommentColumn] ?? null;
if ($this->hasReviewerCommentColumn) {
$newRow['reviewer_comment'] = $commentValue;
}
if ($this->hasReviewerCommentsColumn) {
$newRow['reviewer_comments'] = $commentValue;
}
if ($this->hasAdminCommentsColumn) {
$newRow['admin_comments'] = $commentValue;
}
if ($this->reviewerIdColumn !== '') {
$newRow[$this->reviewerIdColumn] = $update[$this->reviewerIdColumn] ?? null;
@@ -831,6 +858,46 @@ class ExamDraftController extends BaseController
return $saved;
}
private function notifyPrincipalExamDraftSubmitted(?array $draft, int $teacherId, int $classSectionId): void
{
$principalEmail = trim((string) (env('PRINCIPAL_EMAIL') ?: ''));
if ($principalEmail === '' || !filter_var($principalEmail, FILTER_VALIDATE_EMAIL)) {
return;
}
if (empty($draft)) {
return;
}
try {
$teacher = $this->userModel->find($teacherId) ?? [];
$teacherName = trim(($teacher['firstname'] ?? '') . ' ' . ($teacher['lastname'] ?? ''));
if ($teacherName === '') {
$teacherName = 'Teacher #' . $teacherId;
}
$class = $this->classSectionModel->find($classSectionId) ?? [];
$className = $class['class_section_name'] ?? ('Class ' . $classSectionId);
$subject = 'Exam draft submitted: ' . $className;
$submittedAt = $draft['created_at'] ?? $draft['updated_at'] ?? '';
$body = '<p>A teacher has submitted a new exam draft.</p>'
. '<table style="border-collapse:collapse;">'
. '<tr><td style="padding:4px 8px;"><strong>Teacher</strong></td><td style="padding:4px 8px;">' . esc($teacherName) . '</td></tr>'
. '<tr><td style="padding:4px 8px;"><strong>Class</strong></td><td style="padding:4px 8px;">' . esc($className) . '</td></tr>'
. '<tr><td style="padding:4px 8px;"><strong>Exam type</strong></td><td style="padding:4px 8px;">' . esc($draft['exam_type'] ?? 'N/A') . '</td></tr>'
. '<tr><td style="padding:4px 8px;"><strong>Version</strong></td><td style="padding:4px 8px;">' . esc((string) ($draft['version'] ?? '')) . '</td></tr>'
. '<tr><td style="padding:4px 8px;"><strong>Status</strong></td><td style="padding:4px 8px;">' . esc((string) ($draft['status'] ?? 'submitted')) . '</td></tr>'
. '<tr><td style="padding:4px 8px;"><strong>Submitted at</strong></td><td style="padding:4px 8px;">' . esc((string) $submittedAt) . '</td></tr>'
. '</table>'
. '<p>Review drafts: <a href="' . esc(base_url('administrator/exam-drafts')) . '">Admin exam drafts</a></p>';
$mailer = \Config\Services::emailService();
$mailer->send($principalEmail, $subject, $body, 'notifications');
} catch (\Throwable $e) {
log_message('error', 'Failed to send exam draft notification: ' . $e->getMessage());
}
}
private function nextDraftVersion(array $draft): int
{
$query = $this->examDraftModel
@@ -887,6 +954,36 @@ class ExamDraftController extends BaseController
return $current + 1;
}
private function appendReviewerComment(string $existing, string $incoming): string
{
$existing = trim($existing);
$incoming = trim($incoming);
if ($incoming === '') {
return $existing;
}
if ($existing !== '' && str_starts_with($incoming, $existing)) {
$remainder = trim(substr($incoming, strlen($existing)));
if ($remainder === '') {
return $existing;
}
$incoming = $remainder;
}
$lines = $existing === '' ? [] : preg_split('/\R/', $existing);
$lastLine = $lines ? trim((string) end($lines)) : '';
if ($lastLine !== '') {
$lastBody = preg_replace('/^\d+\-\s*/', '', $lastLine);
if ($lastBody !== null && trim($lastBody) === $incoming) {
return $existing;
}
}
$nextIndex = count($lines) + 1;
$incoming = trim(preg_replace('/\R+/', ' ', $incoming) ?? $incoming);
$lines[] = $nextIndex . '- ' . $incoming;
return implode("\n", $lines);
}
private function statusOptions(): array
{
return ['submitted', 'accepted', 'review needed', 'rejected', 'canceled', 'under review', 'legacy'];
@@ -1001,6 +1098,7 @@ class ExamDraftController extends BaseController
$grouped[$key]['_is_review_row'] = true;
$grouped[$key]['review_files'] = [];
$grouped[$key]['final_version'] = null;
$grouped[$key]['_latest_review_comment'] = null;
}
$grouped[$key]['review_files'][] = [
'review_revision' => $reviewRev,
@@ -1008,6 +1106,16 @@ class ExamDraftController extends BaseController
'final_filename' => $row['final_filename'] ?? null,
'status' => $row['status'] ?? null,
];
$reviewComment = $row['reviewer_comment'] ?? $row['admin_comments'] ?? null;
if ($reviewComment !== null && $reviewComment !== '') {
$latestComment = $grouped[$key]['_latest_review_comment'] ?? null;
if ($latestComment === null || $reviewRev > (int) ($latestComment['review_revision'] ?? 0)) {
$grouped[$key]['_latest_review_comment'] = [
'review_revision' => $reviewRev,
'comment' => $reviewComment,
];
}
}
if (strtolower((string) ($row['status'] ?? '')) === 'accepted') {
$currentFinal = $grouped[$key]['final_version'] ?? null;
if ($currentFinal === null || $reviewRev > (int) ($currentFinal['review_revision'] ?? 0)) {
@@ -1029,8 +1137,12 @@ class ExamDraftController extends BaseController
if (!empty($grouped[$key]['_is_review_row'])) {
$reviewFiles = $grouped[$key]['review_files'] ?? [];
$latestReviewComment = $grouped[$key]['_latest_review_comment'] ?? null;
$grouped[$key] = $row;
$grouped[$key]['review_files'] = $reviewFiles;
if (!empty($latestReviewComment['comment'])) {
$grouped[$key]['reviewer_comment'] = $latestReviewComment['comment'];
}
unset($grouped[$key]['_is_review_row']);
}
}
@@ -1043,7 +1155,11 @@ class ExamDraftController extends BaseController
$row['final_file'] = $row['final_version']['final_file'] ?? $row['final_file'] ?? null;
$row['final_filename'] = $row['final_version']['final_filename'] ?? $row['final_filename'] ?? null;
}
if (empty($row['reviewer_comment']) && !empty($row['_latest_review_comment']['comment'])) {
$row['reviewer_comment'] = $row['_latest_review_comment']['comment'];
}
unset($row['_is_review_row']);
unset($row['_latest_review_comment']);
}
unset($row);
+1
View File
@@ -52,6 +52,7 @@ class ExamDraftModel extends Model
'admin_id',
'is_legacy',
'reviewer_comment',
'reviewer_comments',
'admin_comments',
'reviewed_at',
'final_file',
+36 -7
View File
@@ -198,7 +198,7 @@ $fileAccept = implode(',', array_map(static fn ($x) => '.' . $x, $allowedExtensi
<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>
<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['reviewer_comments'] ?? $draft['admin_comments'] ?? '') ?></textarea>
<div class="d-flex flex-wrap gap-2">
<span class="text-muted small js-review-status"></span>
</div>
@@ -392,19 +392,27 @@ document.addEventListener('DOMContentLoaded', () => {
}
};
const saveRow = (row) => {
const saveRow = (row, options = {}) => {
const form = row.querySelector('form');
if (!form) {
return;
}
const statusSelect = form.querySelector('select[name="review_status"]');
if (!statusSelect || statusSelect.value === '') {
if (!statusSelect) {
return;
}
const statusLabel = form.querySelector('.js-review-status');
const data = new FormData(form);
data.delete('final_file');
data.delete('old_exam_file');
if (options.commitComment) {
const commentInput = form.querySelector('textarea[name="reviewer_comment"]');
if (commentInput) {
const raw = commentInput.value || '';
const committed = raw.endsWith('\n') ? raw : raw + '\n';
data.set('reviewer_comment', committed);
}
}
const csrfToken = syncCsrfToken(form);
if (csrfToken) {
data.set(csrfTokenName, csrfToken);
@@ -480,10 +488,29 @@ document.addEventListener('DOMContentLoaded', () => {
return;
}
const row = target.closest('tr');
if (row) {
debouncedSave(row);
if (row && target.value.endsWith('\n')) {
const lastCommitted = target.dataset.lastCommitted || '';
if (target.value !== lastCommitted) {
target.dataset.lastCommitted = target.value;
debouncedSave(row, { commitComment: true });
}
}
});
document.addEventListener('blur', (event) => {
const target = event.target;
if (!(target instanceof HTMLTextAreaElement) || target.name !== 'reviewer_comment') {
return;
}
const row = target.closest('tr');
if (row) {
const lastCommitted = target.dataset.lastCommitted || '';
if (target.value !== lastCommitted) {
target.dataset.lastCommitted = target.value;
debouncedSave(row, { commitComment: true });
}
}
}, true);
});
</script>
<?= $this->endSection() ?>
@@ -496,13 +523,15 @@ document.addEventListener('DOMContentLoaded', () => {
word-break: break-word;
}
.exam-drafts-table th {
min-width: 120px;
min-width: 0;
white-space: nowrap;
}
.exam-drafts-table td {
max-width: 220px;
max-width: none;
}
.exam-drafts-table {
table-layout: auto;
width: max-content;
}
.exam-drafts-table thead th {
background-color: #f8f9fa;
+88 -12
View File
@@ -11,9 +11,36 @@ $schoolYear = $schoolYear ?? '';
$semester = $semester ?? '';
$maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
?>
<div class="container-xxl py-5">
<style>
.teacher-drafts-page,
.teacher-drafts-page * {
font-family: Arial, sans-serif !important;
}
.teacher-drafts-table {
table-layout: auto;
width: max-content;
}
.teacher-drafts-table th {
min-width: 0;
}
.teacher-drafts-table td {
max-width: none;
}
.teacher-drafts-table th {
white-space: nowrap;
}
.teacher-drafts-table td.title-cell {
white-space: nowrap;
}
</style>
<div class="container-xxl py-5 teacher-drafts-page">
<div class="container">
<h1 class="mb-4">Exam drafts</h1>
<div class="d-flex flex-wrap justify-content-between align-items-center mb-4">
<h1 class="mb-0">Exam drafts</h1>
<?php if (!empty($legacyExams)): ?>
<button type="button" class="btn btn-sm btn-outline-secondary" id="legacyToggle">Legacy Exams</button>
<?php endif; ?>
</div>
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
@@ -24,7 +51,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
<div class="card mb-4">
<div class="card-body">
<?= form_open_multipart(base_url('teacher/exam-drafts'), ['class' => 'row g-3']) ?>
<?= form_open_multipart(base_url('teacher/exam-drafts'), ['class' => 'row g-3', 'id' => 'examDraftForm']) ?>
<?= csrf_field() ?>
<div class="col-md-6">
<label class="form-label" for="exam_type">Exam type <span class="text-danger">*</span></label>
@@ -65,7 +92,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
<h2 class="h5 mb-3">Your submissions</h2>
<?php if (!empty($drafts)): ?>
<div class="table-responsive">
<table class="table table-striped align-middle">
<table class="table table-striped align-middle teacher-drafts-table">
<thead>
<tr>
<th>Class</th>
@@ -85,7 +112,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
?>
<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 class="title-cell"><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
<td><?= $badgeHtml ?></td>
<td>
@@ -124,7 +151,7 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
<?php endif; ?>
</td>
<td class="text-nowrap"><?= esc($d['reviewer_comment'] ?? $d['admin_comments'] ?? '—') ?></td>
<td><?= nl2br(esc($d['reviewer_comment'] ?? $d['reviewer_comments'] ?? $d['admin_comments'] ?? '—')) ?></td>
<td><?= esc(!empty($d['updated_at']) ? local_datetime($d['updated_at'], 'm-d-Y H:i') : '') ?></td>
</tr>
@@ -137,15 +164,18 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
<?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">
<div id="legacySection" class="table-responsive d-none mt-4">
<table class="table table-sm table-striped align-middle teacher-drafts-table">
<thead>
<tr>
<th>Class</th>
<th>Title</th>
<th>Ver.</th>
<th>Status</th>
<th>School year</th>
<th>Semester</th>
<th>Exam type</th>
<th>Updated</th>
<th>Actions</th>
</tr>
</thead>
@@ -154,12 +184,16 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
<?php $st = strtolower((string) ($d['status'] ?? '')); ?>
<tr>
<td><?= esc($d['class_section_name'] ?? '') ?></td>
<td><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
<td class="title-cell"><?= esc($d['draft_title'] ?? $d['exam_type'] ?? '') ?></td>
<td><?= esc((string) ($d['version'] ?? '')) ?></td>
<td><?= $renderBadge($st, $statusBadges) ?></td>
<td><?= esc($d['school_year'] ?? '') ?></td>
<td><?= esc($d['semester'] ?? '') ?></td>
<td><?= esc($d['exam_type'] ?? '') ?></td>
<td><?= esc(!empty($d['updated_at']) ? local_datetime($d['updated_at'], 'm-d-Y H:i') : '') ?></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 if (!empty($d['final_file'])): ?>
<a class="btn btn-sm btn-outline-primary" href="<?= base_url('exam-drafts/files/final/' . $d['final_file']) ?>" target="_blank" rel="noopener">View</a>
<?php endif; ?>
</td>
</tr>
@@ -172,8 +206,41 @@ $maxUploadBytes = $maxUploadBytes ?? (12 * 1024 * 1024);
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
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) {
return;
}
const tokenInput = form.querySelector(`input[name="${csrfTokenName}"]`);
if (tokenInput) {
tokenInput.value = tokenValue;
}
};
const draftForm = document.getElementById('examDraftForm');
if (draftForm) {
draftForm.addEventListener('submit', () => syncCsrfToken(draftForm));
}
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) => {
@@ -227,5 +294,14 @@ document.addEventListener('DOMContentLoaded', () => {
poll();
setInterval(poll, 15000);
}
const legacyToggle = document.getElementById('legacyToggle');
const legacySection = document.getElementById('legacySection');
if (legacyToggle && legacySection) {
legacyToggle.addEventListener('click', () => {
legacySection.classList.toggle('d-none');
});
}
});
</script>
<?= $this->endSection() ?>