ADD SCHOOL YEAR MANAGEMENT
Tests / PHPUnit (push) Failing after 40s

This commit is contained in:
root
2026-07-12 02:21:39 -04:00
parent c7f67da9bf
commit e06ccc9cc0
36 changed files with 6722 additions and 327 deletions
@@ -0,0 +1,31 @@
<?php
namespace App\Models\Concerns;
use App\Support\SchoolYear\SchoolYearContext;
use CodeIgniter\Model;
trait SchoolYearScopedModelTrait
{
public function forSchoolYear(SchoolYearContext|string|int $schoolYear): Model
{
if ($schoolYear instanceof SchoolYearContext) {
if ($this->fieldExists('school_year_id')) {
return $this->where($this->table . '.school_year_id', $schoolYear->id());
}
return $this->where($this->table . '.school_year', $schoolYear->yearName());
}
if (is_int($schoolYear)) {
return $this->where($this->table . '.school_year_id', $schoolYear);
}
return $this->where($this->table . '.school_year', $schoolYear);
}
private function fieldExists(string $field): bool
{
return in_array($field, $this->db->getFieldNames($this->table), true);
}
}
@@ -0,0 +1,29 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class SchoolYearClosingBatchModel extends Model
{
protected $table = 'school_year_closing_batches';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'source_school_year_id',
'target_school_year_id',
'status',
'preview_hash',
'total_families',
'total_positive_balance',
'total_credit_balance',
'started_by',
'completed_by',
'started_at',
'completed_at',
'failed_at',
'failure_message',
];
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class SchoolYearClosingItemModel extends Model
{
protected $table = 'school_year_closing_items';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'closing_batch_id',
'family_id',
'source_balance',
'credit_amount',
'adjustment_amount',
'carry_forward_amount',
'target_invoice_id',
'target_adjustment_id',
'status',
'error_message',
];
}
+47
View File
@@ -0,0 +1,47 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class SchoolYearModel extends Model
{
protected $table = 'school_years';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = true;
protected $allowedFields = [
'name',
'status',
'starts_on',
'ends_on',
'description',
'registration_starts_on',
'registration_ends_on',
'previous_school_year_id',
'next_school_year_id',
'activated_at',
'closing_started_at',
'closed_at',
'archived_at',
'created_by',
'updated_by',
];
protected $validationRules = [
'name' => 'required|regex_match[/^\d{4}-\d{4}$/]|max_length[9]',
'status' => 'required|in_list[draft,active,closing,closed,archived]',
'starts_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_ends_on' => 'permit_empty|valid_date[Y-m-d]',
];
public function active(): ?array
{
return $this->where('status', 'active')
->orderBy('id', 'DESC')
->first();
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class SchoolYearTransitionLogModel extends Model
{
protected $table = 'school_year_transition_logs';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useTimestamps = false;
protected $allowedFields = [
'school_year_id',
'from_status',
'to_status',
'action',
'performed_by',
'metadata_json',
'created_at',
];
}
+434 -133
View File
@@ -2,12 +2,17 @@
namespace App\Models;
use CodeIgniter\Database\BaseBuilder;
use CodeIgniter\Model;
class StudentClassModel extends Model
{
protected $table = 'student_class';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $useAutoIncrement = true;
protected $protectFields = true;
protected $allowedFields = [
'student_id',
@@ -16,214 +21,510 @@ class StudentClassModel extends Model
'is_event_only',
'description',
'updated_by',
'created_at',
'updated_at',
'created_at'
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $validationRules = [
'student_id' => 'required|integer',
'class_section_id'=> 'permit_empty|integer',
'school_year' => 'required|max_length[20]',
'is_event_only' => 'permit_empty|in_list[0,1]',
'updated_by' => 'permit_empty|integer',
];
protected $validationMessages = [];
protected $skipValidation = false;
protected $cleanValidationRules = true;
/**
* Scope: only students active for classes/attendance.
* Create a fresh builder for student_class.
*
* Using a fresh builder prevents WHERE conditions from a previous model
* query from leaking into another query.
*/
public function active(): self
private function freshBuilder(): BaseBuilder
{
return $this->select('student_class.*')
->join('students', 'students.id = student_class.student_id', 'inner')
return $this->db->table($this->table);
}
/**
* Create a fresh builder scoped to active students.
*/
private function activeStudentsBuilder(): BaseBuilder
{
return $this->freshBuilder()
->select('student_class.*')
->join(
'students',
'students.id = student_class.student_id',
'inner'
)
->where('students.is_active', 1);
}
public function getClassSectionNameByStudentId(int $studentId): ?string
/**
* Apply the active-student scope to the model.
*
* Prefer the dedicated retrieval methods below for new code because they
* use fresh builders and cannot inherit stale model query state.
*/
public function active(): self
{
return $this->db->table($this->table)
->select('cs.class_section_name')
->join('classSection cs', 'cs.class_section_id = student_class.class_section_id')
->where('student_class.student_id', $studentId)
->get()
->getRow('class_section_name');
}
// Custom findAll() method
public function findAll($limit = 0, $offset = 0)
{
// Optional: Add custom logic before fetching all records
// Then call the parent findAll method to fetch the records
return parent::findAll($limit, $offset);
}
// Method to get all students in a specific class section (optionally scoped to term)
public function getClassStudents($classSectionId, ?string $schoolYear = null)
{
$qb = $this->active()->where('student_class.class_section_id', $classSectionId);
if ($schoolYear !== null && $schoolYear !== '') {
$qb->where('student_class.school_year', $schoolYear);
}
return $qb->findAll();
return $this
->select('student_class.*')
->join(
'students',
'students.id = student_class.student_id',
'inner'
)
->where('students.is_active', 1);
}
/**
* Return class section names for a student in a given school year.
* Return the most recent class section name assigned to a student.
*/
public function getClassSectionNameByStudentId(
int $studentId,
?string $schoolYear = null
): ?string {
$builder = $this->freshBuilder()
->select('cs.class_section_name')
->join(
'classSection cs',
'cs.class_section_id = student_class.class_section_id',
'inner'
)
->where('student_class.student_id', $studentId)
->where(
'student_class.class_section_id IS NOT NULL',
null,
false
);
if ($schoolYear !== null && trim($schoolYear) !== '') {
$builder->where(
'student_class.school_year',
trim($schoolYear)
);
}
$row = $builder
->orderBy('student_class.created_at', 'DESC')
->get()
->getRowArray();
if (!$row) {
return null;
}
$name = trim((string) ($row['class_section_name'] ?? ''));
return $name !== '' ? $name : null;
}
/**
* Get active students assigned to a class section.
*
* student_class is scoped by school year only. It has no semester column.
*/
public function getClassStudents(
int $classSectionId,
?string $schoolYear = null
): array {
$builder = $this->activeStudentsBuilder()
->where(
'student_class.class_section_id',
$classSectionId
)
->where(
'student_class.class_section_id IS NOT NULL',
null,
false
);
if ($schoolYear !== null && trim($schoolYear) !== '') {
$builder->where(
'student_class.school_year',
trim($schoolYear)
);
}
return $builder
->orderBy('students.lastname', 'ASC')
->orderBy('students.firstname', 'ASC')
->get()
->getResultArray();
}
/**
* Return student IDs assigned to a class section for a school year.
*/
public function getStudentIdsByClassSection(
int $classSectionId,
string $schoolYear
): array {
$schoolYear = trim($schoolYear);
$builder = $this->freshBuilder()
->select('student_class.student_id')
->join(
'students',
'students.id = student_class.student_id',
'inner'
)
->where(
'student_class.class_section_id',
$classSectionId
)
->where('students.is_active', 1)
->where(
'student_class.class_section_id IS NOT NULL',
null,
false
);
if ($schoolYear !== '') {
$builder->where(
'student_class.school_year',
$schoolYear
);
}
$rows = $builder
->groupBy('student_class.student_id')
->get()
->getResultArray();
$studentIds = array_map(
static fn(array $row): int =>
(int) ($row['student_id'] ?? 0),
$rows
);
return array_values(array_unique(array_filter(
$studentIds,
static fn(int $studentId): bool => $studentId > 0
)));
}
/**
* Return class section names assigned to a student for a school year.
*
* @param int|string $studentId
* @param string $schoolYear
* @param bool $asArray When true, return an array of names; otherwise a comma-separated string.
* @return array|string
*/
public function getClassSectionsByStudentId($studentId, string $schoolYear, bool $asArray = false)
{
$rows = $this->db->table('student_class')
public function getClassSectionsByStudentId(
int|string $studentId,
string $schoolYear,
bool $asArray = false
): array|string {
$rows = $this->freshBuilder()
->select('cs.class_section_name')
->join('classSection cs', 'student_class.class_section_id = cs.class_section_id')
->join(
'classSection cs',
'student_class.class_section_id = cs.class_section_id',
'inner'
)
->where('student_class.student_id', $studentId)
->where('student_class.class_section_id IS NOT NULL', null, false)
->where('student_class.school_year', $schoolYear)
->where(
'student_class.class_section_id IS NOT NULL',
null,
false
)
->where(
'student_class.school_year',
trim($schoolYear)
)
->orderBy('cs.class_section_name', 'ASC')
->get()
->getResultArray();
$names = array_values(array_unique(array_filter(array_map(static function ($row) {
return $row['class_section_name'] ?? null;
}, $rows))));
$names = array_map(
static fn(array $row): string =>
trim((string) ($row['class_section_name'] ?? '')),
$rows
);
if ($asArray) {
return $names;
}
$names = array_values(array_unique(array_filter(
$names,
static fn(string $name): bool => $name !== ''
)));
return !empty($names) ? implode(', ', $names) : '';
return $asArray ? $names : implode(', ', $names);
}
/**
* Return class section names for a student in a given school year, with optional event flag.
* Return class section names and indicate event-only assignments.
*
* @param int|string $studentId
* @param string $schoolYear
* @param bool $asArray When true, return an array of names; otherwise a comma-separated string.
* @return array|string
*/
public function getClassSectionsByStudentIdWithFlags($studentId, string $schoolYear, bool $asArray = false)
{
$rows = $this->db->table('student_class')
->select('cs.class_section_name, student_class.is_event_only')
->join('classSection cs', 'student_class.class_section_id = cs.class_section_id')
public function getClassSectionsByStudentIdWithFlags(
int|string $studentId,
string $schoolYear,
bool $asArray = false
): array|string {
$rows = $this->freshBuilder()
->select(
'cs.class_section_name, student_class.is_event_only'
)
->join(
'classSection cs',
'student_class.class_section_id = cs.class_section_id',
'inner'
)
->where('student_class.student_id', $studentId)
->where('student_class.class_section_id IS NOT NULL', null, false)
->where('student_class.school_year', $schoolYear)
->where(
'student_class.class_section_id IS NOT NULL',
null,
false
)
->where(
'student_class.school_year',
trim($schoolYear)
)
->orderBy('cs.class_section_name', 'ASC')
->get()
->getResultArray();
$names = array_values(array_unique(array_filter(array_map(static function ($row) {
$name = $row['class_section_name'] ?? null;
if (!$name) return null;
$isEvent = (int)($row['is_event_only'] ?? 0) === 1;
return $isEvent ? ($name . ' (Event)') : $name;
}, $rows))));
$names = [];
if ($asArray) {
return $names;
foreach ($rows as $row) {
$name = trim(
(string) ($row['class_section_name'] ?? '')
);
if ($name === '') {
continue;
}
if ((int) ($row['is_event_only'] ?? 0) === 1) {
$name .= ' (Event)';
}
$names[] = $name;
}
return !empty($names) ? implode(', ', $names) : '';
$names = array_values(array_unique($names));
return $asArray ? $names : implode(', ', $names);
}
/**
* Return class_section_id values for a student/year.
* Return class-section IDs assigned to a student for a school year.
*
* @param int|string $studentId
* @param string $schoolYear
* @return int[]
*/
public function getClassSectionIdsByStudentId($studentId, string $schoolYear): array
{
$rows = $this->db->table('student_class')
->select('class_section_id')
->where('student_id', $studentId)
->where('class_section_id IS NOT NULL', null, false)
->where('school_year', $schoolYear)
public function getClassSectionIdsByStudentId(
int|string $studentId,
string $schoolYear
): array {
$rows = $this->freshBuilder()
->select('student_class.class_section_id')
->where('student_class.student_id', $studentId)
->where(
'student_class.class_section_id IS NOT NULL',
null,
false
)
->where(
'student_class.school_year',
trim($schoolYear)
)
->get()
->getResultArray();
$ids = array_map(static fn($r) => (int)($r['class_section_id'] ?? 0), $rows);
return array_values(array_filter(array_unique($ids), static fn($v) => $v > 0));
$ids = array_map(
static fn(array $row): int =>
(int) ($row['class_section_id'] ?? 0),
$rows
);
return array_values(array_unique(array_filter(
$ids,
static fn(int $id): bool => $id > 0
)));
}
/**
* Return active students for a set of class-section IDs.
*/
public function getStudentsByClassSectionIds(
array $classSectionIds,
?string $schoolYear = null
): array {
$classSectionIds = array_values(array_unique(array_filter(
array_map('intval', $classSectionIds),
static fn(int $id): bool => $id > 0
)));
public function getStudentsByClassSectionIds(array $classSectionIds)
{
return $this->select('students.id as student_id, students.firstname, students.lastname, students.school_id, students.is_new,students.photo_consent ,students.age, student_class.school_year, student_class.class_section_id')
->join('students', 'students.id = student_class.student_id')
->whereIn('student_class.class_section_id', $classSectionIds)
->where('students.is_active', 1)
if ($classSectionIds === []) {
return [];
}
$builder = $this->freshBuilder()
->select([
'students.id AS student_id',
'students.firstname',
'students.lastname',
'students.school_id',
'students.is_new',
'students.photo_consent',
'students.age',
'student_class.school_year',
'student_class.class_section_id',
'student_class.is_event_only',
])
->join(
'students',
'students.id = student_class.student_id',
'inner'
)
->whereIn(
'student_class.class_section_id',
$classSectionIds
)
->where('students.is_active', 1);
if ($schoolYear !== null && trim($schoolYear) !== '') {
$builder->where(
'student_class.school_year',
trim($schoolYear)
);
}
return $builder
->orderBy('students.lastname', 'ASC')
->orderBy('students.firstname', 'ASC')
->get()
->getResultArray();
}
/**
* Get the class ID (grade) for a specific student.
*
* @param int $studentId
* @return string class_id or 'N/A'
* Return the student's most recent non-event class grade.
*/
public function getStudentGrade(int $studentId): string
{
$studentClass = $this->where('student_id', $studentId)
->where('is_event_only', 0)
->orderBy('created_at', 'DESC') // in case of multiple entries, get latest
->first();
public function getStudentGrade(
int $studentId,
?string $schoolYear = null
): string {
$builder = $this->freshBuilder()
->select('classSection.class_id')
->join(
'classSection',
'classSection.class_section_id = student_class.class_section_id',
'inner'
)
->where('student_class.student_id', $studentId)
->where('student_class.is_event_only', 0)
->where(
'student_class.class_section_id IS NOT NULL',
null,
false
);
if ($studentClass && isset($studentClass['class_section_id'])) {
$classSection = $this->db->table('classSection')
->where('class_section_id', $studentClass['class_section_id'])
->get()
->getRowArray();
return $classSection['class_id'] ?? 'N/A';
if ($schoolYear !== null && trim($schoolYear) !== '') {
$builder->where(
'student_class.school_year',
trim($schoolYear)
);
}
log_message('error', "Student class or section not found for student ID: $studentId");
$row = $builder
->orderBy('student_class.created_at', 'DESC')
->get()
->getRowArray();
$classId = trim((string) ($row['class_id'] ?? ''));
if ($classId !== '') {
return $classId;
}
log_message(
'warning',
'Student class or section not found for student ID: {studentId}',
['studentId' => $studentId]
);
return 'N/A';
}
/**
* Return true if the student has at least one non-event class assignment for the year.
* Determine whether a student has a non-event assignment for a year.
*/
public function hasNonEventAssignment(int $studentId, string $schoolYear): bool
{
return (bool) $this->where('student_id', $studentId)
->where('school_year', $schoolYear)
->where('is_event_only', 0)
->first();
public function hasNonEventAssignment(
int $studentId,
string $schoolYear
): bool {
return $this->freshBuilder()
->select('student_class.id')
->where('student_class.student_id', $studentId)
->where(
'student_class.school_year',
trim($schoolYear)
)
->where('student_class.is_event_only', 0)
->limit(1)
->get()
->getRowArray() !== null;
}
/**
* Return student counts per class_section_id, optionally scoped to a school year.
* Return active student counts by class section.
*
* @param string|null $schoolYear
* @return array<int|string,int> Map of class_section_id => count
* @return array<int, int>
*/
public function getStudentCountsBySection(?string $schoolYear = null): array
{
$qb = $this->db->table($this->table)
->select('class_section_id, COUNT(*) AS total')
->join('students', 'students.id = student_class.student_id', 'inner')
public function getStudentCountsBySection(
?string $schoolYear = null
): array {
$builder = $this->freshBuilder()
->select(
'student_class.class_section_id, ' .
'COUNT(DISTINCT student_class.student_id) AS total'
)
->join(
'students',
'students.id = student_class.student_id',
'inner'
)
->where('students.is_active', 1)
->where('student_class.class_section_id IS NOT NULL', null, false)
->where(
'student_class.class_section_id IS NOT NULL',
null,
false
)
->groupBy('student_class.class_section_id');
if ($schoolYear !== null && $schoolYear !== '') {
$qb->where('student_class.school_year', $schoolYear);
if ($schoolYear !== null && trim($schoolYear) !== '') {
$builder->where(
'student_class.school_year',
trim($schoolYear)
);
}
$rows = $qb->get()->getResultArray();
$out = [];
foreach ($rows as $r) {
$cid = $r['class_section_id'] ?? null;
if ($cid === null || $cid === '') continue;
$out[$cid] = (int)($r['total'] ?? 0);
$rows = $builder
->get()
->getResultArray();
$counts = [];
foreach ($rows as $row) {
$classSectionId = (int) (
$row['class_section_id'] ?? 0
);
if ($classSectionId <= 0) {
continue;
}
$counts[$classSectionId] = (int) (
$row['total'] ?? 0
);
}
return $out;
return $counts;
}
}
}
+26 -54
View File
@@ -242,9 +242,7 @@ class UserModel extends Model
->where('roles.name', $roleName)
->where('user_roles.deleted_at', null);
if ($this->userRolesHaveSchoolYear()) {
$builder->where('user_roles.school_year', $schoolYear);
} elseif ($this->roleUsesTeacherAssignments($roleName)) {
if ($this->roleUsesTeacherAssignments($roleName)) {
$builder->join(
'teacher_class tc_year',
'tc_year.teacher_id = users.id AND tc_year.school_year = ' . $this->db->escape($schoolYear),
@@ -366,49 +364,32 @@ class UserModel extends Model
}
if (!empty($schoolYear)) {
// Prefer user_roles.school_year if present (true year-scoped roles)
$userRolesFields = [];
try {
$userRolesFields = $this->db->getFieldNames('user_roles');
} catch (\Throwable $e) {
// ignore
}
$escYear = $this->db->escape($schoolYear);
if (in_array('school_year', $userRolesFields, true)) {
$builder->where('ur.school_year', $schoolYear);
} else {
// Fallback: role-aware filter
$escYear = $this->db->escape($schoolYear);
// Include user if:
// (A) They are assigned as teacher/TA in teacher_class for that year
// OR
// (B) They have at least one non-teacher-ish (and non-parent) role (admin/staff/etc.)
//
// Teacher-ish detection: name contains 'teacher' OR equals 'ta'
$builder->groupStart()
// A) has teacher assignment that year
->where("
EXISTS (
SELECT 1
FROM teacher_class tc
WHERE tc.teacher_id = users.id
AND tc.school_year = {$escYear}
)
", null, false)
// B) OR has any non-teacher-ish, non-parent role
->orWhere("
EXISTS (
SELECT 1
FROM user_roles ur2
JOIN roles r2 ON r2.id = ur2.role_id
WHERE ur2.user_id = users.id
AND LOWER(r2.name) NOT IN ('parent','ta')
AND LOWER(r2.name) NOT LIKE '%teacher%'
)
", null, false)
->groupEnd();
}
// Include user if:
// (A) They are assigned as teacher/TA in teacher_class for that year
// OR
// (B) They have at least one non-teacher-ish (and non-parent) global role.
$builder->groupStart()
->where("
EXISTS (
SELECT 1
FROM teacher_class tc
WHERE tc.teacher_id = users.id
AND tc.school_year = {$escYear}
)
", null, false)
->orWhere("
EXISTS (
SELECT 1
FROM user_roles ur2
JOIN roles r2 ON r2.id = ur2.role_id
WHERE ur2.user_id = users.id
AND LOWER(r2.name) NOT IN ('parent','ta')
AND LOWER(r2.name) NOT LIKE '%teacher%'
)
", null, false)
->groupEnd();
}
return $builder
@@ -417,15 +398,6 @@ class UserModel extends Model
->findAll();
}
private function userRolesHaveSchoolYear(): bool
{
try {
return in_array('school_year', $this->db->getFieldNames('user_roles'), true);
} catch (\Throwable $e) {
return false;
}
}
private function roleUsesTeacherAssignments(string $roleName): bool
{
$role = strtolower(trim($roleName));