Files
alrahma_sunday_school/app/Models/AttendanceTrackingModel.php
T
2026-05-16 13:44:12 -04:00

524 lines
18 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Models;
use CodeIgniter\Model;
use CodeIgniter\I18n\Time;
class AttendanceTrackingModel extends Model
{
protected $table = 'attendance_tracking';
protected $primaryKey = 'id';
protected $useAutoIncrement = true;
protected $returnType = 'array';
protected $useSoftDeletes = false;
protected $allowedFields = [
'student_id',
'date',
'is_reported',
'reason',
'is_notified',
'notif_counter',
'semester',
'school_year',
'note',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
protected $dateFormat = 'datetime';
/* ----------------------- Helpers ----------------------- */
/** Normalize Y-m-d (or Y-m-d H:i:s) into a full 'Y-m-d H:i:s' at midnight if time missing. */
private function normalizeDateTime(string $date): string
{
// If already has time, trust it; else append midnight
if (preg_match('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $date)) {
return $date;
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $date . ' 00:00:00';
}
// Best-effort parse
try {
$t = Time::parse($date, 'UTC');
} catch (\Throwable $e) {
$t = Time::today('UTC');
}
return $t->toDateTimeString(); // Y-m-d H:i:s
}
/** Apply semester/schoolYear filters if provided. */
private function applyTermFilters($qb, ?string $semester, ?string $schoolYear)
{
if ($semester !== null && $semester !== '') {
$qb->where('semester', $semester);
}
if ($schoolYear !== null && $schoolYear !== '') {
$qb->where('school_year', $schoolYear);
}
return $qb;
}
/** Derive (semester, school_year) from a Y-m-d date (Fall=AugDec; Spring=JanJul). */
private function deriveTermFromDate(string $ymd): array
{
try {
$d = Time::createFromFormat('Y-m-d', substr($ymd, 0, 10), 'UTC') ?? Time::today('UTC');
} catch (\Throwable $e) {
$d = Time::today('UTC');
}
$m = (int)$d->format('n');
$y = (int)$d->format('Y');
$semester = ($m >= 8 && $m <= 12) ? 'Fall' : 'Spring';
$startY = ($m >= 8) ? $y : $y - 1;
$endY = $startY + 1;
return [$semester, sprintf('%d-%d', $startY, $endY)];
}
/* ----------------------- CRUD-ish APIs ----------------------- */
/**
* Records a student absence (stores time at 00:00:00 if only a day is given).
* NOTE: $semester/$schoolYear are optional; will be derived from $date if omitted.
*/
public function recordAbsence(
int $studentId,
string $date,
?string $semester = null,
?string $schoolYear = null,
bool $isExcused = false,
?string $reason = null
) {
$dt = $this->normalizeDateTime($date);
if ($semester === null || $schoolYear === null) {
[$sem, $yr] = $this->deriveTermFromDate($dt);
$semester = $semester ?? $sem;
$schoolYear = $schoolYear ?? $yr;
}
return $this->insert([
'student_id' => $studentId,
'date' => $dt,
'is_reported' => $isExcused ? 1 : 0,
'reason' => $reason,
'is_notified' => 0,
'notif_counter' => 0,
'semester' => $semester,
'school_year' => $schoolYear,
], true);
}
/**
* All absences for a student; start/end accept Y-m-d or Y-m-d H:i:s.
* For day-only ranges, we compare on DATE(date).
*/
public function getStudentAbsences(
int $studentId,
?string $startDate = null,
?string $endDate = null,
?string $semester = null,
?string $schoolYear = null
): array {
$qb = $this->builder()->where('student_id', $studentId);
if ($startDate && preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
$qb->where('DATE(`date`) >=', $startDate);
} elseif ($startDate) {
$qb->where('date >=', $this->normalizeDateTime($startDate));
}
if ($endDate && preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
$qb->where('DATE(`date`) <=', $endDate);
} elseif ($endDate) {
$qb->where('date <=', $this->normalizeDateTime($endDate));
}
$this->applyTermFilters($qb, $semester, $schoolYear);
return $qb->orderBy('date', 'DESC')->get()->getResultArray();
}
/**
* Unexcused + unnotified absences (optionally for a specific day).
*/
public function getUnnotifiedUnexcusedAbsences(
?string $date = null,
?string $semester = null,
?string $schoolYear = null
): array {
$qb = $this->builder()
->where('is_reported', 0)
->where('is_notified', 0);
if ($date && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
$qb->where('DATE(`date`)', $date);
} elseif ($date) {
$qb->where('date', $this->normalizeDateTime($date));
}
$this->applyTermFilters($qb, $semester, $schoolYear);
return $qb->orderBy('date', 'DESC')->get()->getResultArray();
}
/**
* Has at least N absences in the last N days (rough heuristic);
* for weekly checks, prefer checkConsecutiveAbsences().
*/
public function hasConsecutiveAbsences(
int $studentId,
int $days,
?string $semester = null,
?string $schoolYear = null
): bool {
$start = date('Y-m-d', strtotime("-{$days} days"));
$qb = $this->builder()
->select('COUNT(*) AS cnt')
->where('student_id', $studentId)
->where('DATE(`date`) >=', $start);
$this->applyTermFilters($qb, $semester, $schoolYear);
$row = $qb->get()->getFirstRow('array');
return (int)($row['cnt'] ?? 0) >= $days;
}
/**
* Return TRUE if last $threshold absences are exactly weekly (7-day spacing).
*/
public function checkConsecutiveAbsences(
int $studentId,
int $threshold,
?string $semester = null,
?string $schoolYear = null
): bool {
$qb = $this->builder()
->select('date, is_reported, is_notified')
->where('student_id', $studentId)
->where('is_reported', 0)
->where('is_notified', 0);
$this->applyTermFilters($qb, $semester, $schoolYear);
$absences = $qb->orderBy('date', 'DESC')->limit($threshold)->get()->getResultArray();
if (count($absences) < $threshold) return false;
for ($i = 0; $i < $threshold - 1; $i++) {
$d1 = new \DateTime($absences[$i]['date']);
$d2 = new \DateTime($absences[$i + 1]['date']);
$diffDays = (int)$d2->diff($d1)->format('%a');
if ($diffDays !== 7) {
return false;
}
}
return true;
}
/**
* TRUE if at least $absencesThreshold absences in the last $weeksStreak weeks.
*/
public function checkAbsencesInStreak(
int $studentId,
int $absencesThreshold,
int $weeksStreak,
?string $semester = null,
?string $schoolYear = null
): bool {
$qb = $this->builder()
->select('date, is_reported, is_notified')
->where('student_id', $studentId)
->where('is_reported', 0)
->where('is_notified', 0);
$this->applyTermFilters($qb, $semester, $schoolYear);
$records = $qb->orderBy('date', 'DESC')->limit($weeksStreak)->get()->getResultArray();
if (count($records) < $weeksStreak) return false;
$count = 0;
foreach ($records as $r) {
if ((int)$r['is_reported'] === 0 && (int)$r['is_notified'] === 0) {
$count++;
}
}
return $count >= $absencesThreshold;
}
/**
* Summarize a student's status this term.
*/
public function getStudentAttendanceStats(
int $studentId,
?string $semester = null,
?string $schoolYear = null
): array {
// total_absences
$qb1 = $this->builder()->where('student_id', $studentId);
$this->applyTermFilters($qb1, $semester, $schoolYear);
$total = (int)($qb1->select('COUNT(*) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0);
// unexcused_absences
$qb2 = $this->builder()->where('student_id', $studentId)->where('is_reported', 0);
$this->applyTermFilters($qb2, $semester, $schoolYear);
$unexcused = (int)($qb2->select('COUNT(*) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0);
// last_absence
$qb3 = $this->builder()->where('student_id', $studentId);
$this->applyTermFilters($qb3, $semester, $schoolYear);
$last = $qb3->orderBy('date', 'DESC')->get()->getFirstRow('array');
return [
'total_absences' => $total,
'unexcused_absences' => $unexcused,
'last_absence' => $last,
];
}
/**
* Fetch records by semester/schoolYear (with optional limit).
*/
public function getAttendanceBySemester(
?string $semester = null,
?string $schoolYear = null,
int $limit = 0
): array {
$qb = $this->builder();
$this->applyTermFilters($qb, $semester, $schoolYear);
if ($limit > 0) $qb->limit($limit);
return $qb->orderBy('date', 'DESC')->get()->getResultArray();
}
/**
* Summary counts for a term.
*/
public function getSemesterSummary(?string $semester = null, ?string $schoolYear = null): array
{
// total
$qb1 = $this->builder();
$this->applyTermFilters($qb1, $semester, $schoolYear);
$total = (int)($qb1->select('COUNT(*) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0);
// unexcused
$qb2 = $this->builder()->where('is_reported', 0);
$this->applyTermFilters($qb2, $semester, $schoolYear);
$unexcused = (int)($qb2->select('COUNT(*) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0);
// distinct students
$qb3 = $this->builder();
$this->applyTermFilters($qb3, $semester, $schoolYear);
$students = (int)($qb3->select('COUNT(DISTINCT student_id) AS cnt')->get()->getFirstRow('array')['cnt'] ?? 0);
return [
'total_absences' => $total,
'unexcused_absences' => $unexcused,
'students_with_absences' => $students,
];
}
/**
* Update/create violation rows (matching by student + DATE(date) + term).
* Expects input rows like: ['id'=>..., 'violation'=>..., 'absences'=>[['date'=>'Y-m-d'], ...]]
*/
public function updateAttendanceViolations(array $studentsWithViolations, string $semester, string $schoolYear): array
{
$results = ['added' => 0, 'updated' => 0, 'skipped' => 0, 'notified' => 0];
foreach ($studentsWithViolations as $student) {
if (empty($student['absences'])) {
$results['skipped']++;
continue;
}
foreach ($student['absences'] as $absence) {
$ymd = substr((string)($absence['date'] ?? date('Y-m-d')), 0, 10);
$dt = $this->normalizeDateTime($ymd);
$data = [
'student_id' => (int)$student['id'],
'date' => $dt,
'is_reported' => 0,
'reason' => (string)($student['violation'] ?? ''),
'is_notified' => 0,
'notif_counter' => 0,
'semester' => $semester,
'school_year' => $schoolYear,
];
try {
// Match by student + DATE(date) + term
$existing = $this->builder()
->where('student_id', $data['student_id'])
->where('DATE(`date`)', $ymd)
->where('semester', $semester)
->where('school_year', $schoolYear)
->get()->getFirstRow('array');
if ($existing) {
if (($existing['reason'] ?? '') !== $data['reason']) {
$this->update((int)$existing['id'], $data);
$results['updated']++;
if (!empty($existing['is_notified'])) {
$this->resetNotification((int)$existing['id']);
$results['notified']++;
}
} else {
$results['skipped']++;
}
} else {
$newId = $this->insert($data, true);
$results['added']++;
// Only match a standalone "4" (final threshold)
if (preg_match('/\b4\b/', $data['reason'])) {
$this->markAsNotified((int)$newId);
$results['notified']++;
}
}
} catch (\Throwable $e) {
log_message('error', sprintf(
'Failed to update attendance violation for student %d on %s: %s',
$data['student_id'],
$ymd,
$e->getMessage()
));
$results['skipped']++;
}
}
}
return $results;
}
/* ----------------------- Notification ops ----------------------- */
public function markAsNotified(int $id): bool
{
// atomic increment + set flag; touch updated_at by doing update()
$now = utc_now();
$this->builder()
->where('id', $id)
->set('notif_counter', 'COALESCE(notif_counter,0)+1', false)
->update();
return $this->update($id, [
'is_notified' => 1,
'updated_at' => $now,
]);
}
public function resetNotification(int $id): bool
{
return $this->update($id, ['is_notified' => 0, 'notif_counter' => 0]);
}
public function markRuleAsNotified(
int $studentId,
string $code,
string $ymd,
?string $semester = null,
?string $schoolYear = null
): bool {
$semester = $semester ?? (new \App\Models\ConfigurationModel())->getConfig('semester');
$schoolYear = $schoolYear ?? (new \App\Models\ConfigurationModel())->getConfig('school_year');
$day = substr($ymd, 0, 10);
$start = $day . ' 00:00:00';
$end = date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00';
$now = utc_now();
// 1) Try match by code + day (preferred)
$qb = $this->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date >=', $start)
->where('date <', $end)
->like('reason', $code, 'both')
->orderBy('date', 'DESC');
$row = $qb->first();
// 2) Fallback: match by day only (reason may hold a human-friendly title)
if (!$row) {
$row = $this->where('student_id', $studentId)
->where('semester', $semester)
->where('school_year', $schoolYear)
->where('date >=', $start)
->where('date <', $end)
->orderBy('date', 'DESC')
->first();
}
if ($row && !empty($row['id'])) {
$count = (int)($row['notif_counter'] ?? 0) + 1;
return (bool) $this->update((int)$row['id'], [
'is_notified' => 1,
'notif_counter' => $count,
'is_reported' => 0,
'updated_at' => $now,
]);
}
// No existing row -> insert one so future passes see it as notified
return (bool) $this->insert([
'student_id' => $studentId,
'date' => $start,
'is_reported' => 0,
'reason' => $code, // store the CODE, not a prose title
'is_notified' => 1,
'notif_counter' => 1,
'semester' => $semester,
'school_year' => $schoolYear,
'created_at' => $now,
'updated_at' => $now,
]);
}
/**
* Mark the latest matching tracking row as notified and increment counter.
* $code is the rule code you stored in "reason" (e.g. ABS_2, LATE_3, MIX_L2A1).
* Optionally bound by $lastDate if you want to avoid touching future rows.
*/
public function markNotified(int $studentId, string $schoolYear, ?string $semester, string $code, ?string $lastDate = null): int
{
$qb = $this->builder();
$qb->where('student_id', $studentId)
->where('school_year', $schoolYear);
if (!empty($semester)) {
$qb->where('semester', $semester);
}
if (!empty($lastDate)) {
$qb->where('date <=', $lastDate); // keep it safe to the last violation date you emailed about
}
$qb->like('reason', $code, 'both')
->orderBy('date', 'DESC')
->limit(1);
$row = $qb->get()->getRowArray();
if (!$row) {
return 0;
}
$id = (int)$row['id'];
$counter = (int)($row['notif_counter'] ?? 0) + 1;
return $this->update($id, [
'is_notified' => 1,
'notif_counter'=> $counter,
'updated_at' => utc_now(),
]) ? 1 : 0;
}
}