From 7f3b24e47f46d14f29eb59870acaf11409fafa7e Mon Sep 17 00:00:00 2001 From: root Date: Wed, 15 Jul 2026 21:42:06 -0400 Subject: [PATCH] fix parent page --- app/Controllers/View/ParentController.php | 138 +++++++++++++++++- ...CreateStudentSectionDistributionDrafts.php | 43 ++++++ .../StudentSectionDistributionDraftModel.php | 34 +++++ app/Models/UserModel.php | 1 - .../View/ParentControllerAgeTest.php | 42 ++++++ 5 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 app/Database/Migrations/2026-07-16-000100_CreateStudentSectionDistributionDrafts.php create mode 100644 app/Models/StudentSectionDistributionDraftModel.php create mode 100644 tests/app/Controllers/View/ParentControllerAgeTest.php diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php index 27cb8eb..0e9414e 100644 --- a/app/Controllers/View/ParentController.php +++ b/app/Controllers/View/ParentController.php @@ -305,6 +305,7 @@ class ParentController extends BaseController // Attach enrollment and class section data to each student foreach ($students as &$student) { $studentId = $student['id']; + $student['age'] = $this->calculateAgeAsOfSchoolYearStartYear($student['dob'] ?? null, $selectedYear); // Get class section info (can be multiple sections like Grade + Arabic) $classSections = $this->studentClassModel->getClassSectionsByStudentId($studentId, $selectedYear, true); @@ -428,11 +429,14 @@ class ParentController extends BaseController } if ($existingEnrollment) { if ($existingEnrollment['is_withdrawn'] == 1) { + $passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear); + // Reactivate the enrollment if the student was previously withdrawn $this->enrollmentModel->where('id', $existingEnrollment['id'])->update([ 'is_withdrawn' => 0, 'withdrawal_date' => null, - 'enrollment_status' => 'payment pending', + 'enrollment_status' => $passedPreviousYear ? 'payment pending' : 'admission under review', + 'admission_status' => $passedPreviousYear ? 'accepted' : 'pending', 'updated_at' => utc_now() ]); log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) has been re-enrolled in enrollment ID {$existingEnrollment['id']}."); @@ -442,6 +446,8 @@ class ParentController extends BaseController log_message('info', "{$studentData[$studentId]['firstname']} {$studentData[$studentId]['lastname']} (ID $studentId) is already actively enrolled."); } } else { + $passedPreviousYear = $this->studentPassedPreviousYear((int)$studentId, (string)$this->schoolYear); + // If no enrollment record exists, insert a new enrollment record $result = $this->enrollmentModel->insert([ 'student_id' => $studentId, @@ -450,8 +456,8 @@ class ParentController extends BaseController 'semester' => $this->semester, 'enrollment_date' => local_date(utc_now(), 'Y-m-d'), 'is_withdrawn' => 0, - 'enrollment_status' => 'admission under review', - 'admission_status' => 'pending', + 'enrollment_status' => $passedPreviousYear ? 'payment pending' : 'admission under review', + 'admission_status' => $passedPreviousYear ? 'accepted' : 'pending', 'created_at' => utc_now() ]); @@ -625,6 +631,61 @@ class ParentController extends BaseController } } + private function studentPassedPreviousYear(int $studentId, string $targetSchoolYear): bool + { + $previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear); + + if ($studentId <= 0 || $previousSchoolYear === null) { + return false; + } + + try { + if ($this->db->tableExists('promotion_queue')) { + $queuedPromotion = $this->db->table('promotion_queue') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year_from', $previousSchoolYear) + ->where('school_year_to', $targetSchoolYear) + ->whereIn('status', ['queued', 'assigned', 'applied']) + ->limit(1) + ->get() + ->getRowArray(); + + if ($queuedPromotion !== null) { + return true; + } + } + + if ($this->db->tableExists('student_decisions')) { + $passedDecision = $this->db->table('student_decisions') + ->select('id') + ->where('student_id', $studentId) + ->where('school_year', $previousSchoolYear) + ->where('LOWER(TRIM(decision)) = ' . $this->db->escape('pass'), null, false) + ->limit(1) + ->get() + ->getRowArray(); + + return $passedDecision !== null; + } + } catch (\Throwable $e) { + log_message('error', 'studentPassedPreviousYear failed: ' . $e->getMessage()); + } + + return false; + } + + private function previousSchoolYearName(string $schoolYear): ?string + { + $schoolYear = trim($schoolYear); + + if (! preg_match('/^(\d{4})-(\d{4})$/', $schoolYear, $matches)) { + return null; + } + + return ((int) $matches[1] - 1) . '-' . ((int) $matches[2] - 1); + } + public function enrollFailure() { echo view('/parent/enroll_failure'); @@ -1754,6 +1815,77 @@ $existing = $this->studentModel } } + private function calculateAgeAsOfToday(?string $dob): ?int + { + $dob = trim((string) $dob); + + if ($dob === '') { + return null; + } + + try { + $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); + $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); + $errors = \DateTimeImmutable::getLastErrors(); + $hasParseErrors = is_array($errors) + && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); + + if ($birthDate === false || $hasParseErrors) { + return null; + } + + $today = new \DateTimeImmutable('today', $timezone); + + if ($birthDate > $today) { + return null; + } + + return $birthDate->diff($today)->y; + } catch (\Throwable $e) { + log_message('warning', 'Unable to calculate current age from DOB: {message}', [ + 'message' => $e->getMessage(), + ]); + + return null; + } + } + + private function calculateAgeAsOfSchoolYearStartYear(?string $dob, string $schoolYear): ?int + { + $dob = trim((string) $dob); + $schoolYear = trim($schoolYear); + + if ($dob === '' || ! preg_match('/^(\d{4})/', $schoolYear, $matches)) { + return null; + } + + try { + $timezone = new \DateTimeZone((string) (config('School')->attendance['timezone'] ?? user_timezone())); + $birthDate = \DateTimeImmutable::createFromFormat('!Y-m-d', $dob, $timezone); + $errors = \DateTimeImmutable::getLastErrors(); + $hasParseErrors = is_array($errors) + && (($errors['warning_count'] ?? 0) > 0 || ($errors['error_count'] ?? 0) > 0); + + if ($birthDate === false || $hasParseErrors) { + return null; + } + + $schoolYearStartYearCutoff = new \DateTimeImmutable($matches[1] . '-12-31', $timezone); + + if ($birthDate > $schoolYearStartYearCutoff) { + return null; + } + + return $birthDate->diff($schoolYearStartYearCutoff)->y; + } catch (\Throwable $e) { + log_message('warning', 'Unable to calculate school-year age from DOB: {message}', [ + 'message' => $e->getMessage(), + ]); + + return null; + } + } + public function parentEventPage() { $schoolYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); diff --git a/app/Database/Migrations/2026-07-16-000100_CreateStudentSectionDistributionDrafts.php b/app/Database/Migrations/2026-07-16-000100_CreateStudentSectionDistributionDrafts.php new file mode 100644 index 0000000..4d2f6f6 --- /dev/null +++ b/app/Database/Migrations/2026-07-16-000100_CreateStudentSectionDistributionDrafts.php @@ -0,0 +1,43 @@ +db->tableExists('student_section_distribution_drafts')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'class_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'class_section_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'school_year' => ['type' => 'VARCHAR', 'constraint' => 20], + 'previous_school_year' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => true], + 'previous_final_score' => ['type' => 'DECIMAL', 'constraint' => '6,2', 'null' => true], + 'score_group' => ['type' => 'VARCHAR', 'constraint' => 20], + 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'pending'], + 'batch_key' => ['type' => 'VARCHAR', 'constraint' => 64], + 'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'applied_at' => ['type' => 'DATETIME', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['student_id', 'school_year'], 'unique_distribution_draft_student_year'); + $this->forge->addKey(['class_id', 'school_year', 'status'], false, 'distribution_draft_class_year_status'); + $this->forge->addKey(['class_section_id', 'school_year'], false, 'distribution_draft_section_year'); + $this->forge->createTable('student_section_distribution_drafts'); + } + + public function down() + { + $this->forge->dropTable('student_section_distribution_drafts', true); + } +} diff --git a/app/Models/StudentSectionDistributionDraftModel.php b/app/Models/StudentSectionDistributionDraftModel.php new file mode 100644 index 0000000..7fe2351 --- /dev/null +++ b/app/Models/StudentSectionDistributionDraftModel.php @@ -0,0 +1,34 @@ +setAccessible(true); + + $this->assertSame(6, $method->invoke($controller, '2019-09-15', '2025-2026')); + $this->assertSame(5, $method->invoke($controller, '2020-01-01', '2025-2026')); + } + + public function testEnrollmentAgeRejectsInvalidInputs(): void + { + $controller = new class extends ParentController { + public function __construct() + { + } + }; + + $method = new ReflectionMethod(ParentController::class, 'calculateAgeAsOfSchoolYearStartYear'); + $method->setAccessible(true); + + $this->assertNull($method->invoke($controller, '', '2025-2026')); + $this->assertNull($method->invoke($controller, 'not-a-date', '2025-2026')); + $this->assertNull($method->invoke($controller, '2026-01-01', '2025-2026')); + $this->assertNull($method->invoke($controller, '2019-09-15', '')); + } +}