fix school year and related issues
Tests / PHPUnit (push) Failing after 34s

This commit is contained in:
root
2026-07-30 00:48:54 -04:00
parent f8f00c70c8
commit 81a72a4c59
27 changed files with 1512 additions and 52 deletions
@@ -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');
}
}
+1
View File
@@ -20,6 +20,7 @@ class Commands extends BaseService
\App\Commands\SendAbsenteesSummary::class,
\App\Commands\SendLatesSummary::class,
\App\Commands\SendMonthlyPaymentNotifications::class,
\App\Commands\SendRegistrationOpeningEmail::class,
\App\Commands\SendTestPaymentNotification::class,
\App\Commands\RecalculateAttendance::class,
];
+1
View File
@@ -48,6 +48,7 @@ class Filters extends BaseConfig
'timezone',
'sanitizeinput',
'invalidchars',
'schoolYearWritable',
'csrf' => ['except' => [
// WhatsApp membership management (legacy allowances retained)
'whatsapp/update-membership',
+1 -1
View File
@@ -1125,7 +1125,7 @@ $routes->match(['get', 'post'], '/configuration/editConfig/(:num)', 'View\Config
$routes->post('/configuration/deleteConfig/(:num)', 'View\ConfigurationController::deleteConfig/$1');
// 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->post('store', 'Administrator\SchoolYearController::store');
$routes->post('(:num)/update', 'Administrator\SchoolYearController::update/$1');
+3 -1
View File
@@ -181,7 +181,9 @@ abstract class BaseController extends Controller
try {
service('renderer')->setData(service('schoolYearViewData')->forCurrentRequest($this->request));
} catch (SchoolYearConfigurationException $e) {
throw $e;
log_message('warning', 'Unable to inject school-year view data: {message}', [
'message' => $e->getMessage(),
]);
} catch (Throwable $e) {
log_message('warning', 'Unable to inject school-year view data: {message}', [
'message' => $e->getMessage(),
+206 -5
View File
@@ -9,6 +9,7 @@ use App\Models\ClassSectionModel;
use App\Models\StudentClassModel;
use App\Models\StudentSectionDistributionDraftModel;
use App\Models\AuthorizedUserModel;
use App\Models\ParentPolicyAcceptanceModel;
use App\Models\EmergencyContactModel;
use App\Models\ConfigurationModel;
use App\Models\StudentMedicalConditionModel;
@@ -54,6 +55,7 @@ class ParentController extends BaseController
protected $medicalConditionModel;
protected $allergyModel;
protected $authorizedUsersModel;
protected ParentPolicyAcceptanceModel $policyAcceptanceModel;
protected $schoolStartDate;
protected $ageDateRefernce;
@@ -75,6 +77,7 @@ class ParentController extends BaseController
$this->medicalConditionModel = new StudentMedicalConditionModel();
$this->allergyModel = new StudentAllergyModel();
$this->authorizedUsersModel = new AuthorizedUserModel();
$this->policyAcceptanceModel = new ParentPolicyAcceptanceModel();
$this->ageDateRefernce = $this->configModel->getConfig('date_age_reference');
$this->lastDayOfRegistration = $this->configModel->getConfig('enrollment_deadline');
@@ -270,7 +273,7 @@ class ParentController extends BaseController
// Verify user type is "parent" from the `users` table
$userData = $this->db->table('users')
->select('user_type')
->select('user_type, accept_school_policy')
->where('id', $parentId)
->get()
->getRowArray();
@@ -290,6 +293,9 @@ class ParentController extends BaseController
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
$statusMap = [
'admission under review' => 'admission under review',
@@ -356,16 +362,23 @@ class ParentController extends BaseController
$student['enrollment_status'],
['admission under review', 'payment pending', 'enrolled', 'withdraw under review', 'denied']
);
$student['previous_year_decision'] = $previousSchoolYear !== null
? $this->studentDecisionForYear((int) $studentId, $previousSchoolYear)
: null;
}
// Render view
return view('/parent/enroll_classes', [
'students' => $students,
'selectedYear' => $selectedYear,
'previousSchoolYear' => $previousSchoolYear,
'fallMakeupExamOn' => $fallMakeupExamOn,
'isEditable' => $isEditable,
'withdrawalDeadline' => $this->withdrawalDeadline,
'lastDayOfRegistration' => $this->lastDayOfRegistration,
'schoolStartDate' => $this->schoolStartDate
'schoolStartDate' => $this->schoolStartDate,
'hasAcceptedSchoolPolicy' => $this->hasAcceptedPolicyForYear((int) $parentId, $selectedYear),
]);
} catch (Exception $e) {
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
$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 (!empty($enroll)) {
return redirect()->back()->with('error', 'Enrollment deadline has passed. New enrollments are not allowed.');
@@ -404,6 +436,12 @@ class ParentController extends BaseController
$studentData = [];
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) {
// Get student full name (supports both string return or array with firstname/lastname)
$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,
* create/update student_class row accordingly (base section until distribution),
@@ -705,6 +777,84 @@ class ParentController extends BaseController
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
{
$schoolYear = trim($schoolYear);
@@ -1292,15 +1442,17 @@ class ParentController extends BaseController
$kids = $this->studentModel->where('parent_id', $parentId)->findAll();
foreach ($kids as &$kid) {
$studentId = (int) ($kid['id'] ?? 0);
$kid['allergies'] = $this->allergyModel
->where('student_id', $kid['id'])
->where('student_id', $studentId)
->findColumn('allergy') ?? [];
$kid['medical_conditions'] = $this->medicalConditionModel
->where('student_id', $kid['id'])
->where('student_id', $studentId)
->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();
@@ -1784,6 +1936,10 @@ $existing = $this->studentModel
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
if (!$this->studentModel->delete($id)) {
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.');
}
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
+19 -1
View File
@@ -8,6 +8,7 @@ use App\Models\UserModel;
use App\Models\AuthorizedUserModel;
use App\Models\RoleModel;
use App\Models\ParentModel;
use App\Models\ParentPolicyAcceptanceModel;
use App\Models\UserRoleModel;
use App\Services\EmailService;
use App\Services\SchoolIdService;
@@ -23,6 +24,7 @@ class RegisterController extends Controller
protected $roleModel;
protected $userModel;
protected $parentModel;
protected ParentPolicyAcceptanceModel $policyAcceptanceModel;
public function __construct()
{
@@ -40,6 +42,7 @@ class RegisterController extends Controller
$this->roleModel = new RoleModel();
$this->userModel = new UserModel();
$this->parentModel = new ParentModel();
$this->policyAcceptanceModel = new ParentPolicyAcceptanceModel();
$this->semester = $this->configModel->getConfig('semester');
$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]',
'state' => 'required|in_list[CT,ME,MA,NH,NY,RI,VT]',
'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]',
];
@@ -221,6 +224,21 @@ class RegisterController extends Controller
'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 ───────────── */
if ($isParent) {
if (empty($rawPost['no_second_parent_info'])) {
@@ -47,6 +47,10 @@ class CreateSchoolYears extends Migration
'type' => 'DATE',
'null' => true,
],
'fall_makeup_exam_on' => [
'type' => 'DATE',
'null' => true,
],
'previous_school_year_id' => [
'type' => 'INT',
'constraint' => 11,
@@ -144,6 +148,7 @@ class CreateSchoolYears extends Migration
'description' => ['type' => 'TEXT', 'null' => true],
'registration_starts_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],
'next_school_year_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, '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;
}
}
+76
View File
@@ -10,12 +10,50 @@ use CodeIgniter\HTTP\ResponseInterface;
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)
{
if (! $request instanceof IncomingRequest) {
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 {
service('schoolYearWriteGuard')->assertWritable(
service('schoolYearContext')->resolve($request)
@@ -45,4 +83,42 @@ final class SchoolYearWritableFilter implements FilterInterface
{
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;
}
}
+2
View File
@@ -19,6 +19,7 @@ class SchoolYearModel extends Model
'description',
'registration_starts_on',
'registration_ends_on',
'fall_makeup_exam_on',
'previous_school_year_id',
'next_school_year_id',
'activated_at',
@@ -36,6 +37,7 @@ class SchoolYearModel extends Model
'ends_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]',
'fall_makeup_exam_on' => 'permit_empty|valid_date[Y-m-d]',
];
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), '"');
}
}
+12 -2
View File
@@ -129,11 +129,21 @@ final class SchoolYearContextService
{
$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.');
}
return $this->fromRow($active, false);
return $this->fromRow($closing[0], false);
}
private function normalizeInt(mixed $value): ?int
+63 -1
View File
@@ -26,6 +26,7 @@ final class SchoolYearManagementService
public function createDraft(array $payload, ?int $userId = null): int
{
$payload = $this->metadataPayload($payload);
$payload['previous_school_year_id'] = $this->previousYearIdForDraft((string) $payload['name']);
$payload['status'] = SchoolYearStatus::DRAFT;
$payload['created_by'] = $userId;
$payload['updated_by'] = $userId;
@@ -35,6 +36,7 @@ final class SchoolYearManagementService
$this->db->transStart();
$id = $this->schoolYearModel->insert($payload, true);
if ($id !== false) {
$this->syncConfigurationFromSchoolYear($payload);
$this->log((int) $id, null, SchoolYearStatus::DRAFT, 'create', $userId);
}
$this->db->transComplete();
@@ -62,6 +64,7 @@ final class SchoolYearManagementService
$this->db->transStart();
$updated = $this->schoolYearModel->update($id, $payload);
if ($updated !== false) {
$this->syncConfigurationFromSchoolYear($payload);
$this->log($id, $status, $status, 'metadata_update', $userId);
}
$this->db->transComplete();
@@ -104,7 +107,7 @@ final class SchoolYearManagementService
'activated_at' => $now,
'updated_by' => $userId,
]);
$this->configurationModel->setConfigValueByKey('school_year', (string) $year['name']);
$this->syncConfigurationFromSchoolYear($year);
$this->syncActiveYearSession((string) $year['name']);
$this->log($id, $from, SchoolYearStatus::ACTIVE, 'activate', $userId);
@@ -255,10 +258,69 @@ final class SchoolYearManagementService
'description' => trim((string) ($payload['description'] ?? '')) ?: null,
'registration_starts_on' => $this->nullableDate($payload['registration_starts_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),
];
}
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
{
$value = trim((string) $value);
@@ -40,6 +40,8 @@ final class SchoolYearValidationService
if ($registrationStarts !== null && $registrationEnds !== null && $registrationStarts > $registrationEnds) {
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
+191
View File
@@ -25,9 +25,88 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
</ul>
</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)): ?>
<form action="<?= base_url('/parent/enroll_classes_handler') ?>" method="post">
<?= csrf_field() ?>
<input type="hidden" id="accept_school_policy_input" name="accept_school_policy" value="<?= $hasAcceptedSchoolPolicy ? '1' : '0' ?>">
<div class="table-responsive">
<table class="table table-striped table-bordered align-middle">
<thead>
@@ -46,6 +125,7 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
</thead>
<tbody>
<?php foreach ($students as $index => $student): ?>
<?php $decisionMessage = $decisionMessageForStudent($student); ?>
<tr>
<td><?= $index + 1 ?></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"
name="enroll[]"
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' : '' ?>>
<?php elseif (in_array($student['enrollment_status'], ['enrolled', 'admission under review', 'payment pending', 'withdraw under review'])): ?>
<input type="checkbox" checked disabled>
@@ -127,6 +210,15 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
?>
</td>
</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; ?>
</tbody>
</table>
@@ -158,6 +250,32 @@ $deadlineISO = $deadlineObj->format('Y-m-d\TH:i:sP'); // for JS
<?php else: ?>
<p>No students found for the selected school year. Please register your kids first.</p>
<?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 -->
<div class="modal fade" id="deadlineModal" tabindex="-1" aria-labelledby="deadlineModalLabel" aria-hidden="true">
<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
const deadlinePassed = <?= $deadlinePassed ? 'true' : 'false' ?>;
const enrollmentDeadline = new Date("<?= esc($deadlineISO) ?>");
let hasAcceptedSchoolPolicy = <?= $hasAcceptedSchoolPolicy ? 'true' : 'false' ?>;
// Bootstrap Modal
const modalEl = document.getElementById('deadlineModal');
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 = () => {
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
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
document.querySelectorAll("input[name='enroll[]']").forEach(cb => {
cb.addEventListener("click", function(e) {
hideAllDecisionMessages();
if (deadlinePassed) {
e.preventDefault();
e.stopImmediatePropagation();
this.checked = false;
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;
}
if (!hasAcceptedSchoolPolicy && anyBoxesChecked) {
e.preventDefault();
showPolicyModal();
return;
}
if (deadlinePassed && anyBoxesChecked) {
e.preventDefault();
showDeadlineModal();
+2 -2
View File
@@ -110,7 +110,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
?>
</td>
<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']) ?>"
method="post"
style="display:inline">
@@ -126,7 +126,7 @@ $disableEmergencyBtn = count($emergencies) >= $maxEmergency;
</button>
</form>
<?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
</button>
<?php endif; ?>
+2 -32
View File
@@ -218,30 +218,6 @@
<div class="fw-semibold"><?= esc((string) ($promotionSummary['pass'] ?? 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">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="border rounded p-2 h-100">
<div class="text-muted small">Pending</div>
@@ -250,14 +226,8 @@
</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</div>
<div class="fw-semibold text-danger"><?= esc((string) ($promotionSummary['missing_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 class="text-muted small">Other</div>
<div class="fw-semibold"><?= esc((string) ($promotionSummary['other_decision'] ?? 0)) ?></div>
</div>
</div>
</div>
+42 -7
View File
@@ -13,6 +13,15 @@
$money = static fn ($value): string => '$' . number_format((float) $value, 2);
$activeId = (int) ($activeYear['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
? 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}">
</div>
<div class="col-md-2">
<label class="form-label" for="new_previous_school_year_id">Previous Year</label>
<select class="form-select" id="new_previous_school_year_id" name="previous_school_year_id">
<option value="">None</option>
<?php foreach (($schoolYears ?? []) as $existingYear): ?>
<option value="<?= (int) ($existingYear['id'] ?? 0) ?>"><?= esc($existingYear['name'] ?? '') ?></option>
<?php endforeach; ?>
</select>
<label class="form-label" for="new_previous_school_year_display">Previous Year</label>
<input
class="form-control"
id="new_previous_school_year_display"
type="text"
value="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
data-default-previous-year="<?= esc($defaultNewPreviousYearName, 'attr') ?>"
readonly
>
</div>
<div class="col-md-2">
<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>
<input class="form-control" id="new_registration_ends_on" name="registration_ends_on" type="date">
</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">
<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>
@@ -149,6 +164,7 @@
<td>
<div><?= $formatDate($year['starts_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>
<span class="badge <?= esc($statusClasses[$status] ?? 'bg-secondary') ?>">
@@ -338,6 +354,10 @@
<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'] ?? '') ?>">
</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>
<label class="form-label" for="description_<?= $id ?>">Description</label>
<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']]
});
}
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>
<?= $this->endSection() ?>
+3
View File
@@ -257,6 +257,9 @@
(Read school policies here)
</button>
</label>
<?php if (isset($errors['accept_school_policy'])): ?>
<div class="text-danger small"><?= esc($errors['accept_school_policy']) ?></div>
<?php endif; ?>
</div>
<div class="mb-3">
<div class="text-center border p-2 bg-light item">
+3
View File
@@ -13,6 +13,9 @@ CI_ENVIRONMENT=production
# 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
# 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
# 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
@@ -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
{
private array $filters = [];
public function __construct(private readonly array $rows)
{
}
@@ -48,6 +50,35 @@ final class SchoolYearContextFakeModel extends SchoolYearModel
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
@@ -116,4 +147,20 @@ final class SchoolYearContextServiceTest extends CIUnitTestCase
$this->assertNull(session()->get('semester'));
$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());
}
}