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.'); } $this->configModel = new ConfigurationModel(); $this->scoreCommentModel = new ScoreCommentModel(); $this->studentModel = new StudentModel(); $this->semesterScoreModel = new SemesterScoreModel(); $this->gradingLockModel = new GradingLockModel(); $this->missingScoreOverrideModel = new MissingScoreOverrideModel(); $this->semester = $this->configModel->getConfig('semester'); $this->schoolYear = $this->configModel->getConfig('school_year'); } public function saveComments() { $comments = $this->request->getPost('comments'); $classSectionId = (int)($this->request->getPost('class_section_id') ?? session()->get('class_section_id') ?? 0); $schoolYear = (string) ($this->request->getPost('school_year') ?? $this->schoolYear); $rawSelectedSemester = $this->request->getPost('selected_semester'); $normalizedSelected = $this->normalizeSemesterSelection($rawSelectedSemester); if ($normalizedSelected !== '') { $semester = ucfirst($normalizedSelected); } else { $semester = session()->get('teacher_scores_selected_semester') ?? session()->get('semester') ?? $this->semester; } $teacherId = session()->get('user_id'); if ($classSectionId <= 0) { return redirect()->back()->with('error', 'Missing class section.'); } if ($this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear)) { return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.'); } if (!is_array($comments)) { return redirect()->back()->with('error', 'No comments were submitted.'); } $missingOk = $this->request->getPost('missing_ok') ?? []; $studentIds = array_map('intval', array_keys($comments)); $studentNames = $this->getStudentFirstNames($studentIds); $isFall = strtolower((string) $semester) === 'fall'; $isSpring = strtolower((string) $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 ); } $errors = []; $payload = []; foreach ($comments as $studentId => $commentTypes) { foreach ($commentTypes as $scoreType => $comment) { $normalizedScoreType = strtolower((string)$scoreType); $trimmedComment = trim((string)$comment); if ($trimmedComment === '') { continue; } $validationError = $this->validateComment($trimmedComment, (int)$studentId, $normalizedScoreType, $studentNames); if ($validationError !== null) { $errors[] = $validationError; log_message( 'warning', "ScoreComment validation failed: teacher_id={$teacherId}, student_id={$studentId}, score_type={$normalizedScoreType}, error={$validationError}, length=" . mb_strlen($trimmedComment, 'UTF-8') ); continue; } $payload[] = [ 'student_id' => $studentId, 'class_section_id' => $classSectionId, 'score_type' => $normalizedScoreType, // 'ptap', 'midterm', 'final' 'semester' => $semester, 'school_year' => $schoolYear, 'comment' => $trimmedComment, 'commented_by' => $teacherId, 'created_at' => utc_now(), ]; } } if (!empty($errors)) { return redirect()->back()->with('errors', $errors)->withInput(); } foreach ($payload as $data) { $existing = $this->scoreCommentModel ->where('student_id', $data['student_id']) ->groupStart() ->where('class_section_id', $data['class_section_id']) ->orWhere('class_section_id', null) ->groupEnd() ->where('score_type', $data['score_type']) ->where('semester', $data['semester']) ->where('school_year', $data['school_year']) ->first(); if ($existing) { $this->scoreCommentModel->update($existing['id'], $data); } else { $this->scoreCommentModel->insert($data); } } return redirect()->back()->with('status', 'Comments saved successfully.'); } public function viewCommentsMngt() { $rawSelectedSemester = $this->request->getGet('semester') ?? $this->request->getPost('semester') ?? session()->get('teacher_scores_selected_semester') ?? session()->get('semester') ?? $this->semester; $normalizedSelected = $this->normalizeSemesterSelection($rawSelectedSemester); if ($normalizedSelected !== '') { $activeSemester = ucfirst($normalizedSelected); } else { $activeSemester = $this->semester; } $incoming = $this->request->getPost('class_section_id') ?? $this->request->getGet('class_section_id') ?? session()->get('class_section_id'); $isAllSections = is_string($incoming) && strtolower(trim($incoming)) === 'allsections'; $classSectionId = $isAllSections ? null : (int) ($incoming ?: 0); if (!$isAllSections && $classSectionId <= 0) { return redirect()->back()->with('status', 'Missing class section.'); } session()->set('class_section_id', $isAllSections ? 'allsections' : $classSectionId); $builder = $this->db->table('student_class sc') ->distinct('SELECT s.id AS student_id, s.firstname, s.lastname, s.school_id') ->join('students s', 's.id = sc.student_id', 'inner') ->where('sc.school_year', $this->schoolYear); if (!$isAllSections) { $builder->where('sc.class_section_id', $classSectionId); } $students = $builder ->orderBy('s.lastname', 'ASC') ->orderBy('s.firstname', 'ASC') ->get() ->getResultArray(); // 3) Load only comments for these students in this term (ptap, midterm, final) $comments = []; if (!empty($students)) { $studentIds = array_map(static fn($r) => (int)$r['student_id'], $students); $rawCommentsBuilder = $this->scoreCommentModel ->whereIn('student_id', $studentIds) ->whereIn('score_type', ['midterm', 'final', 'ptap', 'attendance']) ->where('semester', $activeSemester) ->where('school_year', $this->schoolYear); if (!$isAllSections) { $rawCommentsBuilder->groupStart() ->where('class_section_id', $classSectionId) ->orWhere('class_section_id', null); // legacy rows without class_section_id $rawCommentsBuilder->groupEnd(); } $rawComments = $rawCommentsBuilder->findAll(); $existingAttendance = []; foreach ($rawComments as $c) { $sid = (int)$c['student_id']; $typ = (string)$c['score_type']; $comments[$sid][$typ] = [ 'comment' => $c['comment'] ?? null, 'comment_review' => $c['comment_review'] ?? null, 'commented_by' => $c['commented_by'] ?? null, 'reviewed_by' => $c['reviewed_by'] ?? null, ]; if ($typ === 'attendance') { $existingAttendance[$sid] = $c; } } helper('attendance_comment'); // Autogenerate attendance comments for display when missing $attendanceScores = []; $scoreRows = $this->semesterScoreModel ->select(['student_id', 'attendance_score']) ->whereIn('student_id', $studentIds) ->where('semester', $activeSemester) ->where('school_year', $this->schoolYear) ->findAll(); foreach ($scoreRows as $row) { $attendanceScores[(int)$row['student_id']] = isset($row['attendance_score']) ? (float)$row['attendance_score'] : null; } $attendanceInserts = []; $attendanceUpdates = []; foreach ($students as $student) { $sid = (int)$student['student_id']; $hasComment = isset($comments[$sid]['attendance']['comment']) && trim((string)$comments[$sid]['attendance']['comment']) !== ''; if ($hasComment) { continue; } $score = $attendanceScores[$sid] ?? null; if ($score === null) { continue; } $auto = attendance_comment_from_score($score, $student['firstname'] ?? ''); if ($auto !== null) { $comments[$sid]['attendance'] = [ 'comment' => $auto, 'comment_review' => $comments[$sid]['attendance']['comment_review'] ?? null, 'commented_by' => $comments[$sid]['attendance']['commented_by'] ?? null, 'reviewed_by' => $comments[$sid]['attendance']['reviewed_by'] ?? null, ]; if (isset($existingAttendance[$sid])) { $attendanceUpdates[] = [ 'id' => $existingAttendance[$sid]['id'], 'comment' => $auto, 'commented_by' => $existingAttendance[$sid]['commented_by'] ?? null, 'class_section_id' => $classSectionId, ]; } else { $attendanceInserts[] = [ 'student_id' => $sid, 'score_type' => 'attendance', 'semester' => $activeSemester, 'school_year' => $this->schoolYear, 'comment' => $auto, 'commented_by'=> null, 'class_section_id' => $classSectionId, 'created_at' => utc_now(), ]; } } } if (!empty($attendanceUpdates)) { foreach ($attendanceUpdates as $row) { $id = $row['id']; unset($row['id']); $this->scoreCommentModel->update($id, $row); } } if (!empty($attendanceInserts)) { $this->scoreCommentModel->insertBatch($attendanceInserts); } } // 4) Render return view('grading/comments', [ 'students' => $students, 'semester' => $activeSemester, 'schoolYear' => $this->schoolYear, 'classSectionId' => $isAllSections ? 'allsections' : $classSectionId, 'comments' => $comments, 'scoresLocked' => (!$isAllSections && $classSectionId > 0) ? $this->gradingLockModel->isLocked($classSectionId, $activeSemester, $this->schoolYear) : false, ]); } public function updateComments() { $semester = $this->request->getPost('semester'); $schoolYear = $this->request->getPost('school_year'); $teacherId = $this->request->getPost('teacher_id') ?? session()->get('user_id'); $classSectionId = $this->request->getPost('class_section_id') ?? session()->get('class_section_id'); $comments = $this->request->getPost('comments') ?? []; $reviews = $this->request->getPost('reviews') ?? []; $studentIds = array_unique(array_map( 'intval', array_merge(array_keys($comments), array_keys($reviews)) )); $studentNames = $this->getStudentFirstNames($studentIds); $isReviewer = $this->isReviewer(); $classSectionId = is_numeric($classSectionId) ? (int)$classSectionId : $classSectionId; if (is_int($classSectionId) && $classSectionId > 0) { if ($this->gradingLockModel->isLocked($classSectionId, $semester, $schoolYear)) { return redirect()->back()->with('error', 'Scores are locked for this class. Unlock to edit.'); } } // Pull attendance scores once for auto-generated attendance comments $attendanceScores = []; if (!empty($studentIds)) { $attendanceBuilder = $this->semesterScoreModel ->select(['student_id', 'attendance_score']) ->where('semester', $semester) ->where('school_year', $schoolYear) ->whereIn('student_id', $studentIds); if (is_int($classSectionId) && $classSectionId > 0) { $attendanceBuilder->where('class_section_id', $classSectionId); } $rows = $attendanceBuilder->findAll(); foreach ($rows as $row) { $attendanceScores[(int)$row['student_id']] = isset($row['attendance_score']) ? (float)$row['attendance_score'] : null; } } helper('attendance_comment'); $errors = []; $operations = []; foreach ($comments as $studentId => $types) { foreach ($types as $scoreType => $commentText) { $normalizedScoreType = strtolower((string)$scoreType); $commentText = trim((string)$commentText); // Auto-generate attendance comment when empty based on the student's attendance score if ($normalizedScoreType === 'attendance' && $commentText === '') { $attendanceScore = $attendanceScores[$studentId] ?? null; $auto = attendance_comment_from_score($attendanceScore, $studentNames[$studentId] ?? ''); if ($auto !== null) { $commentText = $auto; } } $reviewText = isset($reviews[$studentId][$scoreType]) ? trim((string)$reviews[$studentId][$scoreType]) : ''; // Skip empty comment & empty review to avoid null inserts if ($commentText === '' && $reviewText === '') { continue; } if ($commentText !== '') { $validationError = $this->validateComment($commentText, (int)$studentId, $normalizedScoreType, $studentNames); if ($validationError !== null) { $errors[] = $validationError; continue; } } $operations[] = [ 'student_id' => $studentId, 'class_section_id' => is_int($classSectionId) ? $classSectionId : null, 'score_type' => $normalizedScoreType, 'comment' => $commentText === '' ? null : $commentText, 'review' => $reviewText === '' ? null : $reviewText, ]; } } if (!empty($errors)) { return redirect()->back()->with('errors', $errors)->withInput(); } foreach ($operations as $operation) { $existingQuery = $this->scoreCommentModel ->where('student_id', $operation['student_id']) ->where('score_type', $operation['score_type']) ->where('semester', $semester) ->where('school_year', $schoolYear); if (is_int($classSectionId) && $classSectionId > 0) { $existingQuery->groupStart() ->where('class_section_id', $classSectionId) ->orWhere('class_section_id', null); // pick up legacy rows $existingQuery->groupEnd(); } $existing = $existingQuery->first(); $data = [ 'comment' => $operation['comment'], 'commented_by' => $teacherId, 'class_section_id' => $operation['class_section_id'], ]; if ($isReviewer && $operation['review'] !== null) { $data['comment_review'] = $operation['review']; $data['reviewed_by'] = $teacherId; } if ($existing) { // Update $this->scoreCommentModel->update($existing['id'], $data); } else { // Insert $this->scoreCommentModel->insert(array_merge([ 'student_id' => $operation['student_id'], 'score_type' => $operation['score_type'], 'semester' => $semester, 'school_year' => $schoolYear, 'class_section_id' => $operation['class_section_id'], ], $data)); } } return redirect()->back()->with('status', 'Comments updated successfully!'); } // Helper function to check if current user is a reviewer private function isReviewer() { $currentUserRole = session()->get('role'); // Make sure this matches your session role key // Get reviewer roles from configuration table $reviewerRoles = $this->configModel->getConfig('comment_reviewer'); if (!$reviewerRoles) { return false; } // Convert comma-separated string to array and trim whitespace $allowedRoles = array_map('trim', explode(',', $reviewerRoles)); // Check if current user role is in the allowed roles return in_array($currentUserRole, $allowedRoles); } private function getStudentFirstNames(array $studentIds): array { $ids = array_values(array_filter(array_unique(array_map('intval', $studentIds)), static function ($id) { return $id > 0; })); if (empty($ids)) { return []; } $rows = $this->studentModel ->select(['id', 'firstname']) ->whereIn('id', $ids) ->findAll(); $names = []; foreach ($rows as $row) { $names[(int)($row['id'] ?? 0)] = trim((string)($row['firstname'] ?? '')); } return $names; } private function validateComment(string $comment, int $studentId, string $scoreType, array $studentNames): ?string { $trimmed = trim($comment); if ($trimmed === '') { return null; } $firstName = $studentNames[$studentId] ?? ''; if ($firstName === '') { return "Missing student first name for student ID {$studentId}."; } $length = mb_strlen($trimmed, 'UTF-8'); if ($length < 100) { return "{$firstName}'s {$scoreType} comment must be at least 100 characters."; } if ($length > 400) { return "{$firstName}'s {$scoreType} comment must be at most 400 characters."; } $pattern = '/^' . preg_quote($firstName, '/') . '\\b/i'; if (!preg_match($pattern, $trimmed)) { return "{$firstName}'s {$scoreType} comment must start with the student's first name."; } return null; } private function normalizeSemesterSelection(?string $input): string { $value = strtolower(trim((string)$input)); if ($value === 'fall' || str_contains($value, '1')) { return 'fall'; } if ($value === 'spring' || str_contains($value, '2')) { return 'spring'; } return ''; } }