This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Services\RegistrationOpeningEmailService;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
class SendRegistrationOpeningEmail extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Registration';
|
||||||
|
protected $name = 'registration:send-opening-email';
|
||||||
|
protected $description = 'Send the parent registration opening email on the configured registration start date.';
|
||||||
|
protected $usage = 'php spark registration:send-opening-email [--force] [--date=YYYY-MM-DD] [--email=parent@example.com] [--dry-run] [--tz=America/New_York]';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$tzName = (string) (CLI::getOption('tz') ?? config('School')->attendance['timezone'] ?? 'America/New_York');
|
||||||
|
$timezone = new \DateTimeZone($tzName);
|
||||||
|
$dateOption = CLI::getOption('date');
|
||||||
|
$date = $dateOption
|
||||||
|
? new \DateTimeImmutable((string) $dateOption, $timezone)
|
||||||
|
: new \DateTimeImmutable('today', $timezone);
|
||||||
|
|
||||||
|
$force = CLI::getOption('force') !== null;
|
||||||
|
$dryRun = CLI::getOption('dry-run') !== null;
|
||||||
|
$email = CLI::getOption('email');
|
||||||
|
$email = is_string($email) && trim($email) !== '' ? trim($email) : null;
|
||||||
|
|
||||||
|
if ($email !== null && ! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
CLI::error('Invalid --email value.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new RegistrationOpeningEmailService();
|
||||||
|
$summary = $service->sendForDate($date, $force, $email, $dryRun);
|
||||||
|
|
||||||
|
foreach ($summary['messages'] as $message) {
|
||||||
|
CLI::write($message, 'yellow');
|
||||||
|
}
|
||||||
|
|
||||||
|
$sentLabel = $dryRun ? 'Would send' : 'Sent';
|
||||||
|
|
||||||
|
CLI::write(sprintf(
|
||||||
|
'Registration opening email complete. Years: %d. Recipients: %d. %s: %d. Failed: %d. Skipped: %d%s.',
|
||||||
|
$summary['school_years'],
|
||||||
|
$summary['recipients'],
|
||||||
|
$sentLabel,
|
||||||
|
$summary['sent'],
|
||||||
|
$summary['failed'],
|
||||||
|
$summary['skipped'],
|
||||||
|
$dryRun ? ' (dry run)' : ''
|
||||||
|
), $summary['failed'] > 0 ? 'red' : 'green');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ class Commands extends BaseService
|
|||||||
\App\Commands\SendAbsenteesSummary::class,
|
\App\Commands\SendAbsenteesSummary::class,
|
||||||
\App\Commands\SendLatesSummary::class,
|
\App\Commands\SendLatesSummary::class,
|
||||||
\App\Commands\SendMonthlyPaymentNotifications::class,
|
\App\Commands\SendMonthlyPaymentNotifications::class,
|
||||||
|
\App\Commands\SendRegistrationOpeningEmail::class,
|
||||||
\App\Commands\SendTestPaymentNotification::class,
|
\App\Commands\SendTestPaymentNotification::class,
|
||||||
\App\Commands\RecalculateAttendance::class,
|
\App\Commands\RecalculateAttendance::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ class Filters extends BaseConfig
|
|||||||
'timezone',
|
'timezone',
|
||||||
'sanitizeinput',
|
'sanitizeinput',
|
||||||
'invalidchars',
|
'invalidchars',
|
||||||
|
'schoolYearWritable',
|
||||||
'csrf' => ['except' => [
|
'csrf' => ['except' => [
|
||||||
// WhatsApp membership management (legacy allowances retained)
|
// WhatsApp membership management (legacy allowances retained)
|
||||||
'whatsapp/update-membership',
|
'whatsapp/update-membership',
|
||||||
|
|||||||
@@ -1125,7 +1125,7 @@ $routes->match(['get', 'post'], '/configuration/editConfig/(:num)', 'View\Config
|
|||||||
$routes->post('/configuration/deleteConfig/(:num)', 'View\ConfigurationController::deleteConfig/$1');
|
$routes->post('/configuration/deleteConfig/(:num)', 'View\ConfigurationController::deleteConfig/$1');
|
||||||
|
|
||||||
// School-year management
|
// School-year management
|
||||||
$routes->group('administrator/school-years', ['filter' => 'auth:admin'], static function ($routes) {
|
$routes->group('administrator/school-years', ['filter' => 'auth:admin|administrator|principal|vice_principal'], static function ($routes) {
|
||||||
$routes->get('', 'Administrator\SchoolYearController::index');
|
$routes->get('', 'Administrator\SchoolYearController::index');
|
||||||
$routes->post('store', 'Administrator\SchoolYearController::store');
|
$routes->post('store', 'Administrator\SchoolYearController::store');
|
||||||
$routes->post('(:num)/update', 'Administrator\SchoolYearController::update/$1');
|
$routes->post('(:num)/update', 'Administrator\SchoolYearController::update/$1');
|
||||||
|
|||||||
@@ -181,7 +181,9 @@ abstract class BaseController extends Controller
|
|||||||
try {
|
try {
|
||||||
service('renderer')->setData(service('schoolYearViewData')->forCurrentRequest($this->request));
|
service('renderer')->setData(service('schoolYearViewData')->forCurrentRequest($this->request));
|
||||||
} catch (SchoolYearConfigurationException $e) {
|
} catch (SchoolYearConfigurationException $e) {
|
||||||
throw $e;
|
log_message('warning', 'Unable to inject school-year view data: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
log_message('warning', 'Unable to inject school-year view data: {message}', [
|
log_message('warning', 'Unable to inject school-year view data: {message}', [
|
||||||
'message' => $e->getMessage(),
|
'message' => $e->getMessage(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use App\Models\ClassSectionModel;
|
|||||||
use App\Models\StudentClassModel;
|
use App\Models\StudentClassModel;
|
||||||
use App\Models\StudentSectionDistributionDraftModel;
|
use App\Models\StudentSectionDistributionDraftModel;
|
||||||
use App\Models\AuthorizedUserModel;
|
use App\Models\AuthorizedUserModel;
|
||||||
|
use App\Models\ParentPolicyAcceptanceModel;
|
||||||
use App\Models\EmergencyContactModel;
|
use App\Models\EmergencyContactModel;
|
||||||
use App\Models\ConfigurationModel;
|
use App\Models\ConfigurationModel;
|
||||||
use App\Models\StudentMedicalConditionModel;
|
use App\Models\StudentMedicalConditionModel;
|
||||||
@@ -54,6 +55,7 @@ class ParentController extends BaseController
|
|||||||
protected $medicalConditionModel;
|
protected $medicalConditionModel;
|
||||||
protected $allergyModel;
|
protected $allergyModel;
|
||||||
protected $authorizedUsersModel;
|
protected $authorizedUsersModel;
|
||||||
|
protected ParentPolicyAcceptanceModel $policyAcceptanceModel;
|
||||||
protected $schoolStartDate;
|
protected $schoolStartDate;
|
||||||
protected $ageDateRefernce;
|
protected $ageDateRefernce;
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ class ParentController extends BaseController
|
|||||||
$this->medicalConditionModel = new StudentMedicalConditionModel();
|
$this->medicalConditionModel = new StudentMedicalConditionModel();
|
||||||
$this->allergyModel = new StudentAllergyModel();
|
$this->allergyModel = new StudentAllergyModel();
|
||||||
$this->authorizedUsersModel = new AuthorizedUserModel();
|
$this->authorizedUsersModel = new AuthorizedUserModel();
|
||||||
|
$this->policyAcceptanceModel = new ParentPolicyAcceptanceModel();
|
||||||
|
|
||||||
$this->ageDateRefernce = $this->configModel->getConfig('date_age_reference');
|
$this->ageDateRefernce = $this->configModel->getConfig('date_age_reference');
|
||||||
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline');
|
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline');
|
||||||
@@ -270,7 +273,7 @@ class ParentController extends BaseController
|
|||||||
|
|
||||||
// Verify user type is "parent" from the `users` table
|
// Verify user type is "parent" from the `users` table
|
||||||
$userData = $this->db->table('users')
|
$userData = $this->db->table('users')
|
||||||
->select('user_type')
|
->select('user_type, accept_school_policy')
|
||||||
->where('id', $parentId)
|
->where('id', $parentId)
|
||||||
->get()
|
->get()
|
||||||
->getRowArray();
|
->getRowArray();
|
||||||
@@ -290,6 +293,9 @@ class ParentController extends BaseController
|
|||||||
return redirect()->to('/no-kids')->with('error', 'No students found. Please register your child first.');
|
return redirect()->to('/no-kids')->with('error', 'No students found. Please register your child first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$previousSchoolYear = $this->previousSchoolYearName($selectedYear);
|
||||||
|
$fallMakeupExamOn = $this->fallMakeupExamDateForYear($selectedYear);
|
||||||
|
|
||||||
// Map enrollment statuses
|
// Map enrollment statuses
|
||||||
$statusMap = [
|
$statusMap = [
|
||||||
'admission under review' => 'admission under review',
|
'admission under review' => 'admission under review',
|
||||||
@@ -356,16 +362,23 @@ class ParentController extends BaseController
|
|||||||
$student['enrollment_status'],
|
$student['enrollment_status'],
|
||||||
['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied']
|
['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied']
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$student['previous_year_decision'] = $previousSchoolYear !== null
|
||||||
|
? $this->studentDecisionForYear((int) $studentId, $previousSchoolYear)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render view
|
// Render view
|
||||||
return view('/parent/enroll_classes', [
|
return view('/parent/enroll_classes', [
|
||||||
'students' => $students,
|
'students' => $students,
|
||||||
'selectedYear' => $selectedYear,
|
'selectedYear' => $selectedYear,
|
||||||
|
'previousSchoolYear' => $previousSchoolYear,
|
||||||
|
'fallMakeupExamOn' => $fallMakeupExamOn,
|
||||||
'isEditable' => $isEditable,
|
'isEditable' => $isEditable,
|
||||||
'withdrawalDeadline' => $this->withdrawalDeadline,
|
'withdrawalDeadline' => $this->withdrawalDeadline,
|
||||||
'lastDayOfRegistration' => $this->lastDayOfRegistration,
|
'lastDayOfRegistration' => $this->lastDayOfRegistration,
|
||||||
'schoolStartDate' => $this->schoolStartDate
|
'schoolStartDate' => $this->schoolStartDate,
|
||||||
|
'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear),
|
||||||
]);
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage());
|
log_message('error', 'An error occurred in enrollClasses: ' . $e->getMessage());
|
||||||
@@ -389,6 +402,25 @@ class ParentController extends BaseController
|
|||||||
$withdraw = $this->request->getPost('withdraw'); // Selected students for withdrawal
|
$withdraw = $this->request->getPost('withdraw'); // Selected students for withdrawal
|
||||||
$parentId = session()->get('user_id'); // Parent ID from session
|
$parentId = session()->get('user_id'); // Parent ID from session
|
||||||
|
|
||||||
|
if (!empty($enroll)) {
|
||||||
|
$parent = $this->userModel->find((int) $parentId);
|
||||||
|
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
|
$hasAcceptedPolicy = $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear);
|
||||||
|
$acceptedOnSubmit = (string) ($this->request->getPost('accept_school_policy') ?? '') === '1';
|
||||||
|
|
||||||
|
if (!$hasAcceptedPolicy && !$acceptedOnSubmit) {
|
||||||
|
return redirect()->back()->withInput()->with('error', 'You must read and accept the school policy before enrolling students.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$hasAcceptedPolicy && $acceptedOnSubmit) {
|
||||||
|
$this->recordPolicyAcceptance((int) $parentId, $selectedYear, 'returning_parent_enrollment');
|
||||||
|
$this->userModel->update((int) $parentId, [
|
||||||
|
'accept_school_policy' => 1,
|
||||||
|
'updated_at' => utc_now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->lastDayOfRegistration && local_date(utc_now(), 'Y-m-d') > date('Y-m-d', strtotime($this->lastDayOfRegistration))) {
|
if ($this->lastDayOfRegistration && local_date(utc_now(), 'Y-m-d') > date('Y-m-d', strtotime($this->lastDayOfRegistration))) {
|
||||||
if (!empty($enroll)) {
|
if (!empty($enroll)) {
|
||||||
return redirect()->back()->with('error', 'Enrollment deadline has passed. New enrollments are not allowed.');
|
return redirect()->back()->with('error', 'Enrollment deadline has passed. New enrollments are not allowed.');
|
||||||
@@ -404,6 +436,12 @@ class ParentController extends BaseController
|
|||||||
$studentData = [];
|
$studentData = [];
|
||||||
|
|
||||||
if (!empty($enroll)) {
|
if (!empty($enroll)) {
|
||||||
|
$selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? ''));
|
||||||
|
$blockingDecisionMessages = $this->blockedEnrollmentDecisionMessages(array_map('intval', (array) $enroll), $selectedYear);
|
||||||
|
if ($blockingDecisionMessages !== []) {
|
||||||
|
return redirect()->back()->withInput()->with('error', implode(' ', $blockingDecisionMessages));
|
||||||
|
}
|
||||||
|
|
||||||
foreach ($enroll as $studentId) {
|
foreach ($enroll as $studentId) {
|
||||||
// Get student full name (supports both string return or array with firstname/lastname)
|
// Get student full name (supports both string return or array with firstname/lastname)
|
||||||
$studentInfo = $this->studentModel->getFullNameById($studentId);
|
$studentInfo = $this->studentModel->getFullNameById($studentId);
|
||||||
@@ -571,6 +609,40 @@ class ParentController extends BaseController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function hasAcceptedPolicyForYear(int $parentId, string $schoolYear): bool
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || $schoolYear === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->policyAcceptanceModel->hasAccepted($parentId, $schoolYear);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
log_message('error', 'Failed to read parent policy acceptance: {message}', [
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordPolicyAcceptance(int $parentId, string $schoolYear, string $source): void
|
||||||
|
{
|
||||||
|
if ($parentId <= 0 || $schoolYear === '') {
|
||||||
|
throw new \RuntimeException('Unable to record school policy acceptance.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->policyAcceptanceModel->recordAcceptance(
|
||||||
|
$parentId,
|
||||||
|
$schoolYear,
|
||||||
|
$source,
|
||||||
|
$this->request->getIPAddress(),
|
||||||
|
$this->request->getUserAgent()->getAgentString()
|
||||||
|
)) {
|
||||||
|
throw new \RuntimeException('Unable to record school policy acceptance.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If a promotion_queue record exists for this student and the given school year,
|
* If a promotion_queue record exists for this student and the given school year,
|
||||||
* create/update student_class row accordingly (base section until distribution),
|
* create/update student_class row accordingly (base section until distribution),
|
||||||
@@ -705,6 +777,84 @@ class ParentController extends BaseController
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function studentDecisionForYear(int $studentId, string $schoolYear): ?array
|
||||||
|
{
|
||||||
|
if ($studentId <= 0 || $schoolYear === '' || ! $this->db->tableExists('student_decisions')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->db->table('student_decisions')
|
||||||
|
->select('decision, source, notes, class_section_name')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->orderBy('updated_at', 'DESC')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray() ?: null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'studentDecisionForYear failed: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fallMakeupExamDateForYear(string $schoolYear): ?string
|
||||||
|
{
|
||||||
|
if ($schoolYear === '' || ! $this->db->tableExists('school_years') || ! $this->db->fieldExists('fall_makeup_exam_on', 'school_years')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$row = $this->db->table('school_years')
|
||||||
|
->select('fall_makeup_exam_on')
|
||||||
|
->where('name', $schoolYear)
|
||||||
|
->limit(1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$date = trim((string) ($row['fall_makeup_exam_on'] ?? ''));
|
||||||
|
return $date !== '' ? $date : null;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
log_message('error', 'fallMakeupExamDateForYear failed: ' . $e->getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function blockedEnrollmentDecisionMessages(array $studentIds, string $targetSchoolYear): array
|
||||||
|
{
|
||||||
|
$previousSchoolYear = $this->previousSchoolYearName($targetSchoolYear);
|
||||||
|
if ($previousSchoolYear === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$messages = [];
|
||||||
|
foreach (array_unique($studentIds) as $studentId) {
|
||||||
|
$studentId = (int) $studentId;
|
||||||
|
$decisionRow = $this->studentDecisionForYear($studentId, $previousSchoolYear);
|
||||||
|
$decision = strtolower(trim((string) ($decisionRow['decision'] ?? '')));
|
||||||
|
$source = strtolower(trim((string) ($decisionRow['source'] ?? '')));
|
||||||
|
|
||||||
|
if ($decision === 'pass' || $decision === 'repeat class') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($decision === '' && $source !== 'pending') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$studentInfo = $this->studentModel->getFullNameById($studentId);
|
||||||
|
$studentName = is_array($studentInfo)
|
||||||
|
? trim((string) ($studentInfo['firstname'] ?? '') . ' ' . (string) ($studentInfo['lastname'] ?? ''))
|
||||||
|
: trim((string) $studentInfo);
|
||||||
|
$studentName = $studentName !== '' ? $studentName : 'Student ID ' . $studentId;
|
||||||
|
|
||||||
|
$messages[] = $studentName . ': enrollment cannot be submitted until the prior-year decision is resolved with administration.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $messages;
|
||||||
|
}
|
||||||
|
|
||||||
private function previousSchoolYearName(string $schoolYear): ?string
|
private function previousSchoolYearName(string $schoolYear): ?string
|
||||||
{
|
{
|
||||||
$schoolYear = trim($schoolYear);
|
$schoolYear = trim($schoolYear);
|
||||||
@@ -1292,15 +1442,17 @@ class ParentController extends BaseController
|
|||||||
|
|
||||||
$kids = $this->studentModel->where('parent_id', $parentId)->findAll();
|
$kids = $this->studentModel->where('parent_id', $parentId)->findAll();
|
||||||
foreach ($kids as &$kid) {
|
foreach ($kids as &$kid) {
|
||||||
|
$studentId = (int) ($kid['id'] ?? 0);
|
||||||
$kid['allergies'] = $this->allergyModel
|
$kid['allergies'] = $this->allergyModel
|
||||||
->where('student_id', $kid['id'])
|
->where('student_id', $studentId)
|
||||||
->findColumn('allergy') ?? [];
|
->findColumn('allergy') ?? [];
|
||||||
|
|
||||||
$kid['medical_conditions'] = $this->medicalConditionModel
|
$kid['medical_conditions'] = $this->medicalConditionModel
|
||||||
->where('student_id', $kid['id'])
|
->where('student_id', $studentId)
|
||||||
->findColumn('condition_name') ?? [];
|
->findColumn('condition_name') ?? [];
|
||||||
|
|
||||||
$kid['enrollment'] = isset($enrollmentMap[$kid['id']]['id']) && !empty($enrollmentMap[$kid['id']]['id']) ? 1 : 0;
|
$kid['enrollment'] = isset($enrollmentMap[$studentId]['id']) && !empty($enrollmentMap[$studentId]['id']) ? 1 : 0;
|
||||||
|
$kid['can_delete'] = $this->canParentDeleteStudent($kid, $parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$emergencies = $this->emergencyContactModel->where('parent_id', $parentId)->findAll();
|
$emergencies = $this->emergencyContactModel->where('parent_id', $parentId)->findAll();
|
||||||
@@ -1784,6 +1936,10 @@ $existing = $this->studentModel
|
|||||||
return redirect()->back()->with('error', 'Student not found or unauthorized.');
|
return redirect()->back()->with('error', 'Student not found or unauthorized.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->canParentDeleteStudent($student, (int) $parentId)) {
|
||||||
|
return redirect()->back()->with('error', 'This student has enrollment history and cannot be deleted. Please contact administration if a change is needed.');
|
||||||
|
}
|
||||||
|
|
||||||
// Perform deletion
|
// Perform deletion
|
||||||
if (!$this->studentModel->delete($id)) {
|
if (!$this->studentModel->delete($id)) {
|
||||||
return redirect()->back()->with('error', 'Failed to delete student.');
|
return redirect()->back()->with('error', 'Failed to delete student.');
|
||||||
@@ -1803,6 +1959,51 @@ $existing = $this->studentModel
|
|||||||
return redirect()->to('/parent/child_register')->with('success', 'Student deleted successfully.');
|
return redirect()->to('/parent/child_register')->with('success', 'Student deleted successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function canParentDeleteStudent(array $student, int $parentId): bool
|
||||||
|
{
|
||||||
|
$studentId = (int) ($student['id'] ?? 0);
|
||||||
|
if ($studentId <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((string) ($student['is_new'] ?? '1') === '0') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->studentHasEnrollmentHistory($studentId, $parentId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->studentHasClassAssignmentHistory($studentId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function studentHasEnrollmentHistory(int $studentId, int $parentId): bool
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('enrollments')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('enrollments')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function studentHasClassAssignmentHistory(int $studentId): bool
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('student_class')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->db->table('student_class')
|
||||||
|
->where('student_id', $studentId)
|
||||||
|
->countAllResults() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private function formatName(string $name): string
|
private function formatName(string $name): string
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Models\UserModel;
|
|||||||
use App\Models\AuthorizedUserModel;
|
use App\Models\AuthorizedUserModel;
|
||||||
use App\Models\RoleModel;
|
use App\Models\RoleModel;
|
||||||
use App\Models\ParentModel;
|
use App\Models\ParentModel;
|
||||||
|
use App\Models\ParentPolicyAcceptanceModel;
|
||||||
use App\Models\UserRoleModel;
|
use App\Models\UserRoleModel;
|
||||||
use App\Services\EmailService;
|
use App\Services\EmailService;
|
||||||
use App\Services\SchoolIdService;
|
use App\Services\SchoolIdService;
|
||||||
@@ -23,6 +24,7 @@ class RegisterController extends Controller
|
|||||||
protected $roleModel;
|
protected $roleModel;
|
||||||
protected $userModel;
|
protected $userModel;
|
||||||
protected $parentModel;
|
protected $parentModel;
|
||||||
|
protected ParentPolicyAcceptanceModel $policyAcceptanceModel;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
@@ -40,6 +42,7 @@ class RegisterController extends Controller
|
|||||||
$this->roleModel = new RoleModel();
|
$this->roleModel = new RoleModel();
|
||||||
$this->userModel = new UserModel();
|
$this->userModel = new UserModel();
|
||||||
$this->parentModel = new ParentModel();
|
$this->parentModel = new ParentModel();
|
||||||
|
$this->policyAcceptanceModel = new ParentPolicyAcceptanceModel();
|
||||||
|
|
||||||
$this->semester = $this->configModel->getConfig('semester');
|
$this->semester = $this->configModel->getConfig('semester');
|
||||||
$this->schoolYear = $this->configModel->getConfig('school_year');
|
$this->schoolYear = $this->configModel->getConfig('school_year');
|
||||||
@@ -119,7 +122,7 @@ class RegisterController extends Controller
|
|||||||
'city' => 'required|alpha_space|min_length[2]|max_length[30]',
|
'city' => 'required|alpha_space|min_length[2]|max_length[30]',
|
||||||
'state' => 'required|in_list[CT,ME,MA,NH,NY,RI,VT]',
|
'state' => 'required|in_list[CT,ME,MA,NH,NY,RI,VT]',
|
||||||
'zip' => 'required|regex_match[/^\d{5}$/]',
|
'zip' => 'required|regex_match[/^\d{5}$/]',
|
||||||
'accept_school_policy' => 'required',
|
'accept_school_policy' => 'required|in_list[1]',
|
||||||
'captcha' => 'required|alpha_numeric|min_length[4]|max_length[10]',
|
'captcha' => 'required|alpha_numeric|min_length[4]|max_length[10]',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -221,6 +224,21 @@ class RegisterController extends Controller
|
|||||||
'created_at' => utc_now(),
|
'created_at' => utc_now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($isParent && (int) $post['accept_school_policy'] === 1) {
|
||||||
|
$recordedPolicyAcceptance = $this->policyAcceptanceModel->recordAcceptance(
|
||||||
|
(int) $firstParentId,
|
||||||
|
(string) $this->schoolYear,
|
||||||
|
'new_parent_registration',
|
||||||
|
$this->request->getIPAddress(),
|
||||||
|
$this->request->getUserAgent()->getAgentString()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $recordedPolicyAcceptance) {
|
||||||
|
$this->db->transRollback();
|
||||||
|
return redirect()->back()->withInput()->with('error', 'Unable to record school policy acceptance. Please try again.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ───────────── 8. Optional second-parent insert ───────────── */
|
/* ───────────── 8. Optional second-parent insert ───────────── */
|
||||||
if ($isParent) {
|
if ($isParent) {
|
||||||
if (empty($rawPost['no_second_parent_info'])) {
|
if (empty($rawPost['no_second_parent_info'])) {
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ class CreateSchoolYears extends Migration
|
|||||||
'type' => 'DATE',
|
'type' => 'DATE',
|
||||||
'null' => true,
|
'null' => true,
|
||||||
],
|
],
|
||||||
|
'fall_makeup_exam_on' => [
|
||||||
|
'type' => 'DATE',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
'previous_school_year_id' => [
|
'previous_school_year_id' => [
|
||||||
'type' => 'INT',
|
'type' => 'INT',
|
||||||
'constraint' => 11,
|
'constraint' => 11,
|
||||||
@@ -144,6 +148,7 @@ class CreateSchoolYears extends Migration
|
|||||||
'description' => ['type' => 'TEXT', 'null' => true],
|
'description' => ['type' => 'TEXT', 'null' => true],
|
||||||
'registration_starts_on' => ['type' => 'DATE', 'null' => true],
|
'registration_starts_on' => ['type' => 'DATE', 'null' => true],
|
||||||
'registration_ends_on' => ['type' => 'DATE', 'null' => true],
|
'registration_ends_on' => ['type' => 'DATE', 'null' => true],
|
||||||
|
'fall_makeup_exam_on' => ['type' => 'DATE', 'null' => true],
|
||||||
'previous_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
'previous_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||||
'next_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
'next_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true],
|
||||||
'activated_at' => ['type' => 'DATETIME', 'null' => true],
|
'activated_at' => ['type' => 'DATETIME', 'null' => true],
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class AddFallMakeupExamToSchoolYears extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('school_years') || $this->db->fieldExists('fall_makeup_exam_on', 'school_years')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addColumn('school_years', [
|
||||||
|
'fall_makeup_exam_on' => [
|
||||||
|
'type' => 'DATE',
|
||||||
|
'null' => true,
|
||||||
|
'after' => 'registration_ends_on',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('school_years') || ! $this->db->fieldExists('fall_makeup_exam_on', 'school_years')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->dropColumn('school_years', 'fall_makeup_exam_on');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateParentPolicyAcceptances extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('parent_policy_acceptances')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->forge->addField([
|
||||||
|
'id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
'auto_increment' => true,
|
||||||
|
],
|
||||||
|
'parent_id' => [
|
||||||
|
'type' => 'INT',
|
||||||
|
'constraint' => 11,
|
||||||
|
'unsigned' => true,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'school_year' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 9,
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'accepted_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => false,
|
||||||
|
],
|
||||||
|
'source' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 40,
|
||||||
|
'null' => false,
|
||||||
|
'default' => 'registration',
|
||||||
|
],
|
||||||
|
'ip_address' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 45,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'user_agent' => [
|
||||||
|
'type' => 'VARCHAR',
|
||||||
|
'constraint' => 255,
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'created_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
'updated_at' => [
|
||||||
|
'type' => 'DATETIME',
|
||||||
|
'null' => true,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->forge->addKey('id', true);
|
||||||
|
$this->forge->addUniqueKey(['parent_id', 'school_year'], 'uq_parent_policy_year');
|
||||||
|
$this->forge->addKey('school_year');
|
||||||
|
$this->forge->createTable('parent_policy_acceptances');
|
||||||
|
|
||||||
|
$this->backfillExistingAcceptances();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$this->forge->dropTable('parent_policy_acceptances', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function backfillExistingAcceptances(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('users')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$schoolYear = $this->configuredSchoolYear();
|
||||||
|
if ($schoolYear === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder = $this->db->table('users u')
|
||||||
|
->select('u.id')
|
||||||
|
->where('u.accept_school_policy', 1);
|
||||||
|
|
||||||
|
$hasUserType = $this->db->fieldExists('user_type', 'users');
|
||||||
|
$hasRoleTables = $this->db->tableExists('user_roles') && $this->db->tableExists('roles');
|
||||||
|
|
||||||
|
if ($hasUserType && $hasRoleTables) {
|
||||||
|
$builder->groupStart()
|
||||||
|
->where('u.user_type', 'primary')
|
||||||
|
->orWhere(
|
||||||
|
'EXISTS (SELECT 1 FROM user_roles ur INNER JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = u.id AND LOWER(r.name) = ' . $this->db->escape('parent') . ')',
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
->groupEnd();
|
||||||
|
} elseif ($hasUserType) {
|
||||||
|
$builder->where('u.user_type', 'primary');
|
||||||
|
} elseif ($hasRoleTables) {
|
||||||
|
$builder->where(
|
||||||
|
'EXISTS (SELECT 1 FROM user_roles ur INNER JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = u.id AND LOWER(r.name) = ' . $this->db->escape('parent') . ')',
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $builder->get()
|
||||||
|
->getResultArray();
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$parentId = (int) ($row['id'] ?? 0);
|
||||||
|
if ($parentId <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing = $this->db->table('parent_policy_acceptances')
|
||||||
|
->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->get(1)
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('parent_policy_acceptances')->insert([
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'accepted_at' => date('Y-m-d H:i:s'),
|
||||||
|
'source' => 'legacy_backfill',
|
||||||
|
'created_at' => date('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => date('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function configuredSchoolYear(): ?string
|
||||||
|
{
|
||||||
|
if ($this->db->tableExists('school_years')) {
|
||||||
|
$row = $this->db->table('school_years')
|
||||||
|
->select('name')
|
||||||
|
->where('status', 'active')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get(1)
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$name = trim((string) ($row['name'] ?? ''));
|
||||||
|
if (preg_match('/^\d{4}-\d{4}$/', $name)) {
|
||||||
|
return $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->db->tableExists('configuration')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->db->table('configuration')
|
||||||
|
->select('config_value')
|
||||||
|
->where('config_key', 'school_year')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->get(1)
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
$name = trim((string) ($row['config_value'] ?? ''));
|
||||||
|
|
||||||
|
return preg_match('/^\d{4}-\d{4}$/', $name) ? $name : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateRegistrationOpeningEmailTemplate extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('email_templates')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = $this->db->getFieldNames('email_templates');
|
||||||
|
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
|
||||||
|
$bodyField = in_array('body_html', $fields, true) ? 'body_html' : 'body';
|
||||||
|
$nameField = in_array('name', $fields, true) ? 'name' : null;
|
||||||
|
|
||||||
|
$exists = $this->db->table('email_templates')
|
||||||
|
->where($keyField, 'registration_opening')
|
||||||
|
->countAllResults() > 0;
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
$keyField => 'registration_opening',
|
||||||
|
'subject' => 'Registration is now open for {{school_year}}',
|
||||||
|
$bodyField => $this->bodyHtml(),
|
||||||
|
'is_active' => 1,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($nameField !== null) {
|
||||||
|
$data[$nameField] = 'Registration Opening';
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->db->table('email_templates')->insert($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('email_templates')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = $this->db->getFieldNames('email_templates');
|
||||||
|
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
|
||||||
|
|
||||||
|
$this->db->table('email_templates')
|
||||||
|
->where($keyField, 'registration_opening')
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bodyHtml(): string
|
||||||
|
{
|
||||||
|
return <<<'HTML'
|
||||||
|
<p>Dear {{name}},</p>
|
||||||
|
<p>Registration is now open for the {{school_year}} school year.</p>
|
||||||
|
<p>Please complete your registration through the school portal. The registration deadline is {{registration_deadline}}.</p>
|
||||||
|
<p><a href="{{registration_url}}">Start registration</a></p>
|
||||||
|
<p>If you already have an account, you can also log in here: <a href="{{login_url}}">{{login_url}}</a></p>
|
||||||
|
<p>Regards,<br>{{school_name}}</p>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,12 +10,50 @@ use CodeIgniter\HTTP\ResponseInterface;
|
|||||||
|
|
||||||
final class SchoolYearWritableFilter implements FilterInterface
|
final class SchoolYearWritableFilter implements FilterInterface
|
||||||
{
|
{
|
||||||
|
private const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||||
|
private const DESTRUCTIVE_PATH_SEGMENTS = ['delete', 'remove'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes here either change the selected context itself or are account/system
|
||||||
|
* actions that are not scoped to a school year.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const EXEMPT_PATHS = [
|
||||||
|
'school-year/select',
|
||||||
|
'school-year/reset',
|
||||||
|
'administrator/school-years',
|
||||||
|
'user/login',
|
||||||
|
'api/login',
|
||||||
|
'api/register',
|
||||||
|
'user/select_role',
|
||||||
|
'set-role',
|
||||||
|
'processForgotPassword',
|
||||||
|
'user/processResetPassword',
|
||||||
|
'user/save_password',
|
||||||
|
'set_authorized_user_password',
|
||||||
|
];
|
||||||
|
|
||||||
public function before(RequestInterface $request, $arguments = null)
|
public function before(RequestInterface $request, $arguments = null)
|
||||||
{
|
{
|
||||||
if (! $request instanceof IncomingRequest) {
|
if (! $request instanceof IncomingRequest) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$path = $this->normalizedPath($request);
|
||||||
|
|
||||||
|
if (! $this->isWriteAttempt($request, $path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! session()->get('user_id')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isExemptPath($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
service('schoolYearWriteGuard')->assertWritable(
|
service('schoolYearWriteGuard')->assertWritable(
|
||||||
service('schoolYearContext')->resolve($request)
|
service('schoolYearContext')->resolve($request)
|
||||||
@@ -45,4 +83,42 @@ final class SchoolYearWritableFilter implements FilterInterface
|
|||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isWriteAttempt(IncomingRequest $request, string $path): bool
|
||||||
|
{
|
||||||
|
if (in_array(strtoupper($request->getMethod()), self::WRITE_METHODS, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = explode('/', $path);
|
||||||
|
|
||||||
|
foreach (self::DESTRUCTIVE_PATH_SEGMENTS as $segment) {
|
||||||
|
if (in_array($segment, $segments, true)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isExemptPath(string $path): bool
|
||||||
|
{
|
||||||
|
foreach (self::EXEMPT_PATHS as $exemptPath) {
|
||||||
|
if ($path === $exemptPath || str_starts_with($path, $exemptPath . '/')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizedPath(IncomingRequest $request): string
|
||||||
|
{
|
||||||
|
$path = trim($request->getUri()->getPath(), '/');
|
||||||
|
if (str_starts_with($path, 'index.php/')) {
|
||||||
|
return substr($path, strlen('index.php/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class ParentPolicyAcceptanceModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'parent_policy_acceptances';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
|
||||||
|
protected $allowedFields = [
|
||||||
|
'parent_id',
|
||||||
|
'school_year',
|
||||||
|
'accepted_at',
|
||||||
|
'source',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $validationRules = [
|
||||||
|
'parent_id' => 'required|integer',
|
||||||
|
'school_year' => 'required|regex_match[/^\d{4}-\d{4}$/]|max_length[9]',
|
||||||
|
'accepted_at' => 'required|valid_date[Y-m-d H:i:s]',
|
||||||
|
'source' => 'required|max_length[40]',
|
||||||
|
'ip_address' => 'permit_empty|max_length[45]',
|
||||||
|
'user_agent' => 'permit_empty|max_length[255]',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function hasAccepted(int $parentId, string $schoolYear): bool
|
||||||
|
{
|
||||||
|
return $this->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->first() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recordAcceptance(
|
||||||
|
int $parentId,
|
||||||
|
string $schoolYear,
|
||||||
|
string $source,
|
||||||
|
?string $ipAddress = null,
|
||||||
|
?string $userAgent = null
|
||||||
|
): bool {
|
||||||
|
$payload = [
|
||||||
|
'parent_id' => $parentId,
|
||||||
|
'school_year' => $schoolYear,
|
||||||
|
'accepted_at' => utc_now(),
|
||||||
|
'source' => $source,
|
||||||
|
'ip_address' => $ipAddress,
|
||||||
|
'user_agent' => $userAgent !== null ? substr($userAgent, 0, 255) : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$existing = $this->where('parent_id', $parentId)
|
||||||
|
->where('school_year', $schoolYear)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
return $this->update((int) $existing[$this->primaryKey], $payload) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->insert($payload) !== false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ class SchoolYearModel extends Model
|
|||||||
'description',
|
'description',
|
||||||
'registration_starts_on',
|
'registration_starts_on',
|
||||||
'registration_ends_on',
|
'registration_ends_on',
|
||||||
|
'fall_makeup_exam_on',
|
||||||
'previous_school_year_id',
|
'previous_school_year_id',
|
||||||
'next_school_year_id',
|
'next_school_year_id',
|
||||||
'activated_at',
|
'activated_at',
|
||||||
@@ -36,6 +37,7 @@ class SchoolYearModel extends Model
|
|||||||
'ends_on' => 'permit_empty|valid_date[Y-m-d]',
|
'ends_on' => 'permit_empty|valid_date[Y-m-d]',
|
||||||
'registration_starts_on' => 'permit_empty|valid_date[Y-m-d]',
|
'registration_starts_on' => 'permit_empty|valid_date[Y-m-d]',
|
||||||
'registration_ends_on' => 'permit_empty|valid_date[Y-m-d]',
|
'registration_ends_on' => 'permit_empty|valid_date[Y-m-d]',
|
||||||
|
'fall_makeup_exam_on' => 'permit_empty|valid_date[Y-m-d]',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function active(): ?array
|
public function active(): ?array
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\BaseConnection;
|
||||||
|
|
||||||
|
class RegistrationOpeningEmailService
|
||||||
|
{
|
||||||
|
public const TEMPLATE_KEY = 'registration_opening';
|
||||||
|
|
||||||
|
private BaseConnection $db;
|
||||||
|
private EmailService $emailService;
|
||||||
|
|
||||||
|
public function __construct(?BaseConnection $db = null, ?EmailService $emailService = null)
|
||||||
|
{
|
||||||
|
$this->db = $db ?? \Config\Database::connect();
|
||||||
|
$this->emailService = $emailService ?? new EmailService();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sendForDate(\DateTimeInterface $date, bool $force = false, ?string $testEmail = null, bool $dryRun = false): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'school_years' => 0,
|
||||||
|
'recipients' => 0,
|
||||||
|
'sent' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'skipped' => 0,
|
||||||
|
'dry_run' => $dryRun,
|
||||||
|
'messages' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->registrationYearsForDate($date, $force) as $schoolYear) {
|
||||||
|
$summary['school_years']++;
|
||||||
|
|
||||||
|
$recipients = $testEmail !== null
|
||||||
|
? [[
|
||||||
|
'user_id' => null,
|
||||||
|
'family_id' => null,
|
||||||
|
'email' => $testEmail,
|
||||||
|
'name' => 'Parent',
|
||||||
|
]]
|
||||||
|
: $this->parentEmailRecipients();
|
||||||
|
|
||||||
|
if ($recipients === []) {
|
||||||
|
$summary['messages'][] = sprintf('No parent emails found for %s.', $schoolYear['name']);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$subject, $body] = $this->composeEmail($schoolYear);
|
||||||
|
if ($testEmail !== null) {
|
||||||
|
$subject = '[TEST] ' . $subject;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($recipients as $recipient) {
|
||||||
|
if ($testEmail === null && ! $dryRun && $this->alreadySentForRecipient($schoolYear, $recipient['email'])) {
|
||||||
|
$summary['skipped']++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$summary['recipients']++;
|
||||||
|
$personalizedBody = $this->replaceTokens($body, $schoolYear, $recipient);
|
||||||
|
$personalizedSubject = $this->replaceTokens($subject, $schoolYear, $recipient);
|
||||||
|
|
||||||
|
$sent = $dryRun || $this->emailService->send($recipient['email'], $personalizedSubject, $personalizedBody, 'general');
|
||||||
|
$sent ? $summary['sent']++ : $summary['failed']++;
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->logAttempt($schoolYear, $recipient, $personalizedSubject, $personalizedBody, $sent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registrationYearsForDate(\DateTimeInterface $date, bool $force = false): array
|
||||||
|
{
|
||||||
|
$builder = $this->db->table('school_years')
|
||||||
|
->where('registration_starts_on IS NOT NULL');
|
||||||
|
|
||||||
|
if (! $force) {
|
||||||
|
$builder->where('registration_starts_on', $date->format('Y-m-d'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $builder->orderBy('registration_starts_on', 'ASC')
|
||||||
|
->get()
|
||||||
|
->getResultArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function parentEmailRecipients(): array
|
||||||
|
{
|
||||||
|
$recipients = [];
|
||||||
|
|
||||||
|
$hasFamilyGuardians = $this->db->tableExists('family_guardians');
|
||||||
|
|
||||||
|
if ($this->db->tableExists('roles') && $this->db->tableExists('user_roles')) {
|
||||||
|
$builder = $this->db->table('users u')
|
||||||
|
->select($hasFamilyGuardians ? 'u.id AS user_id, fg.family_id, u.firstname, u.lastname, u.email' : 'u.id AS user_id, NULL AS family_id, u.firstname, u.lastname, u.email')
|
||||||
|
->join('user_roles ur', 'ur.user_id = u.id', 'inner')
|
||||||
|
->join('roles r', 'r.id = ur.role_id', 'inner')
|
||||||
|
->where('LOWER(r.name)', 'parent')
|
||||||
|
->where('u.email IS NOT NULL')
|
||||||
|
->where('u.email !=', '');
|
||||||
|
|
||||||
|
if ($hasFamilyGuardians) {
|
||||||
|
$builder->join('family_guardians fg', 'fg.user_id = u.id', 'left')
|
||||||
|
->groupStart()
|
||||||
|
->where('fg.id IS NULL')
|
||||||
|
->orWhere('fg.receive_emails', 1)
|
||||||
|
->groupEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->hasField('user_roles', 'deleted_at')) {
|
||||||
|
$builder->where('ur.deleted_at', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($builder->get()->getResultArray() as $row) {
|
||||||
|
$this->addRecipient($recipients, $row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasFamilyGuardians) {
|
||||||
|
$guardianBuilder = $this->db->table('family_guardians fg')
|
||||||
|
->select('u.id AS user_id, fg.family_id, u.firstname, u.lastname, u.email')
|
||||||
|
->join('users u', 'u.id = fg.user_id', 'inner')
|
||||||
|
->where('fg.receive_emails', 1)
|
||||||
|
->where('u.email IS NOT NULL')
|
||||||
|
->where('u.email !=', '');
|
||||||
|
|
||||||
|
foreach ($guardianBuilder->get()->getResultArray() as $row) {
|
||||||
|
$this->addRecipient($recipients, $row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uasort($recipients, static fn(array $a, array $b): int => strcasecmp($a['email'], $b['email']));
|
||||||
|
|
||||||
|
return array_values($recipients);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alreadySentForSchoolYear(array $schoolYear): bool
|
||||||
|
{
|
||||||
|
$schoolYearId = (int) ($schoolYear['id'] ?? 0);
|
||||||
|
$schoolYearName = (string) ($schoolYear['name'] ?? '');
|
||||||
|
|
||||||
|
$builder = $this->db->table('communication_logs')
|
||||||
|
->where('template_key', self::TEMPLATE_KEY)
|
||||||
|
->where('status', 'sent')
|
||||||
|
->groupStart();
|
||||||
|
|
||||||
|
if ($schoolYearId > 0) {
|
||||||
|
$builder->like('metadata', '"school_year_id":' . $schoolYearId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($schoolYearName !== '') {
|
||||||
|
if ($schoolYearId > 0) {
|
||||||
|
$builder->orLike('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"');
|
||||||
|
} else {
|
||||||
|
$builder->like('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->groupEnd();
|
||||||
|
|
||||||
|
return $builder->countAllResults() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function alreadySentForRecipient(array $schoolYear, string $email): bool
|
||||||
|
{
|
||||||
|
$schoolYearId = (int) ($schoolYear['id'] ?? 0);
|
||||||
|
$schoolYearName = (string) ($schoolYear['name'] ?? '');
|
||||||
|
|
||||||
|
$builder = $this->db->table('communication_logs')
|
||||||
|
->where('template_key', self::TEMPLATE_KEY)
|
||||||
|
->where('status', 'sent')
|
||||||
|
->like('recipients', '"' . strtolower(trim($email)) . '"')
|
||||||
|
->groupStart();
|
||||||
|
|
||||||
|
if ($schoolYearId > 0) {
|
||||||
|
$builder->like('metadata', '"school_year_id":' . $schoolYearId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($schoolYearName !== '') {
|
||||||
|
if ($schoolYearId > 0) {
|
||||||
|
$builder->orLike('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"');
|
||||||
|
} else {
|
||||||
|
$builder->like('metadata', '"school_year":"' . $this->escapeJsonLikeValue($schoolYearName) . '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$builder->groupEnd();
|
||||||
|
|
||||||
|
return $builder->countAllResults() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addRecipient(array &$recipients, array $row): void
|
||||||
|
{
|
||||||
|
$email = strtolower(trim((string) ($row['email'] ?? '')));
|
||||||
|
if (! filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = trim((string) ($row['firstname'] ?? '') . ' ' . (string) ($row['lastname'] ?? ''));
|
||||||
|
$recipients[$email] = [
|
||||||
|
'user_id' => isset($row['user_id']) ? (int) $row['user_id'] : null,
|
||||||
|
'family_id' => isset($row['family_id']) ? (int) $row['family_id'] : null,
|
||||||
|
'email' => $email,
|
||||||
|
'name' => $name !== '' ? $name : 'Parent',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function composeEmail(array $schoolYear): array
|
||||||
|
{
|
||||||
|
$template = $this->loadTemplate();
|
||||||
|
|
||||||
|
$subject = $template['subject'] ?? 'Registration is now open for {{school_year}}';
|
||||||
|
$bodyHtml = $template['body'] ?? $this->defaultBody();
|
||||||
|
$bodyHtml = $this->replaceTokens($bodyHtml, $schoolYear, ['name' => 'Parent', 'email' => '']);
|
||||||
|
|
||||||
|
$body = view('emails/_wrap_layout', [
|
||||||
|
'title' => $this->replaceTokens($subject, $schoolYear, ['name' => 'Parent', 'email' => '']),
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
|
], ['saveData' => true]);
|
||||||
|
|
||||||
|
return [$subject, $body];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loadTemplate(): ?array
|
||||||
|
{
|
||||||
|
if (! $this->db->tableExists('email_templates')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = $this->db->getFieldNames('email_templates');
|
||||||
|
$keyField = in_array('code', $fields, true) ? 'code' : 'template_key';
|
||||||
|
$bodyField = in_array('body_html', $fields, true) ? 'body_html' : 'body';
|
||||||
|
|
||||||
|
$row = $this->db->table('email_templates')
|
||||||
|
->select('subject, ' . $bodyField . ' AS body')
|
||||||
|
->where($keyField, self::TEMPLATE_KEY)
|
||||||
|
->where('is_active', 1)
|
||||||
|
->get()
|
||||||
|
->getRowArray();
|
||||||
|
|
||||||
|
return $row ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function replaceTokens(string $text, array $schoolYear, array $recipient): string
|
||||||
|
{
|
||||||
|
$registrationEnds = trim((string) ($schoolYear['registration_ends_on'] ?? ''));
|
||||||
|
$deadline = $registrationEnds !== '' ? $registrationEnds : 'the posted deadline';
|
||||||
|
|
||||||
|
return strtr($text, [
|
||||||
|
'{{name}}' => (string) ($recipient['name'] ?? 'Parent'),
|
||||||
|
'{{parent_name}}' => (string) ($recipient['name'] ?? 'Parent'),
|
||||||
|
'{{school_year}}' => (string) ($schoolYear['name'] ?? ''),
|
||||||
|
'{{registration_starts_on}}' => (string) ($schoolYear['registration_starts_on'] ?? ''),
|
||||||
|
'{{registration_ends_on}}' => $registrationEnds,
|
||||||
|
'{{registration_deadline}}' => $deadline,
|
||||||
|
'{{registration_url}}' => site_url('user'),
|
||||||
|
'{{login_url}}' => site_url('login'),
|
||||||
|
'{{school_name}}' => 'Al Rahma Sunday School',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function defaultBody(): string
|
||||||
|
{
|
||||||
|
return <<<'HTML'
|
||||||
|
<p>Dear {{name}},</p>
|
||||||
|
<p>Registration is now open for the {{school_year}} school year.</p>
|
||||||
|
<p>Please complete your registration through the school portal. The registration deadline is {{registration_deadline}}.</p>
|
||||||
|
<p><a href="{{registration_url}}">Start registration</a></p>
|
||||||
|
<p>If you already have an account, you can also log in here: <a href="{{login_url}}">{{login_url}}</a></p>
|
||||||
|
<p>Regards,<br>{{school_name}}</p>
|
||||||
|
HTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function logAttempt(array $schoolYear, array $recipient, string $subject, string $body, bool $sent): void
|
||||||
|
{
|
||||||
|
$this->db->table('communication_logs')->insert([
|
||||||
|
'student_id' => 0,
|
||||||
|
'family_id' => $recipient['family_id'] ?: null,
|
||||||
|
'student_name' => 'All Families',
|
||||||
|
'template_key' => self::TEMPLATE_KEY,
|
||||||
|
'subject' => $subject,
|
||||||
|
'body' => $body,
|
||||||
|
'recipients' => json_encode([$recipient['email']]),
|
||||||
|
'status' => $sent ? 'sent' : 'failed',
|
||||||
|
'error_message' => $sent ? null : 'Email send failed',
|
||||||
|
'sent_by' => null,
|
||||||
|
'metadata' => json_encode([
|
||||||
|
'campaign' => self::TEMPLATE_KEY,
|
||||||
|
'school_year_id' => (int) ($schoolYear['id'] ?? 0),
|
||||||
|
'school_year' => (string) ($schoolYear['name'] ?? ''),
|
||||||
|
'registration_starts_on' => $schoolYear['registration_starts_on'] ?? null,
|
||||||
|
'registration_ends_on' => $schoolYear['registration_ends_on'] ?? null,
|
||||||
|
'recipient_user_id' => $recipient['user_id'],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasField(string $table, string $field): bool
|
||||||
|
{
|
||||||
|
return in_array($field, $this->db->getFieldNames($table), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function escapeJsonLikeValue(string $value): string
|
||||||
|
{
|
||||||
|
return trim(json_encode($value), '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,11 +129,21 @@ final class SchoolYearContextService
|
|||||||
{
|
{
|
||||||
$active = $this->schoolYearModel->active();
|
$active = $this->schoolYearModel->active();
|
||||||
|
|
||||||
if ($active === null) {
|
if ($active !== null) {
|
||||||
|
return $this->fromRow($active, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$closing = $this->schoolYearModel
|
||||||
|
->where('status', SchoolYearStatus::CLOSING)
|
||||||
|
->orderBy('closing_started_at', 'DESC')
|
||||||
|
->orderBy('id', 'DESC')
|
||||||
|
->findAll(2);
|
||||||
|
|
||||||
|
if (count($closing) !== 1) {
|
||||||
throw new SchoolYearConfigurationException('Exactly one active school year must be configured.');
|
throw new SchoolYearConfigurationException('Exactly one active school year must be configured.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->fromRow($active, false);
|
return $this->fromRow($closing[0], false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function normalizeInt(mixed $value): ?int
|
private function normalizeInt(mixed $value): ?int
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ final class SchoolYearManagementService
|
|||||||
public function createDraft(array $payload, ?int $userId = null): int
|
public function createDraft(array $payload, ?int $userId = null): int
|
||||||
{
|
{
|
||||||
$payload = $this->metadataPayload($payload);
|
$payload = $this->metadataPayload($payload);
|
||||||
|
$payload['previous_school_year_id'] = $this->previousYearIdForDraft((string) $payload['name']);
|
||||||
$payload['status'] = SchoolYearStatus::DRAFT;
|
$payload['status'] = SchoolYearStatus::DRAFT;
|
||||||
$payload['created_by'] = $userId;
|
$payload['created_by'] = $userId;
|
||||||
$payload['updated_by'] = $userId;
|
$payload['updated_by'] = $userId;
|
||||||
@@ -35,6 +36,7 @@ final class SchoolYearManagementService
|
|||||||
$this->db->transStart();
|
$this->db->transStart();
|
||||||
$id = $this->schoolYearModel->insert($payload, true);
|
$id = $this->schoolYearModel->insert($payload, true);
|
||||||
if ($id !== false) {
|
if ($id !== false) {
|
||||||
|
$this->syncConfigurationFromSchoolYear($payload);
|
||||||
$this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId);
|
$this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId);
|
||||||
}
|
}
|
||||||
$this->db->transComplete();
|
$this->db->transComplete();
|
||||||
@@ -62,6 +64,7 @@ final class SchoolYearManagementService
|
|||||||
$this->db->transStart();
|
$this->db->transStart();
|
||||||
$updated = $this->schoolYearModel->update($id, $payload);
|
$updated = $this->schoolYearModel->update($id, $payload);
|
||||||
if ($updated !== false) {
|
if ($updated !== false) {
|
||||||
|
$this->syncConfigurationFromSchoolYear($payload);
|
||||||
$this->log($id, $status, $status, 'metadata_update', $userId);
|
$this->log($id, $status, $status, 'metadata_update', $userId);
|
||||||
}
|
}
|
||||||
$this->db->transComplete();
|
$this->db->transComplete();
|
||||||
@@ -104,7 +107,7 @@ final class SchoolYearManagementService
|
|||||||
'activated_at' => $now,
|
'activated_at' => $now,
|
||||||
'updated_by' => $userId,
|
'updated_by' => $userId,
|
||||||
]);
|
]);
|
||||||
$this->configurationModel->setConfigValueByKey('school_year', (string) $year['name']);
|
$this->syncConfigurationFromSchoolYear($year);
|
||||||
$this->syncActiveYearSession((string) $year['name']);
|
$this->syncActiveYearSession((string) $year['name']);
|
||||||
$this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId);
|
$this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId);
|
||||||
|
|
||||||
@@ -255,10 +258,69 @@ final class SchoolYearManagementService
|
|||||||
'description' => trim((string) ($payload['description'] ?? '')) ?: null,
|
'description' => trim((string) ($payload['description'] ?? '')) ?: null,
|
||||||
'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null),
|
'registration_starts_on' => $this->nullableDate($payload['registration_starts_on'] ?? null),
|
||||||
'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null),
|
'registration_ends_on' => $this->nullableDate($payload['registration_ends_on'] ?? null),
|
||||||
|
'fall_makeup_exam_on' => $this->nullableDate($payload['fall_makeup_exam_on'] ?? null),
|
||||||
'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null),
|
'previous_school_year_id' => $this->nullableInt($payload['previous_school_year_id'] ?? null),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function syncConfigurationFromSchoolYear(array $schoolYear): void
|
||||||
|
{
|
||||||
|
$name = trim((string) ($schoolYear['name'] ?? ''));
|
||||||
|
if ($name === '') {
|
||||||
|
throw new RuntimeException('Unable to update school-year configuration: school year name is missing.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$ageReferenceDate = $this->ageReferenceDateForSchoolYear($name);
|
||||||
|
$configValues = [
|
||||||
|
'school_year' => $name,
|
||||||
|
'date_age_reference' => $ageReferenceDate,
|
||||||
|
'refund_deadline' => $ageReferenceDate,
|
||||||
|
'enrollment_deadline' => (string) ($schoolYear['registration_ends_on'] ?? ''),
|
||||||
|
'fall_semester_start' => (string) ($schoolYear['starts_on'] ?? ''),
|
||||||
|
'last_day_of_school' => (string) ($schoolYear['ends_on'] ?? ''),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($configValues as $key => $value) {
|
||||||
|
if (! $this->configurationModel->setConfigValueByKey($key, $value)) {
|
||||||
|
throw new RuntimeException("Unable to update configuration value for {$key}.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ageReferenceDateForSchoolYear(string $schoolYearName): string
|
||||||
|
{
|
||||||
|
if (! preg_match('/^(\d{4})-\d{4}$/', $schoolYearName, $matches)) {
|
||||||
|
throw new RuntimeException('Unable to update school-year configuration: invalid school year format.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matches[1] . '-12-31';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function previousYearIdForDraft(string $name): ?int
|
||||||
|
{
|
||||||
|
if (preg_match('/^(\d{4})-(\d{4})$/', $name, $matches)) {
|
||||||
|
$previousName = ((int) $matches[1] - 1) . '-' . (int) $matches[1];
|
||||||
|
$previousYear = $this->schoolYearModel
|
||||||
|
->where('name', $previousName)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($previousYear !== null) {
|
||||||
|
return (int) ($previousYear['id'] ?? 0) ?: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$activeYear = $this->schoolYearModel->active();
|
||||||
|
if ($activeYear !== null) {
|
||||||
|
return (int) ($activeYear['id'] ?? 0) ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$latestYear = $this->schoolYearModel
|
||||||
|
->orderBy('name', 'DESC')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $latestYear !== null ? ((int) ($latestYear['id'] ?? 0) ?: null) : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function nullableDate(mixed $value): ?string
|
private function nullableDate(mixed $value): ?string
|
||||||
{
|
{
|
||||||
$value = trim((string) $value);
|
$value = trim((string) $value);
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ final class SchoolYearValidationService
|
|||||||
if ($registrationStarts !== null && $registrationEnds !== null && $registrationStarts > $registrationEnds) {
|
if ($registrationStarts !== null && $registrationEnds !== null && $registrationStarts > $registrationEnds) {
|
||||||
throw new InvalidArgumentException('Registration start date must be on or before the registration end date.');
|
throw new InvalidArgumentException('Registration start date must be on or before the registration end date.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->dateOrNull($payload['fall_makeup_exam_on'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isValidYearName(string $value): bool
|
public function isValidYearName(string $value): bool
|
||||||
|
|||||||
@@ -25,9 +25,88 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<?php if (session()->getFlashdata('error')): ?>
|
||||||
|
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php $hasAcceptedSchoolPolicy = (bool) ($hasAcceptedSchoolPolicy ?? false); ?>
|
||||||
|
<?php
|
||||||
|
$fallMakeupExamLabel = !empty($fallMakeupExamOn) ? local_date($fallMakeupExamOn, 'm-d-Y') : null;
|
||||||
|
$decisionMessageForStudent = static function (array $student) use ($fallMakeupExamLabel): array {
|
||||||
|
$decisionRow = is_array($student['previous_year_decision'] ?? null) ? $student['previous_year_decision'] : [];
|
||||||
|
$decision = trim((string) ($decisionRow['decision'] ?? ''));
|
||||||
|
$source = strtolower(trim((string) ($decisionRow['source'] ?? '')));
|
||||||
|
$decisionKey = strtolower($decision);
|
||||||
|
$name = trim((string) ($student['firstname'] ?? 'Your child') . ' ' . (string) ($student['lastname'] ?? ''));
|
||||||
|
$name = $name !== '' ? $name : 'Your child';
|
||||||
|
$previousClass = trim((string) ($decisionRow['class_section_name'] ?? ''));
|
||||||
|
$previousClass = $previousClass !== '' ? $previousClass : 'the same class';
|
||||||
|
|
||||||
|
if ($decisionKey === 'pass' || ($decisionKey === '' && $source !== 'pending')) {
|
||||||
|
return ['message' => '', 'blocking' => false, 'level' => 'info'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($decisionKey === 'make-up exam in fall') {
|
||||||
|
$dateText = $fallMakeupExamLabel !== null ? ' on ' . $fallMakeupExamLabel : '';
|
||||||
|
return [
|
||||||
|
'message' => $name . ' has a make-up exam decision. Enrollment can be completed only after the fall make-up exam' . $dateText . ' and after the result is finalized.',
|
||||||
|
'blocking' => true,
|
||||||
|
'level' => 'warning',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($decisionKey === 'deferred decision') {
|
||||||
|
return [
|
||||||
|
'message' => $name . ' has a deferred decision. Enrollment can be completed only after you visit the administration to discuss your child\'s situation.',
|
||||||
|
'blocking' => true,
|
||||||
|
'level' => 'warning',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($decisionKey === 'repeat class') {
|
||||||
|
return [
|
||||||
|
'message' => $name . ' has a repeat class decision and is accepted only in ' . $previousClass . '.',
|
||||||
|
'blocking' => false,
|
||||||
|
'level' => 'info',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($decisionKey === 'expel') {
|
||||||
|
return [
|
||||||
|
'message' => $name . ' has an expel decision. Enrollment cannot be completed online; please contact the administration.',
|
||||||
|
'blocking' => true,
|
||||||
|
'level' => 'danger',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($decisionKey === 'withdrawn') {
|
||||||
|
return [
|
||||||
|
'message' => $name . ' was marked withdrawn in the final decision. Please contact the administration before requesting enrollment.',
|
||||||
|
'blocking' => true,
|
||||||
|
'level' => 'warning',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($decisionKey === '' || $source === 'pending') {
|
||||||
|
return [
|
||||||
|
'message' => $name . ' does not have a final promotion decision yet. Enrollment can be completed only after the administration finalizes the decision.',
|
||||||
|
'blocking' => true,
|
||||||
|
'level' => 'warning',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'message' => $name . ' has a "' . $decision . '" decision. Please contact the administration before requesting enrollment.',
|
||||||
|
'blocking' => true,
|
||||||
|
'level' => 'warning',
|
||||||
|
];
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
<?php if (!empty($students)): ?>
|
<?php if (!empty($students)): ?>
|
||||||
<form action="<?= base_url('/parent/enroll_classes_handler') ?>" method="post">
|
<form action="<?= base_url('/parent/enroll_classes_handler') ?>" method="post">
|
||||||
<?= csrf_field() ?>
|
<?= csrf_field() ?>
|
||||||
|
<input type="hidden" id="accept_school_policy_input" name="accept_school_policy" value="<?= $hasAcceptedSchoolPolicy ? '1' : '0' ?>">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-bordered align-middle">
|
<table class="table table-striped table-bordered align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -46,6 +125,7 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php foreach ($students as $index => $student): ?>
|
<?php foreach ($students as $index => $student): ?>
|
||||||
|
<?php $decisionMessage = $decisionMessageForStudent($student); ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td><?= $index + 1 ?></td>
|
<td><?= $index + 1 ?></td>
|
||||||
<td><?= esc($student['school_id'] ?? 'N/A') ?></td>
|
<td><?= esc($student['school_id'] ?? 'N/A') ?></td>
|
||||||
@@ -69,6 +149,9 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
|||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
name="enroll[]"
|
name="enroll[]"
|
||||||
value="<?= esc($student['id']) ?>"
|
value="<?= esc($student['id']) ?>"
|
||||||
|
data-decision-message="<?= esc($decisionMessage['message']) ?>"
|
||||||
|
data-decision-blocking="<?= $decisionMessage['blocking'] ? '1' : '0' ?>"
|
||||||
|
data-decision-message-target="enrollment-decision-message-<?= esc($student['id']) ?>"
|
||||||
<?= $disableEnrollUI ? 'disabled' : '' ?>>
|
<?= $disableEnrollUI ? 'disabled' : '' ?>>
|
||||||
<?php elseif (in_array($student['enrollment_status'], ['enrolled', 'admission under review', 'payment pending', 'withdraw under review'])): ?>
|
<?php elseif (in_array($student['enrollment_status'], ['enrolled', 'admission under review', 'payment pending', 'withdraw under review'])): ?>
|
||||||
<input type="checkbox" checked disabled>
|
<input type="checkbox" checked disabled>
|
||||||
@@ -127,6 +210,15 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
|||||||
?>
|
?>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<?php if ($decisionMessage['message'] !== ''): ?>
|
||||||
|
<tr id="enrollment-decision-message-<?= esc($student['id']) ?>" class="enrollment-decision-message-row d-none">
|
||||||
|
<td colspan="10">
|
||||||
|
<div class="alert alert-<?= esc($decisionMessage['level']) ?> mb-0">
|
||||||
|
<?= esc($decisionMessage['message']) ?>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<?php endif; ?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -158,6 +250,32 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
|||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<p>No students found for the selected school year. Please register your kids first.</p>
|
<p>No students found for the selected school year. Please register your kids first.</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<!-- School Policy Modal -->
|
||||||
|
<div class="modal fade" id="schoolPolicyModal" tabindex="-1" aria-labelledby="schoolPolicyModalLabel" aria-hidden="true" data-bs-backdrop="static">
|
||||||
|
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header bg-success text-white">
|
||||||
|
<h5 class="modal-title" id="schoolPolicyModalLabel">School Policies</h5>
|
||||||
|
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<iframe src="<?= base_url('policy/school_policy') ?>" width="100%" height="520" frameborder="0" title="School Policies"></iframe>
|
||||||
|
<div class="form-check mt-3">
|
||||||
|
<input class="form-check-input" type="checkbox" value="1" id="schoolPolicyAcceptedCheckbox" <?= $hasAcceptedSchoolPolicy ? 'checked disabled' : '' ?>>
|
||||||
|
<label class="form-check-label" for="schoolPolicyAcceptedCheckbox">
|
||||||
|
I have read and accept all school policies.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-success" id="acceptSchoolPolicyButton" <?= $hasAcceptedSchoolPolicy ? '' : 'disabled' ?>>
|
||||||
|
Accept Policy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<!-- Enrollment Deadline Modal -->
|
<!-- Enrollment Deadline Modal -->
|
||||||
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-labelledby="deadlineModalLabel" aria-hidden="true">
|
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-labelledby="deadlineModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
@@ -188,13 +306,56 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
|||||||
// Values from PHP
|
// Values from PHP
|
||||||
const deadlinePassed = <?= $deadlinePassed ? 'true' : 'false' ?>;
|
const deadlinePassed = <?= $deadlinePassed ? 'true' : 'false' ?>;
|
||||||
const enrollmentDeadline = new Date("<?= esc($deadlineISO) ?>");
|
const enrollmentDeadline = new Date("<?= esc($deadlineISO) ?>");
|
||||||
|
let hasAcceptedSchoolPolicy = <?= $hasAcceptedSchoolPolicy ? 'true' : 'false' ?>;
|
||||||
|
|
||||||
// Bootstrap Modal
|
// Bootstrap Modal
|
||||||
const modalEl = document.getElementById('deadlineModal');
|
const modalEl = document.getElementById('deadlineModal');
|
||||||
const deadlineModal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
const deadlineModal = modalEl ? new bootstrap.Modal(modalEl) : null;
|
||||||
|
const policyModalEl = document.getElementById('schoolPolicyModal');
|
||||||
|
const policyModal = policyModalEl ? new bootstrap.Modal(policyModalEl) : null;
|
||||||
|
const policyAcceptedInput = document.getElementById('accept_school_policy_input');
|
||||||
|
const policyAcceptedCheckbox = document.getElementById('schoolPolicyAcceptedCheckbox');
|
||||||
|
const acceptPolicyButton = document.getElementById('acceptSchoolPolicyButton');
|
||||||
const showDeadlineModal = () => {
|
const showDeadlineModal = () => {
|
||||||
if (deadlineModal) deadlineModal.show();
|
if (deadlineModal) deadlineModal.show();
|
||||||
};
|
};
|
||||||
|
const showPolicyModal = () => {
|
||||||
|
if (policyModal) policyModal.show();
|
||||||
|
};
|
||||||
|
const setDecisionMessageVisible = (checkbox, visible) => {
|
||||||
|
const targetId = checkbox.dataset.decisionMessageTarget || '';
|
||||||
|
const target = targetId ? document.getElementById(targetId) : null;
|
||||||
|
if (target) {
|
||||||
|
target.classList.toggle('d-none', !visible);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const hideAllDecisionMessages = () => {
|
||||||
|
document.querySelectorAll('.enrollment-decision-message-row').forEach(row => {
|
||||||
|
row.classList.add('d-none');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (policyAcceptedCheckbox && acceptPolicyButton) {
|
||||||
|
policyAcceptedCheckbox.addEventListener('change', function() {
|
||||||
|
acceptPolicyButton.disabled = !this.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (acceptPolicyButton) {
|
||||||
|
acceptPolicyButton.addEventListener('click', function() {
|
||||||
|
if (policyAcceptedCheckbox && !policyAcceptedCheckbox.checked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAcceptedSchoolPolicy = true;
|
||||||
|
if (policyAcceptedInput) {
|
||||||
|
policyAcceptedInput.value = '1';
|
||||||
|
}
|
||||||
|
if (policyModal) {
|
||||||
|
policyModal.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// The enrollment form
|
// The enrollment form
|
||||||
const form = document.querySelector("form[action*='enroll_classes_handler']");
|
const form = document.querySelector("form[action*='enroll_classes_handler']");
|
||||||
@@ -202,11 +363,35 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
|||||||
// 1) Block clicking "enroll" checkboxes after deadline
|
// 1) Block clicking "enroll" checkboxes after deadline
|
||||||
document.querySelectorAll("input[name='enroll[]']").forEach(cb => {
|
document.querySelectorAll("input[name='enroll[]']").forEach(cb => {
|
||||||
cb.addEventListener("click", function(e) {
|
cb.addEventListener("click", function(e) {
|
||||||
|
hideAllDecisionMessages();
|
||||||
|
|
||||||
if (deadlinePassed) {
|
if (deadlinePassed) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
this.checked = false;
|
this.checked = false;
|
||||||
showDeadlineModal();
|
showDeadlineModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAcceptedSchoolPolicy) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
this.checked = false;
|
||||||
|
showPolicyModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.checked && this.dataset.decisionMessage) {
|
||||||
|
setDecisionMessageVisible(this, true);
|
||||||
|
|
||||||
|
if (this.dataset.decisionBlocking === '1') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
this.checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setDecisionMessageVisible(this, false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -223,6 +408,12 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasAcceptedSchoolPolicy && anyBoxesChecked) {
|
||||||
|
e.preventDefault();
|
||||||
|
showPolicyModal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (deadlinePassed && anyBoxesChecked) {
|
if (deadlinePassed && anyBoxesChecked) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
showDeadlineModal();
|
showDeadlineModal();
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
|
|||||||
?>
|
?>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
<?php if ($isEditable && $kid['enrollment'] == 0): ?>
|
<?php if ($isEditable && !empty($kid['can_delete'])): ?>
|
||||||
<form action="<?= base_url('/parent/delete_student/' . $kid['id']) ?>"
|
<form action="<?= base_url('/parent/delete_student/' . $kid['id']) ?>"
|
||||||
method="post"
|
method="post"
|
||||||
style="display:inline">
|
style="display:inline">
|
||||||
@@ -126,7 +126,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="<?= $isEditable ? 'Student is already enrolled' : 'This school year is read-only' ?>">
|
<button type="button" class="btn btn-sm btn-outline-secondary" disabled title="<?= $isEditable ? 'Student has enrollment history and cannot be deleted' : 'This school year is read-only' ?>">
|
||||||
<i class="fas fa-ban"></i> Delete
|
<i class="fas fa-ban"></i> Delete
|
||||||
</button>
|
</button>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
|||||||
@@ -218,30 +218,6 @@
|
|||||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['pass'] ?? 0)) ?></div>
|
<div class="fw-semibold"><?= esc((string) ($promotionSummary['pass'] ?? 0)) ?></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-lg-2 col-md-3 col-6">
|
|
||||||
<div class="border rounded p-2 h-100">
|
|
||||||
<div class="text-muted small">Other</div>
|
|
||||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['other_decision'] ?? 0)) ?></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-3 col-6">
|
|
||||||
<div class="border rounded p-2 h-100">
|
|
||||||
<div class="text-muted small">Queued</div>
|
|
||||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['queued'] ?? 0)) ?></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-3 col-6">
|
|
||||||
<div class="border rounded p-2 h-100">
|
|
||||||
<div class="text-muted small">Assigned</div>
|
|
||||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['assigned'] ?? 0)) ?></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-3 col-6">
|
|
||||||
<div class="border rounded p-2 h-100">
|
|
||||||
<div class="text-muted small">Applied</div>
|
|
||||||
<div class="fw-semibold"><?= esc((string) ($promotionSummary['applied'] ?? 0)) ?></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-3 col-6">
|
<div class="col-lg-2 col-md-3 col-6">
|
||||||
<div class="border rounded p-2 h-100">
|
<div class="border rounded p-2 h-100">
|
||||||
<div class="text-muted small">Pending</div>
|
<div class="text-muted small">Pending</div>
|
||||||
@@ -250,14 +226,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-lg-2 col-md-3 col-6">
|
<div class="col-lg-2 col-md-3 col-6">
|
||||||
<div class="border rounded p-2 h-100">
|
<div class="border rounded p-2 h-100">
|
||||||
<div class="text-muted small">Missing</div>
|
<div class="text-muted small">Other</div>
|
||||||
<div class="fw-semibold text-danger"><?= esc((string) ($promotionSummary['missing_decision'] ?? 0)) ?></div>
|
<div class="fw-semibold"><?= esc((string) ($promotionSummary['other_decision'] ?? 0)) ?></div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-2 col-md-3 col-6">
|
|
||||||
<div class="border rounded p-2 h-100">
|
|
||||||
<div class="text-muted small">Missing Queue</div>
|
|
||||||
<div class="fw-semibold text-danger"><?= esc((string) ($promotionSummary['missing_queue'] ?? 0)) ?></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,15 @@
|
|||||||
$money = static fn ($value): string => '$' . number_format((float) $value, 2);
|
$money = static fn ($value): string => '$' . number_format((float) $value, 2);
|
||||||
$activeId = (int) ($activeYear['id'] ?? 0);
|
$activeId = (int) ($activeYear['id'] ?? 0);
|
||||||
$nextDraftId = (int) ($nextDraftYear['id'] ?? 0);
|
$nextDraftId = (int) ($nextDraftYear['id'] ?? 0);
|
||||||
|
$defaultNewPreviousYear = $activeYear ?: ($schoolYears[0] ?? null);
|
||||||
|
$defaultNewPreviousYearName = (string) ($defaultNewPreviousYear['name'] ?? 'None');
|
||||||
|
$previousYearNameByNewYearName = [];
|
||||||
|
foreach (($schoolYears ?? []) as $existingYear) {
|
||||||
|
$existingName = (string) ($existingYear['name'] ?? '');
|
||||||
|
if (preg_match('/^(\d{4})-(\d{4})$/', $existingName, $matches)) {
|
||||||
|
$previousYearNameByNewYearName[$matches[2] . '-' . ((int) $matches[2] + 1)] = $existingName;
|
||||||
|
}
|
||||||
|
}
|
||||||
$closingPreviewUrl = $activeId > 0
|
$closingPreviewUrl = $activeId > 0
|
||||||
? site_url('administrator/school-years/' . $activeId . '/closing/preview' . ($nextDraftId > 0 ? '?' . http_build_query(['target_school_year_id' => $nextDraftId]) : ''))
|
? site_url('administrator/school-years/' . $activeId . '/closing/preview' . ($nextDraftId > 0 ? '?' . http_build_query(['target_school_year_id' => $nextDraftId]) : ''))
|
||||||
: '';
|
: '';
|
||||||
@@ -85,13 +94,15 @@
|
|||||||
<input class="form-control" id="new_school_year_name" name="name" placeholder="2026-2027" required pattern="\d{4}-\d{4}">
|
<input class="form-control" id="new_school_year_name" name="name" placeholder="2026-2027" required pattern="\d{4}-\d{4}">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label" for="new_previous_school_year_id">Previous Year</label>
|
<label class="form-label" for="new_previous_school_year_display">Previous Year</label>
|
||||||
<select class="form-select" id="new_previous_school_year_id" name="previous_school_year_id">
|
<input
|
||||||
<option value="">None</option>
|
class="form-control"
|
||||||
<?php foreach (($schoolYears ?? []) as $existingYear): ?>
|
id="new_previous_school_year_display"
|
||||||
<option value="<?= (int) ($existingYear['id'] ?? 0) ?>"><?= esc($existingYear['name'] ?? '') ?></option>
|
type="text"
|
||||||
<?php endforeach; ?>
|
value="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
|
||||||
</select>
|
data-default-previous-year="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
|
||||||
|
readonly
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2">
|
<div class="col-md-2">
|
||||||
<label class="form-label" for="new_school_year_starts_on">Starts On</label>
|
<label class="form-label" for="new_school_year_starts_on">Starts On</label>
|
||||||
@@ -109,6 +120,10 @@
|
|||||||
<label class="form-label" for="new_registration_ends_on">Registration Ends</label>
|
<label class="form-label" for="new_registration_ends_on">Registration Ends</label>
|
||||||
<input class="form-control" id="new_registration_ends_on" name="registration_ends_on" type="date">
|
<input class="form-control" id="new_registration_ends_on" name="registration_ends_on" type="date">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label" for="new_fall_makeup_exam_on">Fall Makeup Exam</label>
|
||||||
|
<input class="form-control" id="new_fall_makeup_exam_on" name="fall_makeup_exam_on" type="date">
|
||||||
|
</div>
|
||||||
<div class="col-md-2 d-flex gap-2">
|
<div class="col-md-2 d-flex gap-2">
|
||||||
<button class="btn btn-primary" type="submit">Save Draft</button>
|
<button class="btn btn-primary" type="submit">Save Draft</button>
|
||||||
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Cancel</button>
|
<button class="btn btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#schoolYearCreateForm">Cancel</button>
|
||||||
@@ -149,6 +164,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div><?= $formatDate($year['starts_on'] ?? null) ?></div>
|
<div><?= $formatDate($year['starts_on'] ?? null) ?></div>
|
||||||
<div class="text-muted small"><?= $formatDate($year['ends_on'] ?? null) ?></div>
|
<div class="text-muted small"><?= $formatDate($year['ends_on'] ?? null) ?></div>
|
||||||
|
<div class="text-muted small">Fall makeup: <?= $formatDate($year['fall_makeup_exam_on'] ?? null) ?></div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge <?= esc($statusClasses[$status] ?? 'bg-secondary') ?>">
|
<span class="badge <?= esc($statusClasses[$status] ?? 'bg-secondary') ?>">
|
||||||
@@ -338,6 +354,10 @@
|
|||||||
<label class="form-label" for="registration_ends_on_<?= $id ?>">Registration Ends</label>
|
<label class="form-label" for="registration_ends_on_<?= $id ?>">Registration Ends</label>
|
||||||
<input class="form-control" id="registration_ends_on_<?= $id ?>" name="registration_ends_on" type="date" value="<?= esc($year['registration_ends_on'] ?? '') ?>">
|
<input class="form-control" id="registration_ends_on_<?= $id ?>" name="registration_ends_on" type="date" value="<?= esc($year['registration_ends_on'] ?? '') ?>">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label" for="fall_makeup_exam_on_<?= $id ?>">Fall Makeup Exam</label>
|
||||||
|
<input class="form-control" id="fall_makeup_exam_on_<?= $id ?>" name="fall_makeup_exam_on" type="date" value="<?= esc($year['fall_makeup_exam_on'] ?? '') ?>">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<label class="form-label" for="description_<?= $id ?>">Description</label>
|
<label class="form-label" for="description_<?= $id ?>">Description</label>
|
||||||
<textarea class="form-control" id="description_<?= $id ?>" name="description" rows="3"><?= esc($year['description'] ?? '') ?></textarea>
|
<textarea class="form-control" id="description_<?= $id ?>" name="description" rows="3"><?= esc($year['description'] ?? '') ?></textarea>
|
||||||
@@ -415,6 +435,21 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
order: [[0, 'desc']]
|
order: [[0, 'desc']]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newYearInput = document.getElementById('new_school_year_name');
|
||||||
|
const previousYearDisplay = document.getElementById('new_previous_school_year_display');
|
||||||
|
const previousYearByNewYear = <?= json_encode($previousYearNameByNewYearName, JSON_UNESCAPED_SLASHES) ?>;
|
||||||
|
|
||||||
|
if (newYearInput && previousYearDisplay) {
|
||||||
|
const defaultPreviousYear = previousYearDisplay.dataset.defaultPreviousYear || 'None';
|
||||||
|
const updatePreviousYear = function () {
|
||||||
|
const newYearName = newYearInput.value.trim();
|
||||||
|
previousYearDisplay.value = previousYearByNewYear[newYearName] || defaultPreviousYear;
|
||||||
|
};
|
||||||
|
|
||||||
|
newYearInput.addEventListener('input', updatePreviousYear);
|
||||||
|
updatePreviousYear();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<?= $this->endSection() ?>
|
<?= $this->endSection() ?>
|
||||||
|
|||||||
@@ -257,6 +257,9 @@
|
|||||||
(Read school policies here)
|
(Read school policies here)
|
||||||
</button>
|
</button>
|
||||||
</label>
|
</label>
|
||||||
|
<?php if (isset($errors['accept_school_policy'])): ?>
|
||||||
|
<div class="text-danger small"><?= esc($errors['accept_school_policy']) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="text-center border p-2 bg-light item">
|
<div class="text-center border p-2 bg-light item">
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ CI_ENVIRONMENT=production
|
|||||||
# June 1 @ 00:10 America/New_York
|
# June 1 @ 00:10 America/New_York
|
||||||
10 0 1 6 * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark config:update -t update_date_age_reference --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1
|
10 0 1 6 * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark config:update -t update_date_age_reference --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1
|
||||||
|
|
||||||
|
# Daily @ 8:00 AM - send registration opening email only when today matches school_years.registration_starts_on
|
||||||
|
0 8 * * * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark registration:send-opening-email --tz=America/New_York >> /var/log/ci4_registration_opening_email.log 2>&1
|
||||||
|
|
||||||
# America/New_York
|
# America/New_York
|
||||||
# 1st February @ 00:05 — Spring
|
# 1st February @ 00:05 — Spring
|
||||||
5 0 1 2 * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark config:update set_semester_spring --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1
|
5 0 1 2 * cd /opt/lampp/htdocs/alrahma_school_sunday && /usr/bin/php spark config:update set_semester_spring --tz=America/New_York >> /var/log/ci4_config_update.log 2>&1
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\App\Config;
|
||||||
|
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
|
||||||
|
final class SchoolYearRouteIntegrityTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
public function testSchoolYearManagementRoutesAllowPrincipalRole(): void
|
||||||
|
{
|
||||||
|
$routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php') ?: '';
|
||||||
|
|
||||||
|
$this->assertMatchesRegularExpression(
|
||||||
|
"/\\\$routes->group\\('administrator\\/school-years', \\['filter' => 'auth:[^']*\\bprincipal\\b[^']*'\\]/",
|
||||||
|
$routes
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\App\Filters;
|
||||||
|
|
||||||
|
use App\Filters\SchoolYearWritableFilter;
|
||||||
|
use App\Models\SchoolYearModel;
|
||||||
|
use App\Services\SchoolYearContextService;
|
||||||
|
use CodeIgniter\HTTP\IncomingRequest;
|
||||||
|
use CodeIgniter\HTTP\Response;
|
||||||
|
use CodeIgniter\HTTP\URI;
|
||||||
|
use CodeIgniter\HTTP\UserAgent;
|
||||||
|
use CodeIgniter\Test\CIUnitTestCase;
|
||||||
|
use Config\App;
|
||||||
|
use Config\Services;
|
||||||
|
|
||||||
|
final class SchoolYearWritableFilterFakeModel extends SchoolYearModel
|
||||||
|
{
|
||||||
|
public function __construct(private readonly array $row)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function active(): ?array
|
||||||
|
{
|
||||||
|
return $this->row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SchoolYearWritableFilterTest extends CIUnitTestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Services::resetSingle('session');
|
||||||
|
Services::resetSingle('schoolYearContext');
|
||||||
|
session()->set('user_id', 123);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostAgainstClosedSelectedYearIsBlocked(): void
|
||||||
|
{
|
||||||
|
$this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']);
|
||||||
|
|
||||||
|
$request = $this->request('POST', 'https://example.test/administrator/grades/save');
|
||||||
|
$request->setHeader('Accept', 'application/json');
|
||||||
|
|
||||||
|
$result = (new SchoolYearWritableFilter())->before($request);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Response::class, $result);
|
||||||
|
$this->assertSame(409, $result->getStatusCode());
|
||||||
|
$this->assertStringContainsString('Read-only school year', $result->getBody());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPostAgainstActiveSelectedYearIsAllowed(): void
|
||||||
|
{
|
||||||
|
$this->useSchoolYearContext(['id' => 2, 'name' => '2026-2027', 'status' => 'active']);
|
||||||
|
|
||||||
|
$request = $this->request('POST', 'https://example.test/administrator/grades/save');
|
||||||
|
|
||||||
|
$this->assertNull((new SchoolYearWritableFilter())->before($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSchoolYearSelectionPostIsExempt(): void
|
||||||
|
{
|
||||||
|
$this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']);
|
||||||
|
|
||||||
|
$request = $this->request('POST', 'https://example.test/school-year/select');
|
||||||
|
|
||||||
|
$this->assertNull((new SchoolYearWritableFilter())->before($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadRequestsAreAllowedForClosedSelectedYear(): void
|
||||||
|
{
|
||||||
|
$this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']);
|
||||||
|
|
||||||
|
$request = $this->request('GET', 'https://example.test/administrator/grades');
|
||||||
|
|
||||||
|
$this->assertNull((new SchoolYearWritableFilter())->before($request));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLegacyGetDeleteRouteAgainstClosedSelectedYearIsBlocked(): void
|
||||||
|
{
|
||||||
|
$this->useSchoolYearContext(['id' => 1, 'name' => '2025-2026', 'status' => 'closed']);
|
||||||
|
|
||||||
|
$request = $this->request('GET', 'https://example.test/administrator/teacher/delete/44');
|
||||||
|
$request->setHeader('Accept', 'application/json');
|
||||||
|
|
||||||
|
$result = (new SchoolYearWritableFilter())->before($request);
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Response::class, $result);
|
||||||
|
$this->assertSame(409, $result->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function useSchoolYearContext(array $row): void
|
||||||
|
{
|
||||||
|
Services::injectMock(
|
||||||
|
'schoolYearContext',
|
||||||
|
new SchoolYearContextService(new SchoolYearWritableFilterFakeModel($row))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function request(string $method, string $uri): IncomingRequest
|
||||||
|
{
|
||||||
|
$request = new IncomingRequest(config(App::class), new URI($uri), 'php://input', new UserAgent());
|
||||||
|
$request->setMethod($method);
|
||||||
|
|
||||||
|
return $request;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,8 @@ final class SchoolYearContextRequest extends MockIncomingRequest
|
|||||||
|
|
||||||
final class SchoolYearContextFakeModel extends SchoolYearModel
|
final class SchoolYearContextFakeModel extends SchoolYearModel
|
||||||
{
|
{
|
||||||
|
private array $filters = [];
|
||||||
|
|
||||||
public function __construct(private readonly array $rows)
|
public function __construct(private readonly array $rows)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -48,6 +50,35 @@ final class SchoolYearContextFakeModel extends SchoolYearModel
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function where($key, $value = null, ?bool $escape = null)
|
||||||
|
{
|
||||||
|
$this->filters[] = [$key, $value];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function orderBy($orderBy, string $direction = '', ?bool $escape = null)
|
||||||
|
{
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAll(?int $limit = null, int $offset = 0)
|
||||||
|
{
|
||||||
|
$rows = array_values(array_filter($this->rows, function (array $row): bool {
|
||||||
|
foreach ($this->filters as [$key, $value]) {
|
||||||
|
if (($row[$key] ?? null) !== $value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}));
|
||||||
|
|
||||||
|
$this->filters = [];
|
||||||
|
|
||||||
|
return $limit !== null ? array_slice($rows, $offset, $limit) : array_slice($rows, $offset);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final class SchoolYearContextServiceTest extends CIUnitTestCase
|
final class SchoolYearContextServiceTest extends CIUnitTestCase
|
||||||
@@ -116,4 +147,20 @@ final class SchoolYearContextServiceTest extends CIUnitTestCase
|
|||||||
$this->assertNull(session()->get('semester'));
|
$this->assertNull(session()->get('semester'));
|
||||||
$this->assertNull(session()->get('active_school_year'));
|
$this->assertNull(session()->get('active_school_year'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSingleClosingYearIsUsedAsReadonlyContextWhenNoActiveYearExists(): void
|
||||||
|
{
|
||||||
|
$service = new SchoolYearContextService(new SchoolYearContextFakeModel([
|
||||||
|
1 => ['id' => 1, 'name' => '2025-2026', 'status' => 'closing'],
|
||||||
|
2 => ['id' => 2, 'name' => '2026-2027', 'status' => 'draft'],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$context = $service->resolve(new SchoolYearContextRequest());
|
||||||
|
|
||||||
|
$this->assertSame(1, $context->id());
|
||||||
|
$this->assertSame('2025-2026', $context->yearName());
|
||||||
|
$this->assertSame('closing', $context->status());
|
||||||
|
$this->assertTrue($context->isReadonly());
|
||||||
|
$this->assertFalse($context->isExplicitSelection());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user