Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39ab0d113e | |||
| ce56c96cdd | |||
| b4a06903e2 | |||
| 0654668530 | |||
| 3737b3522d |
@@ -9,6 +9,7 @@ use CodeIgniter\Format\XMLFormatter;
|
|||||||
|
|
||||||
class Format extends BaseConfig
|
class Format extends BaseConfig
|
||||||
{
|
{
|
||||||
|
public int $jsonEncodeDepth = 512;
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
* Available Response Formats
|
* Available Response Formats
|
||||||
|
|||||||
@@ -538,6 +538,9 @@ $routes->get('grading/(:segment)/(:num)/(:num)', 'View\GradingController::show/$
|
|||||||
$routes->post('grading/update', 'View\GradingController::update');
|
$routes->post('grading/update', 'View\GradingController::update');
|
||||||
$routes->get('grading', 'View\GradingController::grading');
|
$routes->get('grading', 'View\GradingController::grading');
|
||||||
$routes->post('grading/toggle-score-lock', 'View\GradingController::toggleScoreLock', ['filter' => 'auth:read']);
|
$routes->post('grading/toggle-score-lock', 'View\GradingController::toggleScoreLock', ['filter' => 'auth:read']);
|
||||||
|
$routes->get('grading/toggle-score-lock', static function () {
|
||||||
|
return 'GET route hit. Something is calling this route incorrectly.';
|
||||||
|
});
|
||||||
$routes->post('grading/lock-all-scores', 'View\GradingController::lockAllScores', ['filter' => 'auth:read']);
|
$routes->post('grading/lock-all-scores', 'View\GradingController::lockAllScores', ['filter' => 'auth:read']);
|
||||||
$routes->post('grading/release-scores', 'View\GradingController::toggleParentScoresRelease', ['filter' => 'auth:read']);
|
$routes->post('grading/release-scores', 'View\GradingController::toggleParentScoresRelease', ['filter' => 'auth:read']);
|
||||||
$routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']);
|
$routes->post('grading/refresh-semester-scores', 'View\GradingController::refreshSemesterScores', ['filter' => 'auth:read']);
|
||||||
|
|||||||
@@ -10,8 +10,13 @@ class ProofreadController extends ResourceController
|
|||||||
{
|
{
|
||||||
// Basic per-IP throttling: 10 requests per minute
|
// Basic per-IP throttling: 10 requests per minute
|
||||||
$throttler = service('throttler');
|
$throttler = service('throttler');
|
||||||
$key = 'proofread-' . $this->request->getIPAddress();
|
|
||||||
if (!$throttler->check($key, 10, MINUTE)) {
|
// Cache/throttler keys cannot contain reserved characters.
|
||||||
|
// Raw IPv6 addresses contain ":", so hash the IP first.
|
||||||
|
$ip = $this->request->getIPAddress();
|
||||||
|
$key = 'proofread-' . hash('sha256', $ip);
|
||||||
|
|
||||||
|
if (! $throttler->check($key, 10, MINUTE)) {
|
||||||
return $this->respond([
|
return $this->respond([
|
||||||
'ok' => false,
|
'ok' => false,
|
||||||
'error' => 'Too many requests. Try again in a minute.',
|
'error' => 'Too many requests. Try again in a minute.',
|
||||||
@@ -20,8 +25,9 @@ class ProofreadController extends ResourceController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Accept form-urlencoded payload to play nicely with CSRF protection
|
// Accept form-urlencoded payload to play nicely with CSRF protection
|
||||||
$validation = service('validation');
|
|
||||||
$payload = sanitize_request_value($this->request->getPost(['text']));
|
$payload = sanitize_request_value($this->request->getPost(['text']));
|
||||||
|
|
||||||
|
$validation = service('validation');
|
||||||
$validation->setRules([
|
$validation->setRules([
|
||||||
'text' => 'required|min_length[1]|max_length[20000]',
|
'text' => 'required|min_length[1]|max_length[20000]',
|
||||||
]);
|
]);
|
||||||
@@ -36,20 +42,34 @@ class ProofreadController extends ResourceController
|
|||||||
|
|
||||||
$text = (string) $payload['text'];
|
$text = (string) $payload['text'];
|
||||||
|
|
||||||
$client = \Config\Services::curlrequest(['timeout' => 10]);
|
$client = \Config\Services::curlrequest([
|
||||||
|
'timeout' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$resp = $client->post('https://api.languagetool.org/v2/check', [
|
$resp = $client->post('https://api.languagetool.org/v2/check', [
|
||||||
'headers' => ['Content-Type' => 'application/x-www-form-urlencoded'],
|
'headers' => [
|
||||||
|
'Content-Type' => 'application/x-www-form-urlencoded',
|
||||||
|
],
|
||||||
'form_params' => [
|
'form_params' => [
|
||||||
'text' => $text,
|
'text' => $text,
|
||||||
'language' => 'en-US',
|
'language' => 'en-US',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$result = json_decode($resp->getBody(), true);
|
||||||
|
|
||||||
|
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||||
|
return $this->respond([
|
||||||
|
'ok' => false,
|
||||||
|
'error' => 'Invalid response from proofread service.',
|
||||||
|
'csrfHash' => csrf_hash(),
|
||||||
|
], 502);
|
||||||
|
}
|
||||||
|
|
||||||
return $this->respond([
|
return $this->respond([
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'result' => json_decode($resp->getBody(), true),
|
'result' => $result,
|
||||||
'csrfHash' => csrf_hash(),
|
'csrfHash' => csrf_hash(),
|
||||||
]);
|
]);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -60,4 +80,4 @@ class ProofreadController extends ResourceController
|
|||||||
], 502);
|
], 502);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,7 +177,10 @@ class CertificateController extends BaseController
|
|||||||
->table('certificate_records cr')
|
->table('certificate_records cr')
|
||||||
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
|
->select('cr.*, u.firstname AS admin_firstname, u.lastname AS admin_lastname')
|
||||||
->join('users u', 'u.id = cr.issued_by', 'left')
|
->join('users u', 'u.id = cr.issued_by', 'left')
|
||||||
->where('cr.certificate_number', strtoupper($certNumber))
|
->groupStart()
|
||||||
|
->where('cr.verification_token', $certNumber)
|
||||||
|
->orWhere('cr.certificate_number', strtoupper($certNumber))
|
||||||
|
->groupEnd()
|
||||||
->get()->getRowArray();
|
->get()->getRowArray();
|
||||||
|
|
||||||
return view('certificates/verify', ['record' => $record ?: null]);
|
return view('certificates/verify', ['record' => $record ?: null]);
|
||||||
@@ -210,6 +213,7 @@ class CertificateController extends BaseController
|
|||||||
'lastname' => '',
|
'lastname' => '',
|
||||||
'grade' => (string)($record['grade'] ?? ''),
|
'grade' => (string)($record['grade'] ?? ''),
|
||||||
'cert_number' => (string)($record['certificate_number'] ?? ''),
|
'cert_number' => (string)($record['certificate_number'] ?? ''),
|
||||||
|
'verify_token' => $this->ensureVerificationTokenForRecord($record),
|
||||||
];
|
];
|
||||||
|
|
||||||
// student_name is stored as "Firstname Lastname" — split for the PDF builder
|
// student_name is stored as "Firstname Lastname" — split for the PDF builder
|
||||||
@@ -317,34 +321,39 @@ class CertificateController extends BaseController
|
|||||||
// Load any existing certificates for these students this school year
|
// Load any existing certificates for these students this school year
|
||||||
$db = \Config\Database::connect();
|
$db = \Config\Database::connect();
|
||||||
$existingCerts = $db->table('certificate_records')
|
$existingCerts = $db->table('certificate_records')
|
||||||
->select('student_id, certificate_number')
|
->select('id, student_id, certificate_number, verification_token')
|
||||||
->whereIn('student_id', array_column($students, 'id'))
|
->whereIn('student_id', array_column($students, 'id'))
|
||||||
->where('school_year', $schoolYear)
|
->where('school_year', $schoolYear)
|
||||||
->get()->getResultArray();
|
->get()->getResultArray();
|
||||||
$existingCertMap = [];
|
$existingCertMap = [];
|
||||||
foreach ($existingCerts as $ec) {
|
foreach ($existingCerts as $ec) {
|
||||||
$existingCertMap[(int)$ec['student_id']] = $ec['certificate_number'];
|
$existingCertMap[(int)$ec['student_id']] = $ec;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($students as &$student) {
|
foreach ($students as &$student) {
|
||||||
$sid = (int)$student['id'];
|
$sid = (int)$student['id'];
|
||||||
if (isset($existingCertMap[$sid])) {
|
if (isset($existingCertMap[$sid])) {
|
||||||
// Reuse existing certificate number — do not create a new record
|
// Reuse existing certificate number — do not create a new record
|
||||||
$student['cert_number'] = $existingCertMap[$sid];
|
$existing = $existingCertMap[$sid];
|
||||||
|
$student['cert_number'] = (string)($existing['certificate_number'] ?? '');
|
||||||
|
$student['verify_token'] = $this->ensureVerificationTokenForRecord($existing);
|
||||||
} else {
|
} else {
|
||||||
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
|
$certNumber = $this->certRecordModel->nextNumber($schoolYear);
|
||||||
|
$verifyToken = $this->certRecordModel->generateVerificationToken();
|
||||||
$this->certRecordModel->insert([
|
$this->certRecordModel->insert([
|
||||||
'certificate_number' => $certNumber,
|
'certificate_number' => $certNumber,
|
||||||
|
'verification_token' => $verifyToken,
|
||||||
'student_id' => $sid,
|
'student_id' => $sid,
|
||||||
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
|
'student_name' => $student['firstname'] . ' ' . $student['lastname'],
|
||||||
'grade' => $this->formatGrade($student['grade'] ?? ''),
|
'grade' => $this->formatGrade($student['grade'] ?? ''),
|
||||||
'cert_date' => $certDateDb,
|
//'cert_date' => $certDateDb,
|
||||||
'school_year' => $schoolYear,
|
'school_year' => $schoolYear,
|
||||||
'class_section_id' => $classSectionId ?: null,
|
'class_section_id' => $classSectionId ?: null,
|
||||||
'issued_by' => $issuedBy,
|
//'issued_by' => $issuedBy,
|
||||||
'issued_at' => $issuedAt,
|
'issued_at' => $issuedAt,
|
||||||
]);
|
]);
|
||||||
$student['cert_number'] = $certNumber;
|
$student['cert_number'] = $certNumber;
|
||||||
|
$student['verify_token'] = $verifyToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unset($student);
|
unset($student);
|
||||||
@@ -396,6 +405,24 @@ class CertificateController extends BaseController
|
|||||||
return $clean;
|
return $clean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function ensureVerificationTokenForRecord(array $record): string
|
||||||
|
{
|
||||||
|
$token = trim((string)($record['verification_token'] ?? ''));
|
||||||
|
if ($token !== '') {
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
$recordId = (int)($record['id'] ?? 0);
|
||||||
|
if ($recordId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->certRecordModel->generateVerificationToken();
|
||||||
|
$this->certRecordModel->update($recordId, ['verification_token' => $token]);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── PDF rendering ─────────────────────────────────────────────────────────
|
// ─── PDF rendering ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private function buildPdf(array $students, string $certDate): string
|
private function buildPdf(array $students, string $certDate): string
|
||||||
@@ -426,9 +453,11 @@ class CertificateController extends BaseController
|
|||||||
$name = $student['firstname'] . ' ' . $student['lastname'];
|
$name = $student['firstname'] . ' ' . $student['lastname'];
|
||||||
$grade = $this->formatGrade($student['grade'] ?? '');
|
$grade = $this->formatGrade($student['grade'] ?? '');
|
||||||
$certNumber = $student['cert_number'] ?? '';
|
$certNumber = $student['cert_number'] ?? '';
|
||||||
|
$verifyToken = $student['verify_token'] ?? '';
|
||||||
|
|
||||||
// Build verification URL for an inline TCPDF QR code.
|
$verifyUrl = $verifyToken !== ''
|
||||||
$verifyUrl = site_url('verify/' . rawurlencode($certNumber));
|
? site_url('verify/' . rawurlencode($verifyToken))
|
||||||
|
: null;
|
||||||
|
|
||||||
$this->drawCertificate(
|
$this->drawCertificate(
|
||||||
$pdf, $W, $H,
|
$pdf, $W, $H,
|
||||||
@@ -487,11 +516,11 @@ class CertificateController extends BaseController
|
|||||||
// ── Student name — center based on actual string width
|
// ── Student name — center based on actual string width
|
||||||
$pdf->SetFont($edwardianFont, '', 38);
|
$pdf->SetFont($edwardianFont, '', 38);
|
||||||
$nameX = ($W - $pdf->GetStringWidth($name)) / 2;
|
$nameX = ($W - $pdf->GetStringWidth($name)) / 2;
|
||||||
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 229.5);
|
$this->drawGradientText($pdf, $edwardianFont, 38, $name, $nameX, 221.5);
|
||||||
|
|
||||||
// ── Line under name
|
// ── Line under name
|
||||||
$pdf->SetFont('times', '', 20);
|
$pdf->SetFont('times', '', 20);
|
||||||
$pdf->SetXY(0, 243);
|
$pdf->SetXY(0, 236);
|
||||||
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
|
$pdf->Cell(600, 20, '___________________________________', 0, 0, 'R');
|
||||||
|
|
||||||
// ── Description
|
// ── Description
|
||||||
|
|||||||
@@ -845,6 +845,7 @@ class ReportCardsController extends PrintablesBaseController
|
|||||||
$studentName = trim(($data['student']['firstname'] ?? '') . ' ' . ($data['student']['lastname'] ?? ''));
|
$studentName = trim(($data['student']['firstname'] ?? '') . ' ' . ($data['student']['lastname'] ?? ''));
|
||||||
$gradeLabel = (string)($data['grade'] ?? ($data['class_section_name'] ?? 'N/A'));
|
$gradeLabel = (string)($data['grade'] ?? ($data['class_section_name'] ?? 'N/A'));
|
||||||
$today = (string)($data['report_date_display'] ?? date('m-d-Y'));
|
$today = (string)($data['report_date_display'] ?? date('m-d-Y'));
|
||||||
|
$termRank = trim((string)($data['term_rank_display'] ?? ''));
|
||||||
$firstSemScore = $data['first_semester_score'] ?? null;
|
$firstSemScore = $data['first_semester_score'] ?? null;
|
||||||
$secondSemScore = $data['second_semester_score'] ?? ($data['total_score'] ?? null);
|
$secondSemScore = $data['second_semester_score'] ?? ($data['total_score'] ?? null);
|
||||||
$finalAverage = $data['final_average'] ?? null;
|
$finalAverage = $data['final_average'] ?? null;
|
||||||
@@ -980,54 +981,91 @@ class ReportCardsController extends PrintablesBaseController
|
|||||||
$gradeY = $pdf->GetY();
|
$gradeY = $pdf->GetY();
|
||||||
$gradePad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt
|
$gradePad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt
|
||||||
|
|
||||||
$drawGradeCell = static function (\FPDF $pdf, float $x, float $y, float $w, float $h, string $label, string $value, float $pad) {
|
$drawGradeCell = static function (
|
||||||
$pdf->Rect($x, $y, $w, $h);
|
\FPDF $pdf,
|
||||||
$pdf->SetXY($x + $pad, $y + 3);
|
float $x,
|
||||||
$pdf->SetFont('Helvetica', 'B', 11);
|
float $y,
|
||||||
$pdf->Write(5, $label . ' ');
|
float $w,
|
||||||
$labelWidth = $pdf->GetStringWidth($label . ' ');
|
float $h,
|
||||||
$pdf->SetFont('Helvetica', '', 12);
|
string $label,
|
||||||
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
|
string $value,
|
||||||
$pdf->Write(5, $value);
|
float $pad
|
||||||
};
|
) {
|
||||||
|
$pdf->Rect($x, $y, $w, $h);
|
||||||
|
$pdf->SetXY($x + $pad, $y + 3);
|
||||||
|
$pdf->SetFont('Helvetica', 'B', 11);
|
||||||
|
$pdf->Write(5, $label . ' ');
|
||||||
|
$labelWidth = $pdf->GetStringWidth($label . ' ');
|
||||||
|
$pdf->SetFont('Helvetica', '', 12);
|
||||||
|
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
|
||||||
|
$pdf->Write(5, $value);
|
||||||
|
};
|
||||||
|
$drawRankCell = static function (
|
||||||
|
\FPDF $pdf,
|
||||||
|
float $x,
|
||||||
|
float $y,
|
||||||
|
float $w,
|
||||||
|
float $h,
|
||||||
|
string $rankValue,
|
||||||
|
float $pad
|
||||||
|
) {
|
||||||
|
$rankValue = trim($rankValue) !== '' ? trim($rankValue) : 'N/A';
|
||||||
|
|
||||||
if ($semNum === 2) {
|
$pdf->Rect($x, $y, $w, $h);
|
||||||
$firstLabel = ' 1st Semester Grade:';
|
$pdf->SetXY($x + $pad, $y + 3);
|
||||||
$firstValue = $numFmt($firstSemScore) . '/100';
|
$pdf->SetFont('Helvetica', 'B', 11);
|
||||||
$secondLabel = ' 2nd Semester Grade:';
|
$pdf->Write(5, ' Ranking: ');
|
||||||
$secondValue = $numFmt($effectiveSecond) . '/100';
|
|
||||||
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad);
|
|
||||||
$drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad);
|
|
||||||
} else {
|
|
||||||
$gradeLabel = ' 1st Semester Grade:';
|
|
||||||
$gradeValue = $numFmt($effectiveSecond) . '/100';
|
|
||||||
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad);
|
|
||||||
}
|
|
||||||
$pdf->SetY($gradeY + $gradeCellHeight);
|
|
||||||
|
|
||||||
$finalScoreRowHeight = 12;
|
$labelWidth = $pdf->GetStringWidth(' Ranking: ');
|
||||||
$finalScoreEndY = null;
|
$pdf->SetFont('Helvetica', '', 12);
|
||||||
// Show Final Score only for second semester / Spring
|
$pdf->SetXY($x + 2 + $labelWidth, $y + 3);
|
||||||
if ($semNum === 2) {
|
$pdf->Write(5, $rankValue);
|
||||||
$finalLabel = 'Final Score****:';
|
};
|
||||||
$finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100';
|
if ($semNum === 2) {
|
||||||
$finalCellWidth = 65; // match Total Semester Days column width
|
$firstLabel = ' 1st Semester Grade:';
|
||||||
$finalCellHeight = $finalScoreRowHeight;
|
$firstValue = $numFmt($firstSemScore) . '/100';
|
||||||
$finalX = $pdf->GetX();
|
|
||||||
$finalY = $pdf->GetY();
|
$secondLabel = ' 2nd Semester Grade:';
|
||||||
$pdf->Rect($finalX, $finalY, $finalCellWidth, $finalCellHeight);
|
$secondValue = $numFmt($effectiveSecond) . '/100';
|
||||||
$finalPad = max(0, 2 - (4 * 0.3528)); // reduce indentation by 4pt
|
|
||||||
$pdf->SetXY($finalX + $finalPad, $finalY + 3);
|
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $firstLabel, $firstValue, $gradePad);
|
||||||
$pdf->SetFont('Helvetica', 'B', 11);
|
$drawGradeCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $secondLabel, $secondValue, $gradePad);
|
||||||
$pdf->Write(5, ' ' . $finalLabel . ' ');
|
} else {
|
||||||
$finalLabelWidth = $pdf->GetStringWidth(' ' . $finalLabel . ' ');
|
$gradeLabel = ' 1st Semester Grade:';
|
||||||
$pdf->SetFont('Helvetica', '', 12);
|
$gradeValue = $numFmt($effectiveSecond) . '/100';
|
||||||
$pdf->SetXY($finalX + 2 + $finalLabelWidth, $finalY + 3);
|
|
||||||
$pdf->Write(5, $finalValue);
|
$drawGradeCell($pdf, $gradeX, $gradeY, $gradeCellWidth, $gradeCellHeight, $gradeLabel, $gradeValue, $gradePad);
|
||||||
$pdf->Ln($finalCellHeight);
|
|
||||||
$finalScoreEndY = $finalY + $finalCellHeight;
|
// Fall: Ranking goes to the right of 1st Semester Grade.
|
||||||
}
|
$drawRankCell($pdf, $gradeX + $gradeCellWidth, $gradeY, $gradeCellWidth, $gradeCellHeight, $termRank, $gradePad);
|
||||||
$scoresEndY = $pdf->GetY();
|
}
|
||||||
|
|
||||||
|
$pdf->SetY($gradeY + $gradeCellHeight);
|
||||||
|
|
||||||
|
$finalScoreRowHeight = 12;
|
||||||
|
$finalScoreEndY = null;
|
||||||
|
|
||||||
|
// Show Final Score only for second semester / Spring.
|
||||||
|
if ($semNum === 2) {
|
||||||
|
$finalLabel = 'Final Score****:';
|
||||||
|
$finalValue = (is_string($finalScoreVal) ? $finalScoreVal : $numFmt($finalScoreVal)) . '/100';
|
||||||
|
|
||||||
|
$finalCellWidth = 65;
|
||||||
|
$finalCellHeight = $finalScoreRowHeight;
|
||||||
|
$finalX = $pdf->GetX();
|
||||||
|
$finalY = $pdf->GetY();
|
||||||
|
$finalPad = max(0, 2 - (4 * 0.3528));
|
||||||
|
|
||||||
|
$drawGradeCell($pdf, $finalX, $finalY, $finalCellWidth, $finalCellHeight, ' ' . $finalLabel, $finalValue, $finalPad);
|
||||||
|
|
||||||
|
// Spring: Ranking goes to the right of Final Score.
|
||||||
|
$drawRankCell($pdf, $finalX + $finalCellWidth, $finalY, $finalCellWidth, $finalCellHeight, $termRank, $finalPad);
|
||||||
|
|
||||||
|
$pdf->SetY($finalY + $finalCellHeight);
|
||||||
|
$finalScoreEndY = $finalY + $finalCellHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scoresEndY = $pdf->GetY();
|
||||||
|
|
||||||
// Legend anchored near bottom with a 3mm buffer (dynamic placement)
|
// Legend anchored near bottom with a 3mm buffer (dynamic placement)
|
||||||
$examLegendLabel = ($semNum === 1) ? 'Midterm Exam' : 'Final Exam';
|
$examLegendLabel = ($semNum === 1) ? 'Midterm Exam' : 'Final Exam';
|
||||||
@@ -1546,6 +1584,20 @@ class ReportCardsController extends PrintablesBaseController
|
|||||||
$firstSemesterScore = $secondSemesterScore;
|
$firstSemesterScore = $secondSemesterScore;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$rankingScore = $secondSemesterScore;
|
||||||
|
if ($normSemester === 'spring' && is_numeric($firstSemesterScore) && is_numeric($secondSemesterScore)) {
|
||||||
|
$rankingScore = round(((float)$firstSemesterScore + (float)$secondSemesterScore) / 2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
$termRanking = $this->calculateTermRanking(
|
||||||
|
$studentId,
|
||||||
|
$sectionCode,
|
||||||
|
$sectionId,
|
||||||
|
$refYear,
|
||||||
|
$refSemester !== '' ? $refSemester : (string)($score['semester'] ?? ''),
|
||||||
|
$rankingScore
|
||||||
|
);
|
||||||
|
|
||||||
// Total semester days from attendance records (max total_attendance within same term)
|
// Total semester days from attendance records (max total_attendance within same term)
|
||||||
$totalSemesterDays = null;
|
$totalSemesterDays = null;
|
||||||
|
|
||||||
@@ -1672,12 +1724,189 @@ class ReportCardsController extends PrintablesBaseController
|
|||||||
'second_semester_score' => $secondSemesterScore,
|
'second_semester_score' => $secondSemesterScore,
|
||||||
'first_semester_score' => $firstSemesterScore,
|
'first_semester_score' => $firstSemesterScore,
|
||||||
'final_average' => $finalAverage,
|
'final_average' => $finalAverage,
|
||||||
|
'term_rank' => $termRanking,
|
||||||
|
'term_rank_display' => $termRanking['display'] ?? null,
|
||||||
'comments' => $commentMap,
|
'comments' => $commentMap,
|
||||||
'total_score' => $secondSemesterScore,
|
'total_score' => $secondSemesterScore,
|
||||||
'total_attendance_days' => $totalSemesterDays,
|
'total_attendance_days' => $totalSemesterDays,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function calculateTermRanking(
|
||||||
|
int $studentId,
|
||||||
|
int $sectionCode,
|
||||||
|
?int $sectionId,
|
||||||
|
string $schoolYear,
|
||||||
|
?string $semester,
|
||||||
|
?float $studentScore
|
||||||
|
): ?array {
|
||||||
|
if ($studentId <= 0 || $schoolYear === '' || $studentScore === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sectionIds = array_values(array_unique(array_filter([
|
||||||
|
$sectionCode > 0 ? $sectionCode : null,
|
||||||
|
$sectionId && $sectionId > 0 ? $sectionId : null,
|
||||||
|
])));
|
||||||
|
|
||||||
|
if (empty($sectionIds)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$semesterForRank = trim((string)$semester);
|
||||||
|
$rankByFinalScore = $this->normalizeSemester($semesterForRank) === 'spring';
|
||||||
|
|
||||||
|
$builder = $this->db->table('semester_scores ss')
|
||||||
|
->select('ss.student_id, ss.semester, ss.semester_score, ss.updated_at, ss.id, s.firstname, s.lastname')
|
||||||
|
->join('students s', 's.id = ss.student_id', 'inner')
|
||||||
|
->where('s.is_active', 1)
|
||||||
|
->where('ss.school_year', $schoolYear)
|
||||||
|
->whereIn('ss.class_section_id', $sectionIds)
|
||||||
|
->orderBy('ss.updated_at', 'DESC')
|
||||||
|
->orderBy('ss.id', 'DESC');
|
||||||
|
|
||||||
|
if ($semesterForRank !== '') {
|
||||||
|
$this->applySemesterFilter($builder, $semesterForRank, 'ss.semester');
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $builder->get()->getResultArray();
|
||||||
|
if (empty($rows)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scoresByStudent = [];
|
||||||
|
$studentIds = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$sid = (int)($row['student_id'] ?? 0);
|
||||||
|
if ($sid <= 0 || isset($scoresByStudent[$sid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scoreVal = $row['semester_score'] ?? null;
|
||||||
|
if (!is_numeric($scoreVal)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scoresByStudent[$sid] = [
|
||||||
|
'student_id' => $sid,
|
||||||
|
'score' => round((float)$scoreVal, 4),
|
||||||
|
'firstname' => trim((string)($row['firstname'] ?? '')),
|
||||||
|
'lastname' => trim((string)($row['lastname'] ?? '')),
|
||||||
|
];
|
||||||
|
$studentIds[] = $sid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rankByFinalScore) {
|
||||||
|
$firstRowsBuilder = $this->db->table('semester_scores ss')
|
||||||
|
->select('ss.student_id, ss.semester_score, ss.updated_at, ss.id')
|
||||||
|
->where('ss.school_year', $schoolYear)
|
||||||
|
->whereIn('ss.class_section_id', $sectionIds)
|
||||||
|
->whereIn('ss.student_id', $studentIds)
|
||||||
|
->orderBy('ss.updated_at', 'DESC')
|
||||||
|
->orderBy('ss.id', 'DESC');
|
||||||
|
|
||||||
|
if ($semesterForRank !== '') {
|
||||||
|
$this->applySemesterExclusion($firstRowsBuilder, $semesterForRank, 'ss.semester');
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstRows = $firstRowsBuilder->get()->getResultArray();
|
||||||
|
$firstScoresByStudent = [];
|
||||||
|
foreach ($firstRows as $row) {
|
||||||
|
$sid = (int)($row['student_id'] ?? 0);
|
||||||
|
if ($sid <= 0 || isset($firstScoresByStudent[$sid])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scoreVal = $row['semester_score'] ?? null;
|
||||||
|
if (!is_numeric($scoreVal)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstScoresByStudent[$sid] = (float)$scoreVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($scoresByStudent as $sid => &$rankRow) {
|
||||||
|
if (!isset($firstScoresByStudent[$sid])) {
|
||||||
|
unset($scoresByStudent[$sid]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rankRow['score'] = round(((float)$firstScoresByStudent[$sid] + (float)$rankRow['score']) / 2, 4);
|
||||||
|
}
|
||||||
|
unset($rankRow);
|
||||||
|
|
||||||
|
if (empty($scoresByStudent) || !isset($scoresByStudent[$studentId])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scoresByStudent[$studentId]['score'] = round((float)$studentScore, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rankable = array_values($scoresByStudent);
|
||||||
|
usort($rankable, static function (array $a, array $b): int {
|
||||||
|
$scoreCmp = $b['score'] <=> $a['score'];
|
||||||
|
if ($scoreCmp !== 0) {
|
||||||
|
return $scoreCmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastCmp = strcasecmp($a['lastname'], $b['lastname']);
|
||||||
|
if ($lastCmp !== 0) {
|
||||||
|
return $lastCmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
$firstCmp = strcasecmp($a['firstname'], $b['firstname']);
|
||||||
|
if ($firstCmp !== 0) {
|
||||||
|
return $firstCmp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $a['student_id'] <=> $b['student_id'];
|
||||||
|
});
|
||||||
|
|
||||||
|
$position = null;
|
||||||
|
$previousScore = null;
|
||||||
|
foreach ($rankable as $index => $row) {
|
||||||
|
if ($previousScore === null || abs($row['score'] - $previousScore) > 0.0001) {
|
||||||
|
$position = $index + 1;
|
||||||
|
$previousScore = $row['score'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((int)$row['student_id'] === $studentId) {
|
||||||
|
$total = count($rankable);
|
||||||
|
return [
|
||||||
|
'position' => $position,
|
||||||
|
'total' => $total,
|
||||||
|
'display' => $this->formatOrdinal($position) . ' of ' . $total,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formatOrdinal(?int $value): string
|
||||||
|
{
|
||||||
|
$n = (int)$value;
|
||||||
|
if ($n <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$mod100 = $n % 100;
|
||||||
|
if ($mod100 >= 11 && $mod100 <= 13) {
|
||||||
|
return $n . 'th';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($n % 10) {
|
||||||
|
1 => $n . 'st',
|
||||||
|
2 => $n . 'nd',
|
||||||
|
3 => $n . 'rd',
|
||||||
|
default => $n . 'th',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve teacher & TA names for a class section (by PK or code),
|
* Resolve teacher & TA names for a class section (by PK or code),
|
||||||
* relaxing semester/year if needed. Returns ['teacher_name' => string, 'ta_names' => string[]].
|
* relaxing semester/year if needed. Returns ['teacher_name' => string, 'ta_names' => string[]].
|
||||||
|
|||||||
@@ -231,18 +231,36 @@ class ScoreCommentController extends BaseController
|
|||||||
$rawCommentsBuilder->groupEnd();
|
$rawCommentsBuilder->groupEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$isAllSections && $classSectionId > 0) {
|
||||||
|
// Prefer the exact class-section row over legacy NULL class-section rows.
|
||||||
|
// Without this, duplicate rows can overwrite each other unpredictably.
|
||||||
|
$rawCommentsBuilder
|
||||||
|
->orderBy("CASE WHEN class_section_id = {$classSectionId} THEN 0 ELSE 1 END", '', false)
|
||||||
|
->orderBy('id', 'DESC');
|
||||||
|
} else {
|
||||||
|
$rawCommentsBuilder->orderBy('id', 'DESC');
|
||||||
|
}
|
||||||
|
|
||||||
$rawComments = $rawCommentsBuilder->findAll();
|
$rawComments = $rawCommentsBuilder->findAll();
|
||||||
|
|
||||||
$existingAttendance = [];
|
$existingAttendance = [];
|
||||||
foreach ($rawComments as $c) {
|
foreach ($rawComments as $c) {
|
||||||
$sid = (int)$c['student_id'];
|
$sid = (int) $c['student_id'];
|
||||||
$typ = (string)$c['score_type'];
|
$typ = strtolower(trim((string) $c['score_type']));
|
||||||
|
|
||||||
|
// The query is ordered with the best row first. Do not let legacy/older
|
||||||
|
// duplicate rows overwrite the selected comment/review.
|
||||||
|
if (isset($comments[$sid][$typ])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
$comments[$sid][$typ] = [
|
$comments[$sid][$typ] = [
|
||||||
'comment' => $c['comment'] ?? null,
|
'comment' => $c['comment'] ?? null,
|
||||||
'comment_review' => $c['comment_review'] ?? null,
|
'comment_review' => $c['comment_review'] ?? null,
|
||||||
'commented_by' => $c['commented_by'] ?? null,
|
'commented_by' => $c['commented_by'] ?? null,
|
||||||
'reviewed_by' => $c['reviewed_by'] ?? null,
|
'reviewed_by' => $c['reviewed_by'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($typ === 'attendance') {
|
if ($typ === 'attendance') {
|
||||||
$existingAttendance[$sid] = $c;
|
$existingAttendance[$sid] = $c;
|
||||||
}
|
}
|
||||||
@@ -435,6 +453,16 @@ class ScoreCommentController extends BaseController
|
|||||||
$existingQuery->groupEnd();
|
$existingQuery->groupEnd();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_int($classSectionId) && $classSectionId > 0) {
|
||||||
|
// Prefer updating the real class-section row. If only a legacy NULL row
|
||||||
|
// exists, it will be picked next and migrated to the class section below.
|
||||||
|
$existingQuery
|
||||||
|
->orderBy("CASE WHEN class_section_id = {$classSectionId} THEN 0 ELSE 1 END", '', false)
|
||||||
|
->orderBy('id', 'DESC');
|
||||||
|
} else {
|
||||||
|
$existingQuery->orderBy('id', 'DESC');
|
||||||
|
}
|
||||||
|
|
||||||
$existing = $existingQuery->first();
|
$existing = $existingQuery->first();
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddVerificationTokenToCertificateRecords extends Migration
|
||||||
|
{
|
||||||
|
private const INDEX_NAME = 'uniq_certificate_verification_token';
|
||||||
|
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('certificate_records')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->db->fieldExists('verification_token', 'certificate_records')) {
|
||||||
|
$this->forge->addColumn('certificate_records', [
|
||||||
|
'verification_token' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 64,
|
||||||
|
'null' => true,
|
||||||
|
'after' => 'certificate_number',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $this->db->table('certificate_records')
|
||||||
|
->select('id, verification_token')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (trim((string)($row['verification_token'] ?? '')) !== '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('certificate_records')
|
||||||
|
->where('id', (int)$row['id'])
|
||||||
|
->update(['verification_token' => $this->generateToken()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addUniqueKey('verification_token', self::INDEX_NAME);
|
||||||
|
$this->forge->processIndexes('certificate_records');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
if (!$this->db->tableExists('certificate_records')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->db->fieldExists('verification_token', 'certificate_records')) {
|
||||||
|
$this->forge->dropKey('certificate_records', self::INDEX_NAME);
|
||||||
|
$this->forge->dropColumn('certificate_records', 'verification_token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateToken(): string
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$token = bin2hex(random_bytes(16));
|
||||||
|
$exists = $this->db->table('certificate_records')
|
||||||
|
->select('id')
|
||||||
|
->where('verification_token', $token)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
} while ($exists);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ class CertificateRecordModel extends Model
|
|||||||
|
|
||||||
protected $allowedFields = [
|
protected $allowedFields = [
|
||||||
'certificate_number',
|
'certificate_number',
|
||||||
|
'verification_token',
|
||||||
'student_id',
|
'student_id',
|
||||||
'student_name',
|
'student_name',
|
||||||
'grade',
|
'grade',
|
||||||
@@ -44,6 +45,23 @@ class CertificateRecordModel extends Model
|
|||||||
return 'ARSS-' . $schoolYear . '-' . $seq;
|
return 'ARSS-' . $schoolYear . '-' . $seq;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function generateVerificationToken(): string
|
||||||
|
{
|
||||||
|
$db = \Config\Database::connect();
|
||||||
|
|
||||||
|
do {
|
||||||
|
$token = bin2hex(random_bytes(16));
|
||||||
|
$exists = $db->table($this->table)
|
||||||
|
->select('id')
|
||||||
|
->where('verification_token', $token)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
} while ($exists);
|
||||||
|
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns paginated records with the issuing admin's name joined. */
|
/** Returns paginated records with the issuing admin's name joined. */
|
||||||
public function getAuditLog(?string $schoolYear = null, int $perPage = 50): array
|
public function getAuditLog(?string $schoolYear = null, int $perPage = 50): array
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,9 +64,9 @@
|
|||||||
<th>Certificate #</th>
|
<th>Certificate #</th>
|
||||||
<th>Student</th>
|
<th>Student</th>
|
||||||
<th>Grade</th>
|
<th>Grade</th>
|
||||||
<th>Cert Date</th>
|
<!--th>Cert Date</th-->
|
||||||
<th>School Year</th>
|
<th>School Year</th>
|
||||||
<th>Issued By</th>
|
<!--th>Issued By</th-->
|
||||||
<th>Issued At</th>
|
<th>Issued At</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -51,19 +51,7 @@
|
|||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<div class="field-label">School Year</div>
|
<div class="field-label">School Year</div>
|
||||||
<div class="field-value"><?= esc($record['school_year'] ?? '—') ?></div>
|
<div class="field-value"><?= esc($record['school_year'] ?? '—') ?></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
|
||||||
<div class="field-label">Certificate Date</div>
|
|
||||||
<div class="field-value">
|
|
||||||
<?= $record['cert_date'] ? esc(date('F j, Y', strtotime($record['cert_date']))) : '—' ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="field-label">Issued By</div>
|
|
||||||
<div class="field-value">
|
|
||||||
<?= esc(trim(($record['admin_firstname'] ?? '') . ' ' . ($record['admin_lastname'] ?? '')) ?: '—') ?>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="field-label">Issued On</div>
|
<div class="field-label">Issued On</div>
|
||||||
<div class="field-value">
|
<div class="field-value">
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
|||||||
</div>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="d-flex justify-content-between mt-4 flex-wrap gap-2">
|
<div class="d-flex justify-content-between mt-4 flex-wrap gap-2">
|
||||||
<button type="submit" class="btn btn-success" <?= $lockAttr ?>>
|
<button type="submit" form="commentsForm" class="btn btn-success" <?= $lockAttr ?>>
|
||||||
<i class="bi bi-save"></i> Save All Changes
|
<i class="bi bi-save"></i> Save All Changes
|
||||||
</button>
|
</button>
|
||||||
<a href="<?= base_url('/grading') ?>" class="btn btn-secondary">
|
<a href="<?= base_url('/grading') ?>" class="btn btn-secondary">
|
||||||
@@ -177,7 +177,10 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
|||||||
</td>
|
</td>
|
||||||
<?php elseif ($semester === 'Spring'): ?>
|
<?php elseif ($semester === 'Spring'): ?>
|
||||||
<td>
|
<td>
|
||||||
|
<?php $finalCommentId = 'final-comment-' . $studentId; ?>
|
||||||
|
<?php $finalReviewId = 'final-review-' . $studentId; ?>
|
||||||
<textarea
|
<textarea
|
||||||
|
id="<?= esc($finalCommentId) ?>"
|
||||||
name="comments[<?= $studentId ?>][final]"
|
name="comments[<?= $studentId ?>][final]"
|
||||||
rows="2"
|
rows="2"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
@@ -186,7 +189,6 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
|||||||
maxlength="350"
|
maxlength="350"
|
||||||
placeholder="Enter Final comment" <?= $lockAttr ?>><?= esc($comments[$studentId]['final']['comment'] ?? '') ?></textarea>
|
placeholder="Enter Final comment" <?= $lockAttr ?>><?= esc($comments[$studentId]['final']['comment'] ?? '') ?></textarea>
|
||||||
</td>
|
</td>
|
||||||
<?php $finalReviewId = 'final-review-' . $studentId; ?>
|
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex gap-2 align-items-start">
|
<div class="d-flex gap-2 align-items-start">
|
||||||
<textarea
|
<textarea
|
||||||
@@ -196,6 +198,13 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
|||||||
class="form-control review-field"
|
class="form-control review-field"
|
||||||
placeholder="Enter Final review" <?= $lockAttr ?>><?= esc($comments[$studentId]['final']['comment_review'] ?? '') ?></textarea>
|
placeholder="Enter Final review" <?= $lockAttr ?>><?= esc($comments[$studentId]['final']['comment_review'] ?? '') ?></textarea>
|
||||||
<div class="d-flex flex-column gap-1 align-items-stretch">
|
<div class="d-flex flex-column gap-1 align-items-stretch">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-secondary btn-sm copy-final-btn"
|
||||||
|
data-copy-source="#<?= esc($finalCommentId) ?>"
|
||||||
|
data-copy-target="#<?= esc($finalReviewId) ?>" <?= $lockAttr ?>>
|
||||||
|
Paste
|
||||||
|
</button>
|
||||||
<div class="small text-muted proofread-status" data-status-for="<?= esc($finalReviewId) ?>"></div>
|
<div class="small text-muted proofread-status" data-status-for="<?= esc($finalReviewId) ?>"></div>
|
||||||
<ul class="proofread-list small mb-0" data-list-for="<?= esc($finalReviewId) ?>"></ul>
|
<ul class="proofread-list small mb-0" data-list-for="<?= esc($finalReviewId) ?>"></ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,18 +483,9 @@ $lockAttr = $scoresLocked ? 'disabled' : '';
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
document.querySelectorAll('.copy-ptap-btn').forEach(button => {
|
wireCopyButtons('.copy-ptap-btn');
|
||||||
button.addEventListener('click', function() {
|
|
||||||
const source = document.querySelector(this.dataset.copySource || '');
|
|
||||||
const target = document.querySelector(this.dataset.copyTarget || '');
|
|
||||||
if (!source || !target) return;
|
|
||||||
target.value = source.value;
|
|
||||||
target.style.height = 'auto';
|
|
||||||
target.style.height = (target.scrollHeight) + 'px';
|
|
||||||
target.focus();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
wireCopyButtons('.copy-midterm-btn');
|
wireCopyButtons('.copy-midterm-btn');
|
||||||
|
wireCopyButtons('.copy-final-btn');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
services:
|
||||||
|
mysql:
|
||||||
|
image: mysql:8.4
|
||||||
|
container_name: mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: password
|
||||||
|
MYSQL_DATABASE: school
|
||||||
|
MYSQL_USER: root
|
||||||
|
MYSQL_PASSWORD: password
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
|
||||||
|
phpmyadmin:
|
||||||
|
image: phpmyadmin/phpmyadmin:latest
|
||||||
|
platform: linux/amd64
|
||||||
|
container_name: phpmyadmin
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PMA_HOST: mysql
|
||||||
|
PMA_PORT: 3306
|
||||||
|
ports:
|
||||||
|
- "8081:80"
|
||||||
|
depends_on:
|
||||||
|
- mysql
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
Reference in New Issue
Block a user