Files
alrahma_sunday_school/app/Services/GradingScoreService.php
T
root 2b0206e7f2
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 47s
Tests / PHPUnit (push) Successful in 1m16s
move service logic from grading and administrator controller
2026-08-20 16:59:51 -04:00

1458 lines
52 KiB
PHP

<?php
namespace App\Services;
use App\Models\AttendanceRecordModel;
use App\Models\CalendarModel;
use App\Models\ClassSectionModel;
use App\Models\ConfigurationModel;
use App\Models\CurrentFlagModel;
use App\Models\FinalExamModel;
use App\Models\GradingLockModel;
use App\Models\HomeworkModel;
use App\Models\MidtermExamModel;
use App\Models\ProjectModel;
use App\Models\QuizModel;
use App\Models\ScoreCommentModel;
use App\Models\SemesterScoreModel;
use App\Models\StudentClassModel;
use App\Models\StudentModel;
use App\Models\TeacherClassModel;
use App\Models\UserModel;
use App\Services\Calculators\AttendanceCalculator;
use App\Services\NavbarService;
use CodeIgniter\Events\Events;
class GradingScoreService
{
protected $db;
protected $configModel;
protected $homeworkModel;
protected $userModel;
protected $studentClassModel;
protected $studentModel;
protected $teacherClassModel;
protected $classSection;
protected $attendanceCalculator;
protected $gradingLockModel;
protected $semesterScoreService;
protected string $schoolYear = '';
protected string $semester = '';
public function __construct(
\CodeIgniter\Database\BaseConnection $db,
ConfigurationModel $configModel,
HomeworkModel $homeworkModel,
UserModel $userModel,
StudentClassModel $studentClassModel,
StudentModel $studentModel,
TeacherClassModel $teacherClassModel,
ClassSectionModel $classSection,
AttendanceCalculator $attendanceCalculator,
GradingLockModel $gradingLockModel,
$semesterScoreService,
string $schoolYear = '',
string $semester = ''
) {
$this->db = $db;
$this->configModel = $configModel;
$this->homeworkModel = $homeworkModel;
$this->userModel = $userModel;
$this->studentClassModel = $studentClassModel;
$this->studentModel = $studentModel;
$this->teacherClassModel = $teacherClassModel;
$this->classSection = $classSection;
$this->attendanceCalculator = $attendanceCalculator;
$this->gradingLockModel = $gradingLockModel;
$this->semesterScoreService = $semesterScoreService;
$this->schoolYear = $schoolYear;
$this->semester = $semester;
}
public function setTerm(string $schoolYear, string $semester): void
{
$this->schoolYear = $schoolYear;
$this->semester = $semester;
}
public function showType($type, $classSectionId, $studentId, array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$scoreModel = $this->getModelByType($type);
$studentModel = new StudentModel();
$configModel = new ConfigurationModel();
$schoolYear = $configModel->getConfig('school_year');
$semester = getSemester();
$student = $studentModel->find($studentId);
$scores = $scoreModel->where([
'student_id' => $studentId,
'semester' => $semester,
'school_year' => $schoolYear
])->findAll();
$scoresLocked = false;
$classSectionIdInt = (int) ($classSectionId ?? 0);
if ($classSectionIdInt > 0) {
$scoresLocked = $this->gradingLockModel->isLocked($classSectionIdInt, $semester, $schoolYear);
}
return ['kind' => 'view', 'view' => "grading/{$type}", 'data' => [
'student' => $student,
'scores' => $scores,
'type' => $type,
'classSectionId' => $classSectionId, // ✅ pass it manually
'semester' => $semester, // ✅ Pass semester to the view
'scoresLocked' => $scoresLocked,
]];
}
public function updateScores(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$type = ($post['type'] ?? null);
$studentId = ($post['student_id'] ?? null);
$classSectionId = ($post['class_section_id'] ?? null);
$configModel = new ConfigurationModel();
$studentModel = new StudentModel();
$schoolYear = $configModel->getConfig('school_year');
$semester = getSemester();
$model = $this->getModelByType($type);
$classSectionIdInt = (int) ($classSectionId ?? 0);
if ($classSectionIdInt > 0 && $this->isScoresLocked($classSectionIdInt, $semester, $schoolYear)) {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Scores are locked for this class. Unlock to edit.'];
}
if (in_array($type, ['homework', 'quiz', 'project'])) {
$scoreIds = ($post['score_ids'] ?? null);
$scores = ($post['scores'] ?? null);
$comments = ($post['comments'] ?? null);
foreach ($scoreIds as $i => $id) {
$model->update($id, [
'score' => $scores[$i],
'comment' => $comments[$i] ?? null,
'updated_at' => utc_now()
]);
}
} elseif (in_array($type, ['midterm', 'final', 'test'])) {
$score = ($post['score'] ?? null);
$data = [
'score' => $score,
'updated_at' => utc_now()
];
$existing = $model->where([
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear
])->first();
if ($existing) {
$model->update($existing['id'], $data);
} else {
$data += [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => utc_now()
];
$model->insert($data);
}
} elseif ($type === 'comments') {
$comment = ($post['comment'] ?? null);
$model->where([
'student_id' => $studentId,
'semester' => $semester,
'school_year' => $schoolYear
])->delete(); // Remove existing comments of this type (optional)
$model->insert([
'student_id' => $studentId,
'score_type' => 'general',
'semester' => $semester,
'school_year' => $schoolYear,
'comment' => $comment,
'commented_by' => session()->get('user_id'),
'created_at' => utc_now()
]);
}
$studentTeacherInfo = $studentModel->getStudentInfoByClassSectionId($classSectionId, $semester, $schoolYear);
// Call the updateScoresForStudents method
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
} catch (RuntimeException $e) {
// Handle error
}
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Scores updated successfully.'];
}
public function gradingPage(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$schoolYear = (string) $this->schoolYear;
$configuredSemester = (string) $this->semester;
$requestedClassId = (int) (($get['class_id'] ?? null) ?? 0);
$semesterOptions = $this->getSemestersForSchoolYear($schoolYear, $configuredSemester);
$session = session();
$requestedSemester = $this->normalizeSemesterInput(($get['semester'] ?? null));
$sessionSemester = $this->normalizeSemesterInput($session->get('grading_selected_semester'));
$effectiveRequested = $requestedSemester ?? $sessionSemester;
$semester = trim($this->resolveSemesterSelection($effectiveRequested, $semesterOptions, $configuredSemester));
if ($semester === '') {
$semester = $configuredSemester !== '' ? $configuredSemester : ($semesterOptions[0] ?? 'Fall');
}
if (!in_array($semester, $semesterOptions, true)) {
$semesterOptions[] = $semester;
}
$semesterOptions = array_values(array_unique($semesterOptions));
$session->set('grading_selected_semester', $semester);
$this->ensureParentReleaseKeyExists('Fall');
$this->ensureParentReleaseKeyExists('Spring');
$scoresReleased = $this->getParentScoresReleasedForSemester($semester);
$scoresReleasedFall = $this->getParentScoresReleasedForSemester('Fall');
$scoresReleasedSpring = $this->getParentScoresReleasedForSemester('Spring');
// Refresh PTAP/semester scores for the requested class (if provided) so values are present.
if ($requestedClassId > 0 && $this->semesterScoreService !== null) {
$sectionIds = $this->classSection
->select('class_section_id')
->where('class_id', $requestedClassId)
->findAll();
$sectionIds = array_values(array_filter(array_map(
static fn($row) => (int)($row['class_section_id'] ?? 0),
$sectionIds
), static fn($id) => $id > 0));
foreach ($sectionIds as $sectionId) {
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId(
$sectionId,
$semester,
$schoolYear
);
if (empty($studentTeacherInfo)) {
continue;
}
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
} catch (\Throwable $e) {
log_message(
'error',
'GradingController::grading score refresh failed for section '
. $sectionId . ': ' . $e->getMessage()
);
}
}
}
// Normalize the semester text for safe comparison
$semEsc = $this->db->escape($semester);
$yrEsc = $this->db->escape($schoolYear);
$rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester);
// Preload quiz/homework/project/participation/midterm score counts to distinguish true zeros from empty scores
$quizCounts = [];
$homeworkCounts = [];
$projectCounts = [];
$participationCounts = [];
$midtermCounts = [];
if (!empty($rows)) {
$sectionIds = [];
$studentIds = [];
foreach ($rows as $r) {
$sid = (int) ($r['student_id'] ?? 0);
$sec = (int) ($r['section_id'] ?? 0);
if ($sid > 0) $studentIds[$sid] = true;
if ($sec > 0) $sectionIds[$sec] = true;
}
$sectionIds = array_keys($sectionIds);
$studentIds = array_keys($studentIds);
if (!empty($sectionIds) && !empty($studentIds)) {
$quizRows = $this->db->table('quiz')
->select('student_id, class_section_id, COUNT(*) AS cnt')
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('class_section_id', $sectionIds)
->whereIn('student_id', $studentIds)
->where('score IS NOT NULL', null, false)
->groupBy('student_id, class_section_id')
->get()->getResultArray();
foreach ($quizRows as $qr) {
$sec = (int) ($qr['class_section_id'] ?? 0);
$sid = (int) ($qr['student_id'] ?? 0);
if ($sec > 0 && $sid > 0) {
$quizCounts[$sec][$sid] = (int) ($qr['cnt'] ?? 0);
}
}
$hwRows = $this->db->table('homework')
->select('student_id, class_section_id, COUNT(*) AS cnt')
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('class_section_id', $sectionIds)
->whereIn('student_id', $studentIds)
->where('score IS NOT NULL', null, false)
->groupBy('student_id, class_section_id')
->get()->getResultArray();
foreach ($hwRows as $hr) {
$sec = (int) ($hr['class_section_id'] ?? 0);
$sid = (int) ($hr['student_id'] ?? 0);
if ($sec > 0 && $sid > 0) {
$homeworkCounts[$sec][$sid] = (int) ($hr['cnt'] ?? 0);
}
}
$projectRows = $this->db->table('project')
->select('student_id, class_section_id, COUNT(*) AS cnt')
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('class_section_id', $sectionIds)
->whereIn('student_id', $studentIds)
->where('score IS NOT NULL', null, false)
->groupBy('student_id, class_section_id')
->get()->getResultArray();
foreach ($projectRows as $pr) {
$sec = (int) ($pr['class_section_id'] ?? 0);
$sid = (int) ($pr['student_id'] ?? 0);
if ($sec > 0 && $sid > 0) {
$projectCounts[$sec][$sid] = (int) ($pr['cnt'] ?? 0);
}
}
$participationRows = $this->db->table('participation')
->select('student_id, class_section_id, COUNT(*) AS cnt')
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('class_section_id', $sectionIds)
->whereIn('student_id', $studentIds)
->where('score IS NOT NULL', null, false)
->groupBy('student_id, class_section_id')
->get()->getResultArray();
foreach ($participationRows as $par) {
$sec = (int) ($par['class_section_id'] ?? 0);
$sid = (int) ($par['student_id'] ?? 0);
if ($sec > 0 && $sid > 0) {
$participationCounts[$sec][$sid] = (int) ($par['cnt'] ?? 0);
}
}
$midtermRows = $this->db->table('midterm_exam')
->select('student_id, class_section_id, COUNT(*) AS cnt')
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('class_section_id', $sectionIds)
->whereIn('student_id', $studentIds)
->where('score IS NOT NULL', null, false)
->groupBy('student_id, class_section_id')
->get()->getResultArray();
foreach ($midtermRows as $mr) {
$sec = (int) ($mr['class_section_id'] ?? 0);
$sid = (int) ($mr['student_id'] ?? 0);
if ($sec > 0 && $sid > 0) {
$midtermCounts[$sec][$sid] = (int) ($mr['cnt'] ?? 0);
}
}
}
}
// If any section is missing PTAP or semester score, refresh and reload once
$sectionsNeedingRefresh = [];
foreach ($rows as $r) {
$sectionId = (int) ($r['section_id'] ?? 0);
if ($sectionId <= 0) continue;
$ptapMissing = $r['ss_ptap_score'] === null;
$semMissing = $r['ss_semester_score'] === null;
if ($ptapMissing || $semMissing) {
$sectionsNeedingRefresh[$sectionId] = true;
}
}
if (!empty($sectionsNeedingRefresh) && $this->semesterScoreService !== null) {
foreach (array_keys($sectionsNeedingRefresh) as $sectionId) {
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId(
$sectionId,
$semester,
$schoolYear
);
if (empty($studentTeacherInfo)) {
continue;
}
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
} catch (\Throwable $e) {
log_message(
'error',
'GradingController::grading refresh missing scores for section '
. $sectionId . ': ' . $e->getMessage()
);
}
}
// Reload rows after refresh
$rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester);
}
// Build structures keyed by BUSINESS section id
$grades = []; // class_id => [ ['class_section_id','class_section_name'], ... ]
$studentsBySection = []; // section_id => [ students... ]
$seenStudentsBySection = [];
foreach ($rows as $r) {
$sectionId = (int) ($r['section_id'] ?? 0); // BUSINESS id
$classId = (int) ($r['class_id'] ?? 0);
$sectionName = (string) ($r['class_section_name'] ?? '');
if ($sectionId <= 0 || $classId <= 0) continue;
if (!isset($grades[$classId])) $grades[$classId] = [];
$exists = false;
foreach ($grades[$classId] as $s) {
if ((int)$s['class_section_id'] === $sectionId) {
$exists = true;
break;
}
}
if (!$exists) {
$grades[$classId][] = [
'class_section_id' => $sectionId,
'class_section_name' => $sectionName,
];
}
$sid = (int) ($r['student_id'] ?? 0);
if ($sid <= 0) continue;
if (isset($seenStudentsBySection[$sectionId][$sid])) continue;
$seenStudentsBySection[$sectionId][$sid] = true;
$ptapScore = $r['ss_ptap_score'] ?? null;
$semesterScore = $r['ss_semester_score'] ?? null;
$attendanceScore = $this->calculateAttendanceScoreForStudent(
$sid,
$semester,
$schoolYear,
$sectionId
);
if ($attendanceScore === null) {
$rawAttendance = $r['ss_attendance_score'] ?? null;
if ($rawAttendance !== null && $rawAttendance !== '') {
$attendanceScore = round((float) $rawAttendance, 2);
}
}
$homeworkAvg = isset($r['ss_homework_avg']) && $r['ss_homework_avg'] !== '' ? round((float) $r['ss_homework_avg'], 2) : null;
if ($homeworkAvg !== null && (float) $homeworkAvg === 0.0) {
$hwCount = (int) ($homeworkCounts[$sectionId][$sid] ?? 0);
if ($hwCount === 0) {
$homeworkAvg = null;
}
}
$projectAvg = isset($r['ss_project_avg']) && $r['ss_project_avg'] !== '' ? round((float) $r['ss_project_avg'], 2) : null;
if ($projectAvg !== null && (float) $projectAvg === 0.0) {
$prCount = (int) ($projectCounts[$sectionId][$sid] ?? 0);
if ($prCount === 0) {
$projectAvg = null;
}
}
$participationScore = isset($r['ss_participation_score']) && $r['ss_participation_score'] !== '' ? round((float) $r['ss_participation_score'], 2) : null;
if ($participationScore !== null && (float) $participationScore === 0.0) {
$pCount = (int) ($participationCounts[$sectionId][$sid] ?? 0);
if ($pCount === 0) {
$participationScore = null;
}
}
$midtermExam = isset($r['ss_midterm_exam_score']) && $r['ss_midterm_exam_score'] !== '' ? round((float) $r['ss_midterm_exam_score'], 2) : null;
if ($midtermExam !== null && (float) $midtermExam === 0.0) {
$mCount = (int) ($midtermCounts[$sectionId][$sid] ?? 0);
if ($mCount === 0) {
$midtermExam = null;
}
}
$quizAvg = isset($r['ss_quiz_avg']) && $r['ss_quiz_avg'] !== '' ? round((float) $r['ss_quiz_avg'], 2) : null;
if ($quizAvg !== null && (float) $quizAvg === 0.0) {
$quizCount = (int) ($quizCounts[$sectionId][$sid] ?? 0);
if ($quizCount === 0) {
$quizAvg = null;
}
}
$studentsBySection[$sectionId][] = [
'id' => $sid,
'school_id' => $r['school_id'] ?? null,
'firstname' => $r['firstname'] ?? null,
'lastname' => $r['lastname'] ?? null,
'is_active' => (int)($r['is_active'] ?? 1),
'enrollment_status' => $r['enrollment_status'] ?? '',
'is_withdrawn' => (int)($r['is_withdrawn'] ?? 0),
'class_id' => $classId,
'ptap' => is_null($ptapScore) ? null : round((float) $ptapScore, 2),
'semester_score' => is_null($semesterScore) ? null : round((float) $semesterScore, 2),
'attendance' => $attendanceScore,
'homework_avg' => $homeworkAvg,
'project_avg' => $projectAvg,
'quiz_avg' => $quizAvg,
'participation' => $participationScore,
'midterm_exam' => $midtermExam,
'final_exam' => isset($r['ss_final_exam_score']) && $r['ss_final_exam_score'] !== '' ? round((float) $r['ss_final_exam_score'], 2) : null,
'matched_biz_csid' => $r['matched_biz_csid'] ?? null,
'matched_pk_csid' => $r['matched_pk_csid'] ?? null,
'placement_level' => $r['placement_level'] ?? null,
];
}
$scoreLocks = [];
$lockSectionIds = [];
foreach ($grades as $sections) {
foreach ($sections as $section) {
$sid = (int) ($section['class_section_id'] ?? 0);
if ($sid > 0) {
$lockSectionIds[$sid] = true;
}
}
}
$lockSectionIds = array_keys($lockSectionIds);
if (!empty($lockSectionIds)) {
$lockRows = $this->gradingLockModel
->whereIn('class_section_id', $lockSectionIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
foreach ($lockRows as $row) {
$scoreLocks[(int) ($row['class_section_id'] ?? 0)] = !empty($row['is_locked']);
}
}
return ['kind' => 'view', 'view' => 'grading/grading_main', 'data' => [
'grades' => $grades,
'studentsBySection' => $studentsBySection,
'semester' => $semester,
'schoolYear' => $schoolYear,
'requestedClassId' => $requestedClassId,
'semesterOptions' => $semesterOptions,
'scoresReleased' => $scoresReleased,
'scoresReleasedFall' => $scoresReleasedFall,
'scoresReleasedSpring' => $scoresReleasedSpring,
'scoreLocks' => $scoreLocks,
]];
}
public function toggleScoreLock(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$classSectionId = (int) (($post['class_section_id'] ?? null) ?? 0);
$semester = trim((string) (($post['semester'] ?? null) ?? $this->semester));
$schoolYear = trim((string) (($post['school_year'] ?? null) ?? $this->schoolYear));
if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing class section or term.'];
}
$existing = $this->gradingLockModel->getLock($classSectionId, $semester, $schoolYear);
$userId = (int) (session()->get('user_id') ?? 0);
if (!empty($existing) && !empty($existing['is_locked'])) {
$this->gradingLockModel->update($existing['id'], [
'is_locked' => 0,
'locked_by' => null,
'locked_at' => null,
]);
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Scores unlocked for this class.'];
}
if (!empty($existing)) {
$this->gradingLockModel->update($existing['id'], [
'is_locked' => 1,
'locked_by' => $userId > 0 ? $userId : null,
'locked_at' => utc_now(),
]);
} else {
$this->gradingLockModel->insert([
'class_section_id' => $classSectionId,
'semester' => $semester,
'school_year' => $schoolYear,
'is_locked' => 1,
'locked_by' => $userId > 0 ? $userId : null,
'locked_at' => utc_now(),
]);
}
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Scores locked for this class.'];
}
public function lockAllScores(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$semester = trim((string) (($post['semester'] ?? null) ?? $this->semester));
$schoolYear = trim((string) (($post['school_year'] ?? null) ?? $this->schoolYear));
if ($semester === '' || $schoolYear === '') {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing semester or school year.'];
}
$sectionRows = $this->classSection
->select('class_section_id')
->groupBy('class_section_id')
->findAll();
$sectionIds = array_values(array_filter(array_map(
static fn($row) => (int) ($row['class_section_id'] ?? 0),
$sectionRows
), static fn($id) => $id > 0));
if (empty($sectionIds)) {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No class sections found to lock.'];
}
$existingLocks = $this->gradingLockModel
->whereIn('class_section_id', $sectionIds)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
$existingBySection = [];
foreach ($existingLocks as $row) {
$sid = (int) ($row['class_section_id'] ?? 0);
if ($sid > 0) {
$existingBySection[$sid] = $row;
}
}
$userId = (int) (session()->get('user_id') ?? 0);
$now = utc_now();
$insertRows = [];
foreach ($sectionIds as $sid) {
if (!empty($existingBySection[$sid])) {
if (!empty($existingBySection[$sid]['is_locked'])) {
continue;
}
$this->gradingLockModel->update($existingBySection[$sid]['id'], [
'is_locked' => 1,
'locked_by' => $userId > 0 ? $userId : null,
'locked_at' => $now,
]);
continue;
}
$insertRows[] = [
'class_section_id' => $sid,
'semester' => $semester,
'school_year' => $schoolYear,
'is_locked' => 1,
'locked_by' => $userId > 0 ? $userId : null,
'locked_at' => $now,
'created_at' => $now,
'updated_at' => $now,
];
}
if (!empty($insertRows)) {
$this->gradingLockModel->insertBatch($insertRows);
}
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Scores locked for all classes.'];
}
public function toggleParentScoresRelease(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$semester = (string) (($post['semester'] ?? null) ?? '');
if ($semester === '') {
$semester = (string) (session()->get('grading_selected_semester') ?? $this->semester);
}
$configKey = $this->getParentReleaseKey($semester) ?? 'parent_scores_released';
$releaseScoresRaw = (string) ($this->configModel->getConfig($configKey) ?? '');
$scoresReleased = in_array(strtolower(trim($releaseScoresRaw)), ['1', 'true', 'yes', 'y', 'on'], true);
$nextValue = $scoresReleased ? '0' : '1';
$ok = $this->configModel->setConfigValueByKey($configKey, $nextValue);
log_message('info', 'toggleParentScoresRelease', [
'semester' => $semester,
'config_key' => $configKey,
'prev' => $releaseScoresRaw,
'next' => $nextValue,
'ok' => $ok,
]);
if ($ok) {
$msg = $scoresReleased
? 'Parent exam/semester scores are now hidden.'
: 'Parent exam/semester scores are now released.';
$msg .= ' (' . $configKey . '=' . $nextValue . ')';
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => $msg];
}
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Unable to update the scores release flag.'];
}
public function refreshSemesterScores(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
$classSectionId = (int) (($post['class_section_id'] ?? null) ?? 0);
if ($classSectionId <= 0) {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Missing class section.'];
}
$requestedSemester = (string) (($post['semester'] ?? null) ?? '');
$requestedYear = (string) (($post['school_year'] ?? null) ?? '');
$semester = $this->normalizeSemesterInput($requestedSemester) ?? $requestedSemester;
if ($semester === '') {
$semester = (string) $this->semester;
}
$schoolYear = trim($requestedYear) !== '' ? trim($requestedYear) : (string) $this->schoolYear;
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId(
$classSectionId,
$semester,
$schoolYear
);
if (empty($studentTeacherInfo)) {
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'No students found for this class/term.'];
}
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $schoolYear);
$this->refreshAttendanceComments($classSectionId, $semester, $schoolYear);
} catch (\Throwable $e) {
log_message('error', 'refreshSemesterScores failed: ' . $e->getMessage());
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'error', 'message' => 'Refresh failed. Check logs.'];
}
return ['kind' => 'flash', 'redirect' => 'back', 'type' => 'status', 'message' => 'Semester scores refreshed for this class/term.'];
}
public function getScoreComment(array $params = [])
{
$get = $params['get'] ?? [];
$post = $params['post'] ?? [];
// Get all students for the current semester and school year
$studentClassEntries = $this->studentClassModel
->where('semester', $this->semester)
->where('school_year', $this->schoolYear)
->findAll();
// Group student IDs
$studentIds = array_map(fn($entry) => $entry['student_id'], $studentClassEntries);
// Fetch all scores and comments for the students
$scoresAndComments = $this->getAllScoresAndComments($studentIds, $this->semester, $this->schoolYear);
return $scoresAndComments;
}
public function getModelByType($type)
{
return match ($type) {
'homework' => new HomeworkModel(),
'quiz' => new QuizModel(),
'project' => new ProjectModel(),
'midterm' => new MidtermExamModel(),
'final' => new FinalExamModel(),
'test' => new SemesterScoreModel(), // Assuming you use test_avg here
'comments' => new ScoreCommentModel(),
default => throw new \InvalidArgumentException("Invalid type: $type"),
};
}
public function isScoresLocked(int $classSectionId, string $semester, string $schoolYear): bool
{
return $this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear);
}
public function getParentReleaseKey(string $semester): ?string
{
$norm = strtolower(trim((string) $semester));
if ($norm === 'fall') {
return 'parent_scores_released_fall';
}
if ($norm === 'spring') {
return 'parent_scores_released_spring';
}
return null;
}
public function getParentScoresReleasedForSemester(string $semester): bool
{
$key = $this->getParentReleaseKey($semester);
$raw = $key ? $this->configModel->getConfig($key) : null;
$raw = (string) ($raw ?? '');
return in_array(strtolower(trim($raw)), ['1', 'true', 'yes', 'y', 'on'], true);
}
public function ensureParentReleaseKeyExists(string $semester): void
{
$key = $this->getParentReleaseKey($semester);
if (!$key) {
return;
}
if ($this->configModel->getConfigValueByKey($key) === null) {
$this->configModel->setConfigValueByKey($key, '0');
}
}
public function refreshAttendanceComments(int $classSectionId, string $semester, string $schoolYear): void
{
helper('attendance_comment');
$scoreModel = new SemesterScoreModel();
$commentModel = new ScoreCommentModel();
$scoreRows = $scoreModel
->select(['student_id', 'attendance_score'])
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
if (empty($scoreRows)) {
return;
}
$studentIds = array_values(array_unique(array_map(
static fn($row) => (int) ($row['student_id'] ?? 0),
$scoreRows
)));
$studentIds = array_values(array_filter($studentIds, static fn($id) => $id > 0));
if (empty($studentIds)) {
return;
}
$students = $this->studentModel
->select(['id', 'firstname'])
->whereIn('id', $studentIds)
->findAll();
$nameMap = [];
foreach ($students as $st) {
$nameMap[(int)$st['id']] = (string) ($st['firstname'] ?? '');
}
$existing = $commentModel
->where('score_type', 'attendance')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->whereIn('student_id', $studentIds)
->findAll();
$existingByStudent = [];
foreach ($existing as $row) {
$existingByStudent[(int)$row['student_id']] = $row;
}
foreach ($scoreRows as $row) {
$sid = (int) ($row['student_id'] ?? 0);
if ($sid <= 0) {
continue;
}
$score = isset($row['attendance_score']) ? (float) $row['attendance_score'] : null;
if ($score === null) {
continue;
}
$auto = attendance_comment_from_score($score, $nameMap[$sid] ?? '');
if ($auto === null) {
continue;
}
if (isset($existingByStudent[$sid])) {
$commentModel->update($existingByStudent[$sid]['id'], [
'comment' => $auto,
]);
} else {
$commentModel->insert([
'student_id' => $sid,
'class_section_id' => $classSectionId,
'score_type' => 'attendance',
'semester' => $semester,
'school_year' => $schoolYear,
'comment' => $auto,
'commented_by' => null,
'created_at' => utc_now(),
]);
}
}
}
/**
* Build grading rows with PTAP and semester scores (business + pk join).
*
* @param string $semEsc Escaped semester string for SQL
* @param string $yrEsc Escaped school year string for SQL
* @param string $schoolYear Raw school year value for filtering student_class
* @return array
*/
public function buildGradingRows(string $semEsc, string $yrEsc, string $schoolYear, string $semesterRaw): array
{
$builder = $this->db->table('student_class sc')
->select([
'cs.id AS section_pk',
'cs.class_section_id AS section_id', // BUSINESS id used in URLs
'cs.class_id AS class_id', // 13=KG, 1..11=Grade N, 12=Youth
'cs.class_section_name',
's.id AS student_id',
's.school_id',
's.firstname',
's.lastname',
's.is_active',
'e.enrollment_status',
'e.is_withdrawn',
'pl.level AS placement_level',
// Prefer business-id match; fall back to pk match
'COALESCE(ss_b.ptap_score, ss_p.ptap_score) AS ss_ptap_score',
'COALESCE(ss_b.semester_score, ss_p.semester_score) AS ss_semester_score',
'COALESCE(ss_b.attendance_score, ss_p.attendance_score) AS ss_attendance_score',
'COALESCE(ss_b.homework_avg, ss_p.homework_avg) AS ss_homework_avg',
'COALESCE(ss_b.project_avg, ss_p.project_avg) AS ss_project_avg',
'COALESCE(ss_b.quiz_avg, ss_p.quiz_avg) AS ss_quiz_avg',
'COALESCE(ss_b.participation_score, ss_p.participation_score) AS ss_participation_score',
'COALESCE(ss_b.midterm_exam_score, ss_p.midterm_exam_score) AS ss_midterm_exam_score',
'COALESCE(ss_b.final_exam_score, ss_p.final_exam_score) AS ss_final_exam_score',
// helpful to debug what matched:
'ss_b.class_section_id AS matched_biz_csid',
'ss_p.class_section_id AS matched_pk_csid'
])
->distinct()
->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('students s', 's.id = sc.student_id', 'inner')
->join(
'enrollments e',
"e.student_id = s.id AND e.school_year = {$yrEsc}",
'left'
)
->join(
'placement_levels pl',
'pl.student_id = s.id',
'left'
)
// business-id join
->join(
'semester_scores ss_b',
"ss_b.student_id = s.id
AND ss_b.class_section_id = sc.class_section_id
AND LOWER(ss_b.semester) = LOWER(TRIM({$semEsc}))
AND ss_b.school_year = {$yrEsc}",
'left'
)
// pk join
->join(
'semester_scores ss_p',
"ss_p.student_id = s.id
AND ss_p.class_section_id = cs.id
AND LOWER(ss_p.semester) = LOWER(TRIM({$semEsc}))
AND ss_p.school_year = {$yrEsc}",
'left'
)
->where('sc.school_year', $schoolYear)
->groupStart()
->where('s.is_active', 1)
->orWhere("LOWER(TRIM(e.enrollment_status)) IN ('denied','withdrawn','waitlist')", null, false)
->orWhere('e.is_withdrawn', 1)
->groupEnd();
return $builder
->orderBy('cs.class_id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('s.lastname', 'ASC')
->orderBy('s.firstname', 'ASC')
->get()->getResultArray();
}
public function calculateAttendanceScoreForStudent(int $studentId, string $semester, string $schoolYear, ?int $classSectionId = null): ?float
{
try {
$result = $this->attendanceCalculator->calculate($studentId, $semester, $schoolYear, $classSectionId);
$score = $result['attendance_score'] ?? null;
if ($score === null || $score === '') {
return null;
}
return round((float) $score, 2);
} catch (\Throwable $e) {
log_message(
'error',
'GradingController::calculateAttendanceScoreForStudent failed for '
. "student {$studentId}: " . $e->getMessage()
);
}
return null;
}
public function calculateTrophyThreshold(array $scores, float $percentile = 75.0): array
{
$scores = array_values(array_filter(
$scores,
static fn ($value): bool => is_numeric($value) && $value !== null
));
$scores = array_map('floatval', $scores);
sort($scores);
$count = count($scores);
if ($count === 0) {
return ['threshold' => null, 'winners' => 0, 'method' => 'empty'];
}
$minWinners = 3;
$maxWinners = max($minWinners, (int) floor($count * (1 - $percentile / 100)));
$threshold = $this->empiricalTrophyPercentile($scores, $percentile);
$winners = $this->countScoresAtOrAbove($scores, $threshold);
if ($winners < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_reduced'];
}
if ($winners <= $maxWinners) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'empirical_percentile'];
}
$result = $this->capTrophyThresholdByRank($scores, $maxWinners);
if ($result['winners'] < $minWinners) {
$target = min($minWinners, $count);
$descending = array_reverse($scores);
$threshold = $descending[$target - 1];
$winners = $this->countScoresAtOrAbove($scores, $threshold);
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'min3_after_cap'];
}
return $result;
}
public function capTrophyThresholdByRank(array $sortedScores, int $max): array
{
$descending = array_reverse($sortedScores);
$threshold = $descending[$max - 1];
$winners = $this->countScoresAtOrAbove($sortedScores, $threshold);
if ($winners <= $max) {
return ['threshold' => $threshold, 'winners' => $winners, 'method' => 'capped_25pct'];
}
$uniqueHigherScores = array_values(array_unique(array_filter(
$sortedScores,
static fn ($score): bool => $score > $threshold
)));
sort($uniqueHigherScores);
foreach ($uniqueHigherScores as $candidate) {
$winnerCount = $this->countScoresAtOrAbove($sortedScores, $candidate);
if ($winnerCount <= $max) {
return ['threshold' => $candidate, 'winners' => $winnerCount, 'method' => 'capped_25pct'];
}
}
return ['threshold' => $sortedScores[0], 'winners' => count($sortedScores), 'method' => 'all_equal'];
}
public function empiricalTrophyPercentile(array $sortedScores, float $percentile): float
{
$count = count($sortedScores);
if ($count === 0) {
return 0.0;
}
$index = ($percentile / 100.0) * ($count - 1);
$lower = (int) floor($index);
$upper = (int) ceil($index);
if ($lower === $upper) {
return $sortedScores[$lower];
}
return $sortedScores[$lower] + ($index - $lower) * ($sortedScores[$upper] - $sortedScores[$lower]);
}
public function countScoresAtOrAbove(array $scores, float $threshold): int
{
return count(array_filter(
$scores,
static fn ($score): bool => $score >= $threshold
));
}
/**
* Fetch all scores and comments for a list of students based on semester and school year.
*
* @param array $studentIds List of student IDs.
* @param string $semester Current semester (e.g., 'fall', 'spring').
* @param string $schoolYear Current school year (e.g., '2025-2026').
* @return array
*/
public function getAllScoresAndComments($studentIds, $semester, $schoolYear)
{
// Validate input parameters
if (empty($studentIds) || !is_array($studentIds)) {
return [];
}
if (empty($this->semester) || empty($this->schoolYear)) {
throw new \InvalidArgumentException('Semester and school year must be provided');
}
// Initialize models
$models = [
'final_exam' => new FinalExamModel(),
'homework' => new HomeworkModel(),
'midterm' => new MidtermExamModel(),
'project' => new ProjectModel(),
'quiz' => new QuizModel(),
'comments' => new ScoreCommentModel(),
'semester_scores' => new SemesterScoreModel(),
'student' => new StudentModel(),
'student_class' => new StudentClassModel(),
'teacher_class' => new TeacherClassModel(),
'config' => new ConfigurationModel(),
'attendance' => new AttendanceRecordModel()
];
// Get semester days configuration
$semesterKey = strtolower($semester) === 'fall' ? 'total_semester1_days' : 'total_semester2_days';
$totalSemesterDays = $models['config']->getConfig($semesterKey) ?? 0;
// Common query conditions
$conditions = [
'semester' => $this->semester,
'school_year' => $this->schoolYear
];
// Fetch all student data first
$students = $models['student']->whereIn('id', $studentIds)
->where('school_year', $this->schoolYear)
->findAll();
if (empty($students)) {
return [];
}
// Initialize result array with student data
$allScores = [];
foreach ($students as $student) {
$className = $models['student_class']->getClassSectionsByStudentId($student['id'], $this->schoolYear);
$updatedBy = $models['teacher_class']->getTeacherIdByClassSection($className, $this->semester, $this->schoolYear);
$allScores[$student['id']] = [
'school_id' => $student['school_id'],
'firstname' => $student['firstname'],
'lastname' => $student['lastname'],
'class_name' => $className,
//'teacherId' => $updatedBy,
'comments' => [] // Initialize empty comments array
];
}
// Fetch and process attendance data
foreach ($studentIds as $studentId) {
if (!isset($allScores[$studentId])) continue;
$absences = $models['attendance']->getTotalAbsences($studentId, $this->semester, $this->schoolYear);
$attendance = min((($totalSemesterDays - $absences + 1) / $totalSemesterDays) * 100, 100);
$allScores[$studentId]['attendance'] = [
'score' => round($attendance, 2),
'absences' => $absences,
'total_days' => $totalSemesterDays
];
}
// Fetch and process all score types
$scoreTypes = [
'final_exam' => $models['final_exam'],
'homework' => $models['homework'],
'midterm' => $models['midterm'],
'project' => $models['project'],
'quiz' => $models['quiz'],
'semester_score' => $models['semester_scores']
];
foreach ($scoreTypes as $type => $model) {
$scores = $model->whereIn('student_id', $studentIds)
->where($conditions)
->findAll();
foreach ($scores as $score) {
if ($type === 'semester_score') {
$allScores[$score['student_id']][$type] = [
'homework_avg' => $score['homework_avg'],
'quiz_avg' => $score['quiz_avg'],
'project_avg' => $score['project_avg'],
'midterm_exam_score' => $score['midterm_exam_score'],
'final_exam_score' => $score['final_exam_score'],
'attendance_score' => $score['attendance_score'],
'participation_score' => $score['participation_score'],
'ptap_score' => $score['ptap_score'],
'test_avg' => $score['test_avg'],
'semester_score' => $score['semester_score'],
'semester' => $score['semester'],
'school_year' => $score['school_year']
];
} else {
$allScores[$score['student_id']][$type] = $score;
}
}
}
// Fetch and process comments
$comments = $models['comments']->whereIn('student_id', $studentIds)
->where($conditions)
->findAll();
foreach ($comments as $comment) {
$allScores[$comment['student_id']]['comments'][] = $comment;
}
return $allScores;
}
public function normalizeSemesterInput(?string $semester): ?string
{
if (!is_string($semester)) {
return null;
}
$trimmed = trim($semester);
return $trimmed === '' ? null : $trimmed;
}
public function resolveSemesterSelection(?string $requestedSemester, array $semesterOptions, ?string $fallbackSemester): string
{
if ($requestedSemester !== null) {
foreach ($semesterOptions as $option) {
if (strcasecmp($option, $requestedSemester) === 0) {
return $option;
}
}
return $requestedSemester;
}
if ($fallbackSemester !== null && $fallbackSemester !== '') {
foreach ($semesterOptions as $option) {
if (strcasecmp($option, $fallbackSemester) === 0) {
return $option;
}
}
return $fallbackSemester;
}
return $semesterOptions[0] ?? '';
}
public function getSemestersForSchoolYear(string $schoolYear, ?string $fallbackSemester = null): array
{
$rows = $this->db->table('semester_scores')
->select('DISTINCT semester', false)
->where('semester IS NOT NULL', null, false)
->where('semester != ""', null, false)
->where('school_year', $schoolYear)
->orderBy('semester', 'ASC')
->get()
->getResultArray();
$semesters = [];
foreach ($rows as $row) {
$value = trim((string) ($row['semester'] ?? ''));
if ($value === '') continue;
$semesters[] = $value;
}
if (empty($semesters)) {
$semesters = ['Fall', 'Spring'];
}
if ($fallbackSemester !== null && $fallbackSemester !== '' && !in_array($fallbackSemester, $semesters, true)) {
array_unshift($semesters, $fallbackSemester);
}
return array_values(array_unique($semesters));
}
public function getSchoolYearsForScores(?string $fallback = null): array
{
$schoolYears = [];
try {
$rows = $this->db->table('semester_scores')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->where('school_year != ""', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows as $row) {
$val = (string)($row['school_year'] ?? '');
if ($val !== '') $schoolYears[] = $val;
}
} catch (\Throwable $e) {
}
try {
$rows2 = $this->db->table('student_class')
->select('DISTINCT school_year', false)
->where('school_year IS NOT NULL', null, false)
->where('school_year != ""', null, false)
->orderBy('school_year', 'DESC')
->get()
->getResultArray();
foreach ($rows2 as $row) {
$val = (string)($row['school_year'] ?? '');
if ($val !== '' && !in_array($val, $schoolYears, true)) $schoolYears[] = $val;
}
} catch (\Throwable $e) {
}
if ($fallback && !in_array($fallback, $schoolYears, true)) {
array_unshift($schoolYears, $fallback);
}
return array_values(array_unique($schoolYears));
}
public function userHasMenuUrl(string $needle): bool
{
$needle = strtolower(trim($needle));
if ($needle === '') {
return false;
}
$rawRole = session()->get('role');
$roles = is_array($rawRole) ? $rawRole : [$rawRole ?? 'guest'];
$roles = array_values(array_filter(array_map('strval', $roles)));
if (empty($roles)) {
return false;
}
$service = new NavbarService();
$menu = $service->getMenuForRoles($roles);
if (empty($menu)) {
return false;
}
$normalize = static function (string $url) use ($needle): string {
$url = strtolower(trim($url));
if ($url === '') return '';
$url = preg_replace('#^https?://[^/]+/#i', '', $url);
$url = ltrim($url, '/');
return $url;
};
$target = $normalize($needle);
$stack = $menu;
while (!empty($stack)) {
$node = array_shift($stack);
if (!empty($node['url'])) {
$url = $normalize((string)$node['url']);
if ($url !== '' && $url === $target) {
return true;
}
}
if (!empty($node['children']) && is_array($node['children'])) {
foreach ($node['children'] as $child) {
$stack[] = $child;
}
}
}
return false;
}
/**
* Parses a classSection name into a (classId, label) pair compatible with your Attendance view.
* - KG -> (0, 'KG')
* - Youth-> (13, 'Youth')
* - Grade N -> (N, 'Grade N')
* Fallback: tries to extract first number; else returns (99, original).
*/
public function parseClassIdFromSectionName(string $name): array
{
$n = trim($name);
// Common patterns used in your data
if (preg_match('/\bKG\b/i', $n)) {
return [0, 'KG'];
}
if (preg_match('/\bYouth\b/i', $n)) {
return [13, 'Youth'];
}
if (preg_match('/Grade\s*(\d+)/i', $n, $m)) {
$g = (int)$m[1];
return [$g, 'Grade ' . $g];
}
// Fallback: any number present becomes the grade id
if (preg_match('/(\d+)/', $n, $m2)) {
$g = (int)$m2[1];
return [$g, 'Grade ' . $g];
}
// Last fallback
return [99, $n];
}
/**
* Resolve class_section_id for a student in the current term.
*/
public function resolveClassSectionIdForStudent(int $studentId): int
{
$row = $this->db->table('student_class')
->select('class_section_id')
->where('student_id', $studentId)
->where('school_year', $this->schoolYear)
->where('semester', $this->semester)
->get()->getRow();
return $row ? (int)$row->class_section_id : 0;
}
/** Simple slugifier used as array keys for tabs */
public function slugify(string $s): string
{
$s = strtolower($s);
$s = preg_replace('/[^a-z0-9]+/i', '-', $s);
return trim($s, '-');
}
/** Rank: KG first, numbers ascending, Youth last */
public function rankOf(string $name): int
{
$k = strtolower(trim($name));
if ($k === 'kg' || $k === 'kindergarten') return -100;
if ($k === 'youth') return 100000;
if (preg_match('/\d+/', $k, $m)) return (int)$m[0];
return 50000;
}
}