recreate project
This commit is contained in:
@@ -0,0 +1,523 @@
|
||||
<?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=Aug–Dec; Spring=Jan–Jul). */
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user