Files
alrahma_sunday_school/app/Controllers/View/ScoreController.php
T
2026-05-16 13:44:12 -04:00

1439 lines
58 KiB
PHP
Executable File

<?php
namespace App\Controllers\View;
use CodeIgniter\Controller;
use App\Models\TeacherClassModel;
use App\Models\StudentClassModel;
use App\Models\StudentModel;
use App\Models\QuizModel;
use App\Models\HomeworkModel;
use App\Models\FinalExamModel;
use App\Models\MidtermExamModel;
use App\Models\ProjectModel;
use App\Models\SemesterScoreModel;
use App\Models\ConfigurationModel;
use App\Models\ScoreCommentModel;
use App\Models\ParticipationModel;
use App\Models\AttendanceRecordModel;
use App\Models\AttendanceDataModel;
use App\Models\ClassSectionModel;
use App\Models\UserModel;
use CodeIgniter\Events\Events;
use Config\Services;
use App\Models\CalendarModel;
use App\Services\Calculators\AttendanceCalculator;
use App\Models\GradingLockModel;
use App\Models\MissingScoreOverrideModel;
class ScoreController extends Controller
{
protected $semesterScoreService;
protected $db;
protected $configModel;
protected $semester;
protected $schoolYear;
protected $classSectionModel;
protected $studentClassModel;
protected $teacherClassModel;
protected $userModel;
protected $targetHigh;
protected $targetLow;
protected $semesterScoreModel;
protected $studentModel;
protected $calendarModel;
protected $attendanceCalculator;
protected $gradingLockModel;
protected $missingScoreOverrideModel;
public function __construct()
{
// Load the database service
$this->db = \Config\Database::connect();
// Check if the database connection is established
if (!$this->db->connect()) {
log_message('error', 'Database connection failed.');
throw new \Exception('Database connection failed.');
} else {
log_message('info', 'Database connection successful.');
}
// Initialize the config model
$this->configModel = new ConfigurationModel();
$this->classSectionModel = new ClassSectionModel();
$this->teacherClassModel = new TeacherClassModel();
$this->semesterScoreModel = new SemesterScoreModel();
$this->studentModel = new StudentModel();
$this->studentClassModel = new StudentClassModel();
$this->userModel = new UserModel();
$this->calendarModel = new CalendarModel();
$this->attendanceCalculator = new AttendanceCalculator(
new AttendanceRecordModel(),
$this->configModel,
$this->calendarModel
);
$this->gradingLockModel = new GradingLockModel();
$this->missingScoreOverrideModel = new MissingScoreOverrideModel();
// Retrieve the configuration values
$this->semester = $this->configModel->getConfig('semester');
$this->schoolYear = $this->configModel->getConfig('school_year');
$this->targetHigh = $this->configModel->getConfig('trophy_score');
$this->targetLow = $this->configModel->getConfig('pass_score');
$this->semesterScoreService = Services::semesterScoreService();
}
public function index()
{
log_message('debug', 'ScoreController::index invoked');
$semesterChoiceParam = $this->request->getGet('semester_choice');
$session = session();
if ($this->request->getGet('choose_semester') !== null) {
$session->remove('teacher_scores_selected_semester');
$session->remove('semester');
$semesterChoiceParam = null;
}
$normalizedChoice = $this->normalizeSemester($semesterChoiceParam);
$sessionSelected = $session->get('teacher_scores_selected_semester');
$selectedSemesterLabel = '';
if ($normalizedChoice !== '') {
$selectedSemesterLabel = ucfirst($normalizedChoice);
$session->set('teacher_scores_selected_semester', $selectedSemesterLabel);
$session->set('semester', $selectedSemesterLabel);
} elseif (!empty($sessionSelected)) {
$selectedSemesterLabel = $sessionSelected;
} else {
$invalidChoice = null;
if ($semesterChoiceParam !== null && $semesterChoiceParam !== '') {
$invalidChoice = $semesterChoiceParam;
}
return view('teacher/select_semester', [
'fallUrl' => base_url('/teacher/scores?semester_choice=Fall'),
'springUrl' => base_url('/teacher/scores?semester_choice=Spring'),
'invalidChoice' => $invalidChoice,
]);
}
$focusTarget = $this->request->getGet('focus');
$focusTarget = in_array($focusTarget, ['scores', 'comments'], true) ? $focusTarget : null;
// Step 1: Get the teacher_id from the session
$teacherId = session()->get('user_id');
$teacher = $this->userModel->find($teacherId);
$teacherFirst = $teacher['firstname'] ?? $teacher['first_name'] ?? '';
$teacherLast = $teacher['lastname'] ?? $teacher['last_name'] ?? '';
$teacherName = ($teacherFirst !== '' || $teacherLast !== '')
? trim($teacherFirst . ' ' . $teacherLast)
: 'Unknown';
$activeSemester = $selectedSemesterLabel;
$effectiveSemester = $activeSemester;
$effectiveSchoolYear = $this->schoolYear;
// ✅ Get class section ID for the active term (respect active selection)
$assignments = $this->teacherClassModel->getClassAssignmentsByUserId(
(int)$teacherId,
(string)$effectiveSchoolYear,
(string)$effectiveSemester
);
if (empty($assignments)) {
// Fallback: any class regardless of term
$fallback = $this->teacherClassModel
->where('teacher_id', $teacherId)
->first();
if ($fallback) {
$assignments = [[
'class_section_id' => $fallback['class_section_id'],
'class_section_name' => null,
'school_year' => $fallback['school_year'] ?? $effectiveSchoolYear,
'semester' => $fallback['semester'] ?? $effectiveSemester,
]];
$effectiveSemester = $fallback['semester'] ?? $effectiveSemester;
$effectiveSchoolYear = $fallback['school_year'] ?? $effectiveSchoolYear;
} else {
log_message('info', 'No class section assigned to teacher ID: ' . $teacherId);
return redirect()
->to('no-classes')
->with('message', 'You do not have an assigned class yet. Please contact the administration.');
}
}
$allowedIds = array_map(static fn($a) => (int)($a['class_section_id'] ?? 0), $assignments);
$allowedIds = array_values(array_filter(array_unique($allowedIds)));
$requestedId = (int)($this->request->getGet('class_section_id') ?? 0);
$sessionId = (int)(session()->get('class_section_id') ?? 0);
$chosenAssignment = null;
if ($requestedId && in_array($requestedId, $allowedIds, true)) {
$classSectionId = $requestedId;
foreach ($assignments as $a) {
if ((int)$a['class_section_id'] === $requestedId) { $chosenAssignment = $a; break; }
}
} elseif ($sessionId && in_array($sessionId, $allowedIds, true)) {
$classSectionId = $sessionId;
foreach ($assignments as $a) {
if ((int)$a['class_section_id'] === $sessionId) { $chosenAssignment = $a; break; }
}
} else {
$classSectionId = $allowedIds[0];
foreach ($assignments as $a) {
if ((int)$a['class_section_id'] === $classSectionId) { $chosenAssignment = $a; break; }
}
}
session()->set('class_section_id', $classSectionId);
if ($chosenAssignment) {
$effectiveSemester = $chosenAssignment['semester'] ?? $effectiveSemester;
$effectiveSchoolYear = $chosenAssignment['school_year'] ?? $effectiveSchoolYear;
}
// Build assigned teacher/TA names for this section (current term)
$rows = $this->db->table('teacher_class tc')
->select('tc.position, u.firstname, u.lastname')
->join('users u', 'u.id = tc.teacher_id', 'inner')
->where('tc.class_section_id', $classSectionId)
->where('tc.school_year', $effectiveSchoolYear)
->orderBy("FIELD(tc.position,'main','ta')", '', false)
->orderBy('u.firstname', 'ASC')
->get()->getResultArray();
$mains = [];
$tas = [];
foreach ($rows as $r) {
$name = trim(($r['firstname'] ?? '') . ' ' . ($r['lastname'] ?? ''));
if ($name === '') continue;
$pos = strtolower((string)($r['position'] ?? ''));
if (in_array($pos, ['main', 'teacher'], true)) {
$mains[] = $name;
} elseif (in_array($pos, ['ta', 'assistant', 'teacher_assistant'], true)) {
$tas[] = $name;
}
}
if (empty($mains) && !empty($teacherName)) {
$mains = [$teacherName];
}
$mainsText = implode(', ', $mains);
$tasText = implode(', ', $tas);
// Resolve class section name (by section code)
$csRow = $this->db->table('classSection')
->select('class_section_name')
->where('class_section_id', $classSectionId)
->get()->getRowArray();
$classSectionName = $csRow['class_section_name'] ?? '';
log_message('debug', "ScoreController::index teacher {$teacherId} classSection {$classSectionId} semester {$effectiveSemester}");
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId(
$classSectionId,
$effectiveSemester,
$effectiveSchoolYear
);
if (!empty($studentTeacherInfo) && $this->semesterScoreService !== null) {
try {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo);
} catch (\Throwable $e) {
log_message('error', 'ScoreController::index semester score refresh failed: ' . $e->getMessage());
}
}
// Step 2: Retrieve the student scores data
$students = $this->getStudentScores($classSectionId, $effectiveSemester, $effectiveSchoolYear, $teacherId);
// Step 3: Sort the students array by last name and then by first name
usort($students, function ($a, $b) {
$lastNameComparison = strcmp($a['lastname'], $b['lastname']);
return $lastNameComparison === 0
? strcmp($a['firstname'], $b['firstname'])
: $lastNameComparison;
});
if (ENVIRONMENT !== 'production') {
$sampleRows = array_map(static function ($student) {
return [
'id' => $student['student_id'],
'attendance_score' => $student['attendance_score'] ?? null,
'ptap_score' => $student['ptap_score'] ?? null,
'final_exam' => $student['final_exam'][0] ?? null,
];
}, array_slice($students, 0, 5));
log_message('debug', 'ScoreController::index sample rows: ' . json_encode($sampleRows));
}
$scoresLocked = $this->gradingLockModel->isLocked(
(int) $classSectionId,
(string) $effectiveSemester,
(string) $effectiveSchoolYear
);
// Step 4: Pass everything to the view
$data = [
'students' => $students,
'teacher_name' => $teacherName,
'class_section_id' => $classSectionId,
'class_section_name' => $classSectionName,
'mainsText' => $mainsText,
'tasText' => $tasText,
'activeSemester' => $activeSemester,
'selectedSemester' => $activeSemester,
'focusTarget' => $focusTarget,
'schoolYear' => $effectiveSchoolYear,
'scoresLocked' => $scoresLocked,
];
return view('/teacher/scores', $data);
}
public function submitScoresLock()
{
$classSectionId = (int) ($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0);
$semesterRaw = (string) ($this->request->getPost('semester') ?? $this->request->getPost('selected_semester') ?? '');
$semesterNormalized = $this->normalizeSemester($semesterRaw);
$semester = $semesterNormalized !== '' ? ucfirst($semesterNormalized) : $this->semester;
$schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear);
if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') {
return redirect()->back()->with('error', 'Missing class section or term.');
}
$missingOk = $this->request->getPost('missing_ok') ?? [];
$studentIds = $this->request->getPost('student_ids') ?? array_keys((array) $missingOk);
$studentIds = array_map('intval', (array) $studentIds);
$teacherId = (int) (session()->get('user_id') ?? 0);
$isFall = strtolower($semester) === 'fall';
$isSpring = strtolower($semester) === 'spring';
$commentTypes = ['ptap_comment'];
if ($isFall) {
$commentTypes[] = 'midterm_comment';
}
if ($isSpring) {
$commentTypes[] = 'final_comment';
}
foreach ($commentTypes as $type) {
$checkedItems = [];
if (is_array($missingOk)) {
foreach ($missingOk as $studentId => $types) {
if (!is_array($types) || empty($types[$type])) {
continue;
}
$checkedItems[] = [
'student_id' => (int) $studentId,
'item_index' => null,
];
}
}
$this->missingScoreOverrideModel->replaceOverrides(
$classSectionId,
(string) $semester,
(string) $schoolYear,
$type,
$studentIds,
null,
$checkedItems,
$teacherId,
true
);
}
$existing = $this->gradingLockModel->getLock($classSectionId, $semester, $schoolYear);
if (!empty($existing) && !empty($existing['is_locked'])) {
return redirect()->back()->with('status', 'Scores already submitted and locked.');
}
$userId = $teacherId;
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 redirect()->back()->with('status', 'Scores submitted and locked for this semester.');
}
public function noClasses()
{
return view('/teacher/no_classes');
}
private function getStudentScores(int $classSectionId, $semester, $schoolYear, int $teacherId)
{
if (!$semester || !$schoolYear) {
throw new \RuntimeException('Semester or school year configuration is missing.');
}
$totalSemesterDays = null;
$normSemester = $this->normalizeSemester($semester);
if ($schoolYear !== '' && $normSemester !== '') {
$semRange = $this->getSemesterRange($schoolYear, $normSemester);
if ($semRange) {
try {
$events = $this->calendarModel->getEventsBySchoolYearAndSemester($schoolYear, $normSemester);
$noSchoolSundays = 0;
foreach ($events as $event) {
if (!empty($event['no_school'])) {
$date = new \DateTime($event['date']);
if ($date->format('N') == 7) {
if ($date->format('Y-m-d') >= $semRange[0] && $date->format('Y-m-d') <= $semRange[1]) {
$noSchoolSundays++;
}
}
}
}
$totalSemesterDays = $this->countSundays($semRange[0], $semRange[1]) - $noSchoolSundays;
} catch (\Throwable $e) {
log_message('error', 'Failed to count sundays for score calculation: ' . $e->getMessage());
$totalSemesterDays = null;
}
}
}
if ($totalSemesterDays === null) {
$totalSemesterDays = strtolower($semester) === 'fall'
? $this->configModel->getConfig('total_semester1_days')
: $this->configModel->getConfig('total_semester2_days');
}
if (!$totalSemesterDays) {
throw new \RuntimeException('Total semester days configuration is missing.');
}
if ($classSectionId <= 0) {
throw new \RuntimeException('Invalid class section for the current teacher.');
}
$students = $this->prepareStudents($classSectionId, $teacherId, $schoolYear);
$studentIds = array_keys($students);
// Load regular scores
$this->loadScoresByType(new HomeworkModel(), 'homework', $students, $classSectionId, $semester, $schoolYear);
$this->loadScoresByType(new QuizModel(), 'quiz', $students, $classSectionId, $semester, $schoolYear);
$this->loadScoresByType(new ProjectModel(), 'project', $students, $classSectionId, $semester, $schoolYear);
$this->loadScoresByType(new MidtermExamModel(), 'midterm', $students, $classSectionId, $semester, $schoolYear);
$this->loadScoresByType(new FinalExamModel(), 'final_exam', $students, $classSectionId, $semester, $schoolYear);
$this->loadScoresByType(new ParticipationModel(), 'participation', $students, $classSectionId, $semester, $schoolYear);
$this->calculateStudentScores($students, $classSectionId, $semester, $totalSemesterDays);
// Load all semester scores in one query
$this->loadSemesterScores($students, $classSectionId, $semester, $schoolYear);
$this->loadComments(new ScoreCommentModel(), $students, $studentIds, $semester, $schoolYear);
$homeworkIndexes = $this->loadIndexedScores(new HomeworkModel(), 'homework_index', $students, $classSectionId, $semester, $schoolYear, 'homework_scores_by_index');
$quizIndexes = $this->loadIndexedScores(new QuizModel(), 'quiz_index', $students, $classSectionId, $semester, $schoolYear, 'quiz_scores_by_index');
$projectIndexes = $this->loadIndexedScores(new ProjectModel(), 'project_index', $students, $classSectionId, $semester, $schoolYear, 'project_scores_by_index');
$this->loadMissingOverrides($students, $classSectionId, $semester, $schoolYear);
$this->buildMissingItems($students, $homeworkIndexes, $quizIndexes, $projectIndexes, $semester);
return $students;
}
private function loadSemesterScores(&$students, $classSectionId, $semester, $schoolYear)
{
// Get all scores in one query
$results = $this->semesterScoreModel->select('student_id, attendance_score, ptap_score, semester_score, midterm_exam_score, final_exam_score')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
foreach ($results as $row) {
$studentId = $row['student_id'];
if (isset($students[$studentId])) {
$attendanceScore = $this->roundScore($row['attendance_score'] ?? null);
if ($attendanceScore !== null && !isset($students[$studentId]['attendance_score'])) {
$students[$studentId]['attendance_score'] = $attendanceScore;
}
$ptapScore = $this->roundScore($row['ptap_score'] ?? null);
if ($ptapScore !== null && !isset($students[$studentId]['ptap_score'])) {
$students[$studentId]['ptap_score'] = $ptapScore;
}
$semesterScore = $this->roundScore($row['semester_score'] ?? null);
if ($semesterScore !== null && !isset($students[$studentId]['semester_score'])) {
$students[$studentId]['semester_score'] = $semesterScore;
}
$midtermScore = $this->roundScore($row['midterm_exam_score'] ?? null);
if ($midtermScore !== null && empty($students[$studentId]['midterm'])) {
$students[$studentId]['midterm'][] = $midtermScore;
}
$finalExamScore = $this->roundScore($row['final_exam_score'] ?? null);
if ($finalExamScore !== null && empty($students[$studentId]['final_exam'])) {
$students[$studentId]['final_exam'][] = $finalExamScore;
}
}
}
}
private function roundScore($value): ?float
{
if ($value === null) {
return null;
}
// Ensure value is numeric before rounding
return is_numeric($value) ? round((float)$value, 1) : null;
}
private function prepareStudents($classSectionId, $teacherId, $schoolYear)
{
$students = [];
$studentClasses = $this->studentClassModel
->active()
->where('student_class.class_section_id', $classSectionId)
->findAll();
foreach ($studentClasses as $studentClass) {
$student = $this->studentModel
->select('id, firstname, lastname, age, school_id')
->where('id', $studentClass['student_id'])
->where('is_active', 1)
->first();
if ($student) {
$students[$student['id']] = [
'student_id' => $student['id'],
'firstname' => $student['firstname'],
'lastname' => $student['lastname'],
'age' => $student['age'],
'school_id' => $student['school_id'],
'class_section_id' => $classSectionId,
'school_year' => $schoolYear,
'teacher_id' => $teacherId,
'homework' => [],
'quiz' => [],
'midterm' => [],
'final_exam' => [],
'project' => [],
'participation' => [],
'ptap' => [],
'ptap_comment' => '',
'midterm_comment' => '',
'final_comment' => ''
];
}
}
return $students;
}
private function loadScoresByType($model, $type, &$students, $classSectionId, $semester, $schoolYear)
{
$records = $model->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
foreach ($records as $rec) {
if (!isset($students[$rec['student_id']])) {
continue;
}
// Only include real numeric scores; leave null/blank out entirely
if ($rec['score'] === null || $rec['score'] === '') {
continue;
}
if (!is_numeric($rec['score'])) {
continue;
}
$students[$rec['student_id']][$type][] = (float) $rec['score'];
}
}
private function loadIndexedScores($model, string $indexField, array &$students, int $classSectionId, $semester, $schoolYear, string $storeKey): array
{
$records = $model->select("student_id, {$indexField} as idx, score")
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->findAll();
$indexes = [];
foreach ($records as $rec) {
$sid = (int) ($rec['student_id'] ?? 0);
$idx = (int) ($rec['idx'] ?? 0);
if ($idx <= 0) {
continue;
}
$indexes[$idx] = true;
if (isset($students[$sid])) {
if (!isset($students[$sid][$storeKey]) || !is_array($students[$sid][$storeKey])) {
$students[$sid][$storeKey] = [];
}
$students[$sid][$storeKey][$idx] = $rec['score'] ?? null;
}
}
$indexList = array_keys($indexes);
sort($indexList, SORT_NUMERIC);
return $indexList;
}
private function loadComments($commentModel, &$students, $studentIds, $semester, $schoolYear)
{
if (empty($studentIds)) return;
$semVariants = array_values(array_unique(array_filter([
$semester,
strtolower((string)$semester),
ucfirst(strtolower((string)$semester)),
], static fn($v) => $v !== '')));
$comments = $commentModel->whereIn('student_id', $studentIds)
->whereIn('semester', $semVariants)
->where('school_year', $schoolYear)
->findAll();
foreach ($comments as $comment) {
$sid = $comment['student_id'];
if (!isset($students[$sid])) continue;
$type = strtolower($comment['score_type']);
// Set both comment and comment_review for each type
if ($type === 'ptap') {
$students[$sid]['ptap_comment'] = $comment['comment'];
$students[$sid]['ptap_comment_review'] = $comment['comment_review'] ?? null;
}
elseif ($type === 'midterm') {
$students[$sid]['midterm_comment'] = $comment['comment'];
$students[$sid]['midterm_comment_review'] = $comment['comment_review'] ?? null;
}
elseif ($type === 'final') {
$students[$sid]['final_comment'] = $comment['comment'];
$students[$sid]['final_comment_review'] = $comment['comment_review'] ?? null;
}
elseif ($type === 'attendance' || $type === 'attendance_comment') {
$students[$sid]['attendance_comment'] = $comment['comment'];
$students[$sid]['attendance_comment_review'] = $comment['comment_review'] ?? null;
}
}
// Fallback: if no attendance comment saved, derive one from score for display
helper('attendance_comment');
foreach ($students as $sid => &$student) {
if (!isset($student['attendance_comment']) && isset($student['attendance_score'])) {
$student['attendance_comment'] = attendance_comment_from_score(
$student['attendance_score'],
$student['firstname'] ?? ''
);
}
}
}
private function loadMissingOverrides(array &$students, int $classSectionId, $semester, $schoolYear): void
{
if ($classSectionId <= 0 || empty($students)) {
return;
}
$rows = $this->missingScoreOverrideModel
->select('student_id, item_type, item_index')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('is_allowed', 1)
->findAll();
foreach ($rows as $row) {
$sid = (int) ($row['student_id'] ?? 0);
if (!isset($students[$sid])) {
continue;
}
$type = (string) ($row['item_type'] ?? '');
if ($type === '') {
continue;
}
$idx = isset($row['item_index']) ? (int) $row['item_index'] : 0;
if (!isset($students[$sid]['missing_ok'])) {
$students[$sid]['missing_ok'] = [];
}
if (!isset($students[$sid]['missing_ok'][$type])) {
$students[$sid]['missing_ok'][$type] = [];
}
$students[$sid]['missing_ok'][$type][$idx] = true;
}
}
private function buildMissingItems(array &$students, array $homeworkIndexes, array $quizIndexes, array $projectIndexes, $semester): void
{
$isFall = strtolower((string) $semester) === 'fall';
$isSpring = strtolower((string) $semester) === 'spring';
foreach ($students as $sid => &$student) {
$missing = [];
$missingOk = $student['missing_ok'] ?? [];
$isAllowed = static function (array $missingOk, string $type, ?int $idx = null): bool {
$key = $idx === null ? 0 : $idx;
return !empty($missingOk[$type][$key]);
};
foreach ($homeworkIndexes as $idx) {
$val = $student['homework_scores_by_index'][$idx] ?? null;
if (($val === null || $val === '' || !is_numeric($val)) && !$isAllowed($missingOk, 'homework', $idx)) {
$missing[] = 'Homework ' . $idx;
}
}
foreach ($quizIndexes as $idx) {
$val = $student['quiz_scores_by_index'][$idx] ?? null;
if (($val === null || $val === '' || !is_numeric($val)) && !$isAllowed($missingOk, 'quiz', $idx)) {
$missing[] = 'Quiz ' . $idx;
}
}
foreach ($projectIndexes as $idx) {
$val = $student['project_scores_by_index'][$idx] ?? null;
if (($val === null || $val === '' || !is_numeric($val)) && !$isAllowed($missingOk, 'project', $idx)) {
$missing[] = 'Project ' . $idx;
}
}
if (empty($student['participation']) && !$isAllowed($missingOk, 'participation', 0)) {
$missing[] = 'Participation';
}
if ($isFall && empty($student['midterm']) && !$isAllowed($missingOk, 'midterm_exam', 0)) {
$missing[] = 'Midterm Exam';
}
if ($isSpring && empty($student['final_exam']) && !$isAllowed($missingOk, 'final_exam', 0)) {
$missing[] = 'Final Exam';
}
$ptapComment = trim((string) ($student['ptap_comment'] ?? ''));
if ($ptapComment === '' && !$isAllowed($missingOk, 'ptap_comment', 0)) {
$missing[] = 'PTAP Comment';
}
if ($isFall) {
$midtermComment = trim((string) ($student['midterm_comment'] ?? ''));
if ($midtermComment === '' && !$isAllowed($missingOk, 'midterm_comment', 0)) {
$missing[] = 'Midterm Comment';
}
}
if ($isSpring) {
$finalComment = trim((string) ($student['final_comment'] ?? ''));
if ($finalComment === '' && !$isAllowed($missingOk, 'final_comment', 0)) {
$missing[] = 'Final Comment';
}
}
$student['missing_items'] = $missing;
}
}
private function calculateStudentScores(&$students, int $classSectionId, $semester, $totalDays)
{
foreach ($students as $sid => &$s) {
$attendanceScore = $this->calculateAttendanceScoreForStudent(
$sid,
$classSectionId,
$semester,
$s['school_year'] ?? '',
$totalDays
);
if ($attendanceScore === null) {
$attendanceScore = 100.0;
}
$attendance = $attendanceScore;
$homeworkScores = array_values(array_filter(
$s['homework'],
static fn($score) => $score !== null && $score !== '' && is_numeric($score)
));
$avgHomework = !empty($homeworkScores) ? array_sum($homeworkScores) / count($homeworkScores) : null;
$quizScores = array_values(array_filter(
$s['quiz'],
static fn($score) => $score !== null && $score !== '' && is_numeric($score)
));
$avgQuiz = !empty($quizScores) ? array_sum($quizScores) / count($quizScores) : null;
$projectScores = array_values(array_filter(
$s['project'],
static fn($score) => $score !== null && $score !== '' && is_numeric($score)
));
$avgProject = !empty($projectScores) ? array_sum($projectScores) / count($projectScores) : null;
$participationScores = array_values(array_filter(
$s['participation'],
static fn($score) => $score !== null && $score !== '' && is_numeric($score)
));
$avgParticipation = !empty($participationScores) ? array_sum($participationScores) / count($participationScores) : null;
$ptap = $this->calculatePtapScore($avgHomework, $avgQuiz, $avgProject, $avgParticipation);
$midterm = (!empty($s['midterm']) && $s['midterm'][0] !== null && $s['midterm'][0] !== '')
? (float) $s['midterm'][0]
: null;
$final = (!empty($s['final_exam']) && $s['final_exam'][0] !== null && $s['final_exam'][0] !== '')
? (float) $s['final_exam'][0]
: null;
$score = null;
if ($ptap !== null) {
$score = 0.2 * $ptap + 0.2 * $attendance;
if (strtolower($semester) === 'fall') {
if ($midterm !== null) {
$score += 0.6 * $midterm;
} else {
$score = null;
}
} else {
if ($final !== null) {
$score += 0.6 * $final;
} else {
$score = null;
}
}
}
$s['average_homework'] = $avgHomework !== null ? round($avgHomework, 1) : null;
$s['average_quiz'] = $avgQuiz !== null ? round($avgQuiz, 1) : null;
$s['average_project'] = $avgProject !== null ? round($avgProject, 1) : null;
$s['average_participation'] = $avgParticipation !== null ? round($avgParticipation, 1) : null;
$s['ptap_score'] = $ptap !== null ? round($ptap, 1) : null;
$s['midterm_score'] = $midterm !== null ? round($midterm, 1) : null;
$s['final_exam_score'] = $final !== null ? round($final, 1) : null;
$s['attendance_score'] = round($attendanceScore, 1);
$s['semester_score'] = $score !== null ? round(min($score, 100), 1) : null;
}
}
private function calculateAttendanceScoreForStudent(
int $studentId,
int $classSectionId,
string $semester,
string $schoolYear,
?int $totalDays
): ?float {
$score = $this->getAttendanceScoreFromCalculator(
$studentId,
$semester,
$schoolYear,
$classSectionId
);
if ($score !== null) {
return $score;
}
return $this->calculateAttendanceScoreFallback(
$studentId,
$semester,
$schoolYear,
$totalDays
);
}
private function getAttendanceScoreFromCalculator(
int $studentId,
string $semester,
string $schoolYear,
int $classSectionId
): ?float {
try {
$result = $this->attendanceCalculator->calculate($studentId, $semester, $schoolYear, $classSectionId);
$score = $result['attendance_score'] ?? null;
if ($score === null || $score === '') {
return null;
}
return (float) $score;
} catch (\Throwable $e) {
log_message(
'error',
'ScoreController::getAttendanceScoreFromCalculator failed for '
. "student {$studentId}: " . $e->getMessage()
);
}
return null;
}
private function calculateAttendanceScoreFallback(
int $studentId,
string $semester,
string $schoolYear,
?int $totalDays
): ?float {
if ($totalDays === null || $totalDays <= 0) {
return 100.0;
}
$attendanceDataModel = new AttendanceDataModel();
$absences = $attendanceDataModel
->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('status', 'absent')
->countAllResults();
return max(0, ((($totalDays - $absences) / $totalDays) * 100));
}
/**
* PTAP weighting rules (tests are represented by quiz averages):
* - No tests and no projects: 50% homework, 50% participation.
* - No projects: 40% homework, 40% participation, 20% tests.
* - No tests: 34% homework, 33% participation, 33% projects.
* - All available: 30% homework, 30% participation, 30% projects, 10% tests.
*/
private function calculatePtapScore(?float $avgHomework, ?float $avgQuiz, ?float $avgProject, ?float $avgParticipation): ?float
{
$hasTests = $avgQuiz !== null;
$hasProjects = $avgProject !== null;
if (!$hasTests && !$hasProjects) {
if ($avgHomework === null || $avgParticipation === null) {
return null;
}
return round(($avgHomework * 0.5) + ($avgParticipation * 0.5), 1);
}
if (!$hasProjects) {
if ($avgHomework === null || $avgParticipation === null || $avgQuiz === null) {
return null;
}
return round(($avgHomework * 0.4) + ($avgParticipation * 0.4) + ($avgQuiz * 0.2), 1);
}
if (!$hasTests) {
if ($avgHomework === null || $avgParticipation === null || $avgProject === null) {
return null;
}
return round(($avgHomework * 0.34) + ($avgParticipation * 0.33) + ($avgProject * 0.33), 1);
}
if ($avgHomework === null || $avgParticipation === null || $avgProject === null || $avgQuiz === null) {
return null;
}
return round(
($avgHomework * 0.3) + ($avgParticipation * 0.3) + ($avgProject * 0.3) + ($avgQuiz * 0.1),
1
);
}
private function getHomeworkCount(int $classSectionId, string $semester, string $schoolYear): int
{
if ($classSectionId <= 0 || $semester === '' || $schoolYear === '') {
return 0;
}
$builder = $this->db->table('homework')
->select('COUNT(DISTINCT homework_index) AS hw_count')
->where('class_section_id', $classSectionId)
->where('semester', $semester);
if ($schoolYear !== '') {
$builder->where('school_year', $schoolYear);
}
$row = $builder->get()->getRow();
// Fallback: if none found (school_year not populated), drop school_year filter
if ((!$row || $row->hw_count === null) && $schoolYear !== '') {
$row = $this->db->table('homework')
->select('COUNT(DISTINCT homework_index) AS hw_count')
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->get()
->getRow();
}
return ($row && $row->hw_count !== null) ? (int) $row->hw_count : 0;
}
private function saveFinalScores($students, $model, $teacherId, $classSectionId, $semester, $schoolYear)
{
foreach ($students as $s) {
$existing = $model->where('student_id', $s['student_id'])
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->first();
$data = [
'student_id' => $s['student_id'],
'class_section_id' => $classSectionId,
'updated_by' => $teacherId,
'score' => $s['semester_score'],
'score_letter' => $this->convertScoreToLetter($s['semester_score']),
'semester' => $semester,
'school_year' => $schoolYear,
'updated_at' => utc_now(),
];
if ($existing) $model->update($existing['id'], $data);
else {
$data['created_at'] = utc_now();
$model->save($data);
}
}
}
private function convertScoreToLetter($score)
{
// Example conversion function, adjust logic as needed
if ($score >= 90) {
return 'A';
} elseif ($score >= 80) {
return 'B';
} elseif ($score >= 70) {
return 'C';
} elseif ($score >= 60) {
return 'D';
} else {
return 'F';
}
}
private function resolveActiveSemester(): string
{
$session = session();
$selected = $session->get('teacher_scores_selected_semester');
if (!empty($selected)) {
return (string) $selected;
}
return $session->get('semester') ?? $this->semester;
}
protected function normalizeSemester(?string $input): string
{
$s = strtolower(trim((string)$input));
if ($s === 'fall' || str_contains($s, '1')) return 'fall';
if ($s === 'spring' || str_contains($s, '2')) return 'spring';
return '';
}
protected function getSemesterRange(string $schoolYear, string $semester): ?array
{
$norm = $this->normalizeSemester($semester);
if ($norm === '' || !preg_match('/^(\\d{4})/', trim($schoolYear), $m)) {
return null;
}
$y1 = (int)$m[1];
$y2 = $y1 + 1;
$fallStartCfg = (string)($this->configModel->getConfig('fall_semester_start') ?? '');
$fallEndCfg = (string)($this->configModel->getConfig('fall_end_date') ?? ''); // e.g., 'YYYY-01-15'
$springStartCfg = (string)($this->configModel->getConfig('spring_semester_start') ?? ''); // e.g., 'YYYY-01-16'
$springEndCfg = (string)($this->configModel->getConfig('last_school_day') ?? '');
if ($norm === 'fall') {
$start = ($fallStartCfg !== '') ? date($y1 . '-m-d', strtotime($fallStartCfg)) : "{$y1}-09-01";
$end = ($fallEndCfg !== '') ? date($y2 . '-m-d', strtotime($fallEndCfg)) : "{$y2}-01-15";
return [$start, $end];
}
if ($norm === 'spring') {
$start = ($springStartCfg !== '') ? date($y2 . '-m-d', strtotime($springStartCfg)) : "{$y2}-01-16";
$end = ($springEndCfg !== '') ? date($y2 . '-m-d', strtotime($springEndCfg)) : "{$y2}-06-30";
return [$start, $end];
}
return null;
}
private function countSundays(string $startDate, string $endDate): int
{
$start = new \DateTime($startDate);
$end = new \DateTime($endDate);
$sundays = 0;
$interval = new \DateInterval('P1D');
$period = new \DatePeriod($start, $interval, $end->modify('+1 day')); // include end date
foreach ($period as $day) {
if ($day->format('N') == 7) {
$sundays++;
}
}
return $sundays;
}
public function updateFinalExamScores()
{
return $this->updateScores('final_exam');
}
public function updateFinalExamScoresForGrading()
{
return $this->updateScores('final_exam', base_url('grading'));
}
public function updateMidtermScores()
{
return $this->updateScores('midterm_exam');
}
public function updateScores(string $table, ?string $redirectUrl = null)
{
$builder = $this->db->table($table);
$scores = $this->request->getPost('final_score'); // name must match input form
$teacherId = session()->get('user_id');
$classSectionId = session()->get('class_section_id');
$semester = $this->resolveActiveSemester();
$affectedStudents = [];
$classSectionId = (int) ($classSectionId ?? 0);
if ($classSectionId > 0 && $this->gradingLockModel->isLocked($classSectionId, $semester, $this->schoolYear)) {
return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.');
}
if (is_array($scores) && in_array($table, ['midterm_exam', 'final_exam'], true)) {
$missingOk = $this->request->getPost('missing_ok') ?? [];
$studentIds = array_keys($scores);
$checkedItems = [];
if (is_array($missingOk)) {
foreach ($missingOk as $studentId => $flags) {
$checkedItems[] = [
'student_id' => (int) $studentId,
'item_index' => null,
];
}
}
$this->missingScoreOverrideModel->replaceOverrides(
$classSectionId,
(string) $semester,
(string) $this->schoolYear,
$table === 'midterm_exam' ? 'midterm_exam' : 'final_exam',
$studentIds,
null,
$checkedItems,
$teacherId,
true
);
}
if (is_array($scores)) {
foreach ($scores as $studentId => $data) {
if (!is_numeric($studentId)) {
continue;
}
$rawScore = $data['score'] ?? null;
$normalizedScore = is_numeric($rawScore) ? (float) $rawScore : null;
// Check if a record already exists for this student
$existing = $builder
->where('student_id', $studentId)
->where('class_section_id', $classSectionId)
->where('semester', $semester)
->where('school_year', $this->schoolYear)
->get()
->getRow();
$insertData = [
'student_id' => $studentId,
'class_section_id' => $classSectionId,
'updated_by' => $teacherId,
'score' => $normalizedScore,
//'comment' => $data['comment'] ?? null,
'semester' => $semester,
'school_year' => $this->schoolYear,
'updated_at' => utc_now()
];
if (strcasecmp($table, 'quiz') === 0) {
$insertData['quiz_index'] = $data['quiz_index'] ?? null;
}
if ($existing) {
// Update existing
$builder->where('id', $existing->id)->update($insertData);
} else {
// Insert new
$insertData['created_at'] = utc_now();
$builder->insert($insertData);
}
// Track student for events (avoid duplicates)
$affectedStudents[(int)$studentId] = true;
}
}
// If final exam scores updated during Spring, trigger finalScoreReleased for these students
if (strcasecmp($table, 'final_exam') === 0 && strcasecmp((string)$this->semester, 'Spring') === 0 && !empty($affectedStudents)) {
$ids = array_keys($affectedStudents);
$payload = ['students' => array_map(static fn($sid) => ['id' => (int)$sid], $ids)];
//Events::trigger('finalScoreReleased', $payload);
}
// Ensure semester_scores are re-calculated when midterm/final exams change.
if (in_array(strtolower($table), ['final_exam', 'midterm_exam'], true) && $this->semesterScoreService !== null) {
try {
$studentTeacherInfo = $this->studentModel->getStudentInfoByClassSectionId(
$classSectionId,
$semester,
$this->schoolYear
);
if (!empty($studentTeacherInfo)) {
$this->semesterScoreService->updateScoresForStudents($studentTeacherInfo, $semester, $this->schoolYear);
}
} catch (\Throwable $e) {
log_message('error', 'semester score resync failed after exam update: ' . $e->getMessage());
}
}
$statusMessage = ucfirst($table) . ' scores updated successfully.';
if ($redirectUrl !== null) {
return redirect()->to($redirectUrl)->with('status', $statusMessage);
}
return redirect()->back()->with('status', $statusMessage);
}
private function fetchAndOrganizeScores($db, &$organizedScores, $tableName, $parentId, $selectedYear, $selectedSemester)
{
// Only supports semester_scores table in this context
if ($tableName !== 'semester_scores') {
return;
}
// Define the columns to select
$columns = [
'students.id as student_id',
'students.school_id',
'students.firstname as student_firstname',
'students.lastname as student_lastname',
'classSection.class_section_name',
"$tableName.homework_avg",
"$tableName.quiz_avg",
"$tableName.project_avg",
"$tableName.midterm_exam_score",
"$tableName.final_exam_score",
"$tableName.attendance_score",
"$tableName.test_avg",
"$tableName.semester_score",
"$tableName.school_year",
"$tableName.semester",
"$tableName.created_at",
"$tableName.updated_at"
// "$tableName.comment"
];
// Build the query
$builder = $db->table($tableName);
$builder->select(implode(', ', $columns));
$builder->join('students', 'students.id = ' . $tableName . '.student_id');
// Join on the correct foreign key: classSection.class_section_id
$builder->join('classSection', 'classSection.class_section_id = ' . $tableName . '.class_section_id');
$builder->where('students.parent_id', $parentId)
->where("$tableName.school_year", $selectedYear)
->where("$tableName.semester", $selectedSemester);
$scores = $builder->get()->getResultArray();
foreach ($scores as $score) {
if (!isset($score['school_id'])) {
log_message('error', "Missing school_id for student ID: " . ($score['student_id'] ?? 'unknown'));
continue;
}
$schoolYear = $score['school_year'];
$semester = $score['semester'];
$studentSchoolId = $score['school_id'];
if (!isset($organizedScores[$schoolYear][$semester][$studentSchoolId])) {
$organizedScores[$schoolYear][$semester][$studentSchoolId] = [
'student_firstname' => $score['student_firstname'],
'student_lastname' => $score['student_lastname'],
'class_section_name' => $score['class_section_name'],
'school_id' => $studentSchoolId, // ✅ Add this line
'scores' => [],
// 'comment' => $score['comment'] ?? ''
];
}
// Organize each category
$organizedScores[$schoolYear][$semester][$studentSchoolId]['scores'] = [
'homework' => ['score' => $score['homework_avg']],
'quiz' => ['score' => $score['quiz_avg']],
'project' => ['score' => $score['project_avg']],
'midterm_exam' => ['score' => $score['midterm_exam_score']],
'final_exam' => ['score' => $score['final_exam_score']],
'attendance' => ['score' => $score['attendance_score']],
'test_avg' => ['score' => $score['test_avg']],
'semester' => ['score' => $score['semester_score']],
];
// Optionally include comment
$organizedScores[$schoolYear][$semester][$studentSchoolId]['comment'] = $score['comment'] ?? '';
}
}
public function viewStudentScore()
{
$parentId = session()->get('user_id');
$userType = session()->get('user_type') ?? '';
$firstParentId = null;
$releaseFall = $this->getParentScoresReleasedForSemester('Fall');
$releaseSpring = $this->getParentScoresReleasedForSemester('Spring');
$releaseAny = $releaseFall || $releaseSpring;
// Identify the firstparent based on user type
if ($userType === 'primary' || $userType === '') {
$firstParentId = $parentId;
} elseif ($userType === 'secondary') {
$parentData = $this->db->table('parents')
->select('parent_id')
->where('secondparent_user_id', $parentId)
->get()
->getRowArray();
if ($parentData) {
$firstParentId = $parentData['parent_id'];
}
} elseif ($userType === 'tertiary') {
$authUserData = $this->db->table('authorized_users')
->select('user_id as parent_id')
->where('authorized_user_id', $parentId)
->get()
->getRowArray();
if ($authUserData) {
$firstParentId = $authUserData['parent_id'];
}
}
if (!$firstParentId) {
return redirect()->back()->with('error', 'Unable to retrieve student data. Please contact support.');
}
// Handle selected school year (parents can view scores for any available year)
$selectedYear = $this->request->getVar('school_year') ?? $this->schoolYear;
// Fetch distinct school years only from semester_scores
$query = $this->db->table('semester_scores')
->select('school_year')
->distinct()
->get();
$schoolYears = array_column($query->getResultArray(), 'school_year');
$schoolYears = array_unique($schoolYears);
rsort($schoolYears);
// Initialize scores array
$scores = [];
// First get all students with their class section info for this parent
$students = $this->db->table('students')
->select('students.id, students.school_id, students.firstname, students.lastname, classSection.class_section_name AS class_section_name')
->join('student_class', 'student_class.student_id = students.id', 'left')
// Join using class_section_id FK stored in student_class
->join('classSection', 'classSection.class_section_id = student_class.class_section_id', 'left')
->where('students.parent_id', $firstParentId)
->get()
->getResultArray();
if (!empty($students)) {
$studentIdList = array_column($students, 'id');
// Fetch data from semester_scores for these students
$semesterScores = $this->db->table('semester_scores')
->whereIn('student_id', $studentIdList)
->where('school_year', $selectedYear)
->get()
->getResultArray();
$scoresByStudent = [];
foreach ($semesterScores as $score) {
$studentIdKey = (int)($score['student_id'] ?? 0);
$semesterName = trim((string)($score['semester'] ?? ''));
if ($studentIdKey === 0 || $semesterName === '') {
continue;
}
$normalizedSemester = ucfirst(strtolower($semesterName));
$scoresByStudent[$studentIdKey][$normalizedSemester] = $score;
}
foreach ($students as $student) {
$studentId = (int)$student['id'];
if (empty($scoresByStudent[$studentId])) {
continue;
}
foreach ($scoresByStudent[$studentId] as $semester => $score) {
$releaseScores = (strcasecmp($semester, 'Fall') === 0) ? $releaseFall
: ((strcasecmp($semester, 'Spring') === 0) ? $releaseSpring : $releaseAny);
$scores[$selectedYear][$semester][$studentId] = [
'student_id' => $studentId,
'school_id' => $student['school_id'],
'student_firstname' => $student['firstname'],
'student_lastname' => $student['lastname'],
'class_section_name' => $student['class_section_name'] ?? 'Unknown Class',
'comment' => $score['comment'] ?? null,
'scores' => [
'homework' => ['score' => $score['homework_avg'] ?? '-'],
'quiz' => ['score' => $score['quiz_avg'] ?? '-'],
'project' => ['score' => $score['project_avg'] ?? '-'],
'attendance' => ['score' => $score['attendance_score'] ?? '-'],
'participation_score' => ['score' => $score['participation_score'] ?? '-'],
'ptap' => ['score' => $releaseScores ? ($score['ptap_score'] ?? '-') : '-'],
'midterm_exam' => ['score' => $releaseScores ? ($score['midterm_exam_score'] ?? '-') : '-'],
'final_exam' => ['score' => $releaseScores ? ($score['final_exam_score'] ?? '-') : '-'],
'semester' => ['score' => $releaseScores ? ($score['semester_score'] ?? '-') : '-']
]
];
}
}
}
return view('/parent/scores', [
'organizedScores' => $scores,
'schoolYears' => $schoolYears,
'selectedYear' => $selectedYear,
'semesterOrder' => ['Fall', 'Spring'],
'showExamScores' => $releaseAny,
'showExamScoresBySemester' => [
'Fall' => $releaseFall,
'Spring' => $releaseSpring,
],
]);
}
private 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);
}
private function getParentReleaseKey(string $semester): ?string
{
$s = strtolower(trim($semester));
if ($s === 'fall') {
return 'parent_scores_released_fall';
}
if ($s === 'spring') {
return 'parent_scores_released_spring';
}
return null;
}
}