recreate project

This commit is contained in:
root
2026-02-10 22:11:06 -05:00
commit 663c0cdbda
10149 changed files with 1379710 additions and 0 deletions
+523
View File
@@ -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=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;
}
}