diff --git a/.env b/.env
index 6443556..1e48905 100644
--- a/.env
+++ b/.env
@@ -63,7 +63,7 @@ session.expiration = 43200
database.default.hostname = 127.0.0.1
database.default.database = school
database.default.username = root
-database.default.password =
+database.default.password = rootpassword
database.default.DBDriver = MySQLi
database.default.DBPrefix =
database.default.port = 3306
diff --git a/app/Config/Database.php b/app/Config/Database.php
index 78561d0..0af3e90 100644
--- a/app/Config/Database.php
+++ b/app/Config/Database.php
@@ -9,43 +9,51 @@ class Database extends Config
public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR;
public string $defaultGroup = 'default';
- public array $default = [
- 'DSN' => '',
- 'hostname' => 'localhost',
- 'username' => 'u280815660_melabidi',
- 'password' => '>tNxlRzP/W8',
- 'database' => 'u280815660_school',
- 'DBDriver' => 'MySQLi',
- 'DBPrefix' => '',
- 'pConnect' => false,
- 'DBDebug' => (ENVIRONMENT !== 'development'),
- 'charset' => 'utf8',
- 'DBCollat' => 'utf8_general_ci',
- 'swapPre' => '',
- 'encrypt' => false,
- 'compress' => false,
- 'strictOn' => false,
- 'failover' => [],
- 'port' => 3306,
- ];
+ public array $default = [];
+ public array $tests = [];
- public array $tests = [
- 'DSN' => '',
- 'hostname' => 'localhost',
- 'username' => 'u280815660_melabidi',
- 'password' => '>tNxlRzP/W8',
- 'database' => 'u280815660_school',
- 'DBDriver' => 'MySQLi',
- 'DBPrefix' => 'db_',
- 'pConnect' => false,
- 'DBDebug' => true,
- 'charset' => 'utf8',
- 'DBCollat' => 'utf8_general_ci',
- 'swapPre' => '',
- 'encrypt' => false,
- 'compress' => false,
- 'strictOn' => false,
- 'failover' => [],
- 'port' => 3306,
- ];
+ public function __construct()
+ {
+ parent::__construct();
+
+ $this->default = [
+ 'DSN' => '',
+ 'hostname' => env('database.default.hostname'),
+ 'username' => env('database.default.username'),
+ 'password' => env('database.default.password'),
+ 'database' => env('database.default.database'),
+ 'DBDriver' => env('database.default.DBDriver', 'MySQLi'),
+ 'DBPrefix' => '',
+ 'pConnect' => false,
+ 'DBDebug' => (ENVIRONMENT !== 'development'),
+ 'charset' => 'utf8',
+ 'DBCollat' => 'utf8_general_ci',
+ 'swapPre' => '',
+ 'encrypt' => false,
+ 'compress' => false,
+ 'strictOn' => false,
+ 'failover' => [],
+ 'port' => (int) env('database.default.port', 3306),
+ ];
+
+ $this->tests = [
+ 'DSN' => '',
+ 'hostname' => env('database.tests.hostname', env('database.default.hostname')),
+ 'username' => env('database.tests.username', env('database.default.username')),
+ 'password' => env('database.tests.password', env('database.default.password')),
+ 'database' => env('database.tests.database', env('database.default.database')),
+ 'DBDriver' => env('database.tests.DBDriver', env('database.default.DBDriver', 'MySQLi')),
+ 'DBPrefix' => env('database.tests.DBPrefix', 'db_'),
+ 'pConnect' => false,
+ 'DBDebug' => true,
+ 'charset' => 'utf8',
+ 'DBCollat' => 'utf8_general_ci',
+ 'swapPre' => '',
+ 'encrypt' => false,
+ 'compress' => false,
+ 'strictOn' => false,
+ 'failover' => [],
+ 'port' => (int) env('database.tests.port', env('database.default.port', 3306)),
+ ];
+ }
}
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index 113b887..57a2926 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -230,6 +230,10 @@ $routes->get('reset_password', 'View\UserController::resetPassword');
//$routes->get('/blocked', 'View\UserController::blocked');
+$routes->get('confirm_authorized_user', 'View\AuthorizedUsersController::confirm');
+$routes->get('set_authorized_user_password/(:num)', 'View\AuthorizedUsersController::setPassword/$1');
+$routes->post('set_authorized_user_password/(:num)', 'View\AuthorizedUsersController::savePassword/$1');
+
$routes->post('assign_class_student', 'View\StudentController::assignClassStudent');
$routes->post('remove_class_student', 'View\StudentController::removeClassStudent');
$routes->post('administrator/remove_class_student', 'View\StudentController::removeClassStudent'); // alias to avoid 404s
@@ -401,6 +405,8 @@ $routes->get('teacher/progress/submit', 'ClassProgressController::create', ['fil
$routes->post('teacher/progress/store', 'ClassProgressController::store', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->get('teacher/progress/history', 'ClassProgressController::history', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->get('teacher/progress/view/(:num)', 'ClassProgressController::view/$1', ['filter' => 'auth:teacher,teacher_assistant']);
+$routes->get('teacher/progress/edit/(:num)', 'ClassProgressController::edit/$1', ['filter' => 'auth:teacher,teacher_assistant']);
+$routes->post('teacher/progress/update/(:num)', 'ClassProgressController::update/$1', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->get('teacher/progress/attachment/(:num)', 'ClassProgressController::attachment/$1', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->get('teacher/progress/attachment-file/(:num)', 'ClassProgressController::attachmentFile/$1', ['filter' => 'auth:teacher,teacher_assistant']);
$routes->get('parent/progress', 'ParentProgressController::index', ['filter' => 'auth:parent']);
diff --git a/app/Controllers/AdminProgressController.php b/app/Controllers/AdminProgressController.php
index 965d9fe..3ef8c63 100644
--- a/app/Controllers/AdminProgressController.php
+++ b/app/Controllers/AdminProgressController.php
@@ -111,6 +111,23 @@ class AdminProgressController extends BaseController
);
$sectionStats = $this->buildSectionSubmissionStats($rows, $activeDatesSet, $expectedDays);
$sectionSubjectCounts = $this->buildSectionSubjectCounts($rows);
+ $lowProgressSectionIds = [];
+ if ($expectedDays > 0) {
+ foreach ($filteredSections as $section) {
+ $sectionId = (int) ($section['class_section_id'] ?? 0);
+ if ($sectionId === 0) {
+ continue;
+ }
+ $stat = $sectionStats[$sectionId] ?? null;
+ $percent = $stat['percent'] ?? 0;
+ if ($stat === null) {
+ $percent = 0;
+ }
+ if ($percent < 50) {
+ $lowProgressSectionIds[] = $sectionId;
+ }
+ }
+ }
return view('admin/class_progress_list', [
'reportGroupsBySection' => $reportGroupsBySection,
@@ -121,6 +138,7 @@ class AdminProgressController extends BaseController
'sectionStats' => $sectionStats,
'sectionSubjectCounts' => $sectionSubjectCounts,
'expectedDays' => $expectedDays,
+ 'lowProgressSectionIds' => $lowProgressSectionIds,
]);
}
@@ -414,13 +432,11 @@ class AdminProgressController extends BaseController
$allowedSubjects = array_values(array_filter($allowedSubjects));
$counts = [];
- $latestWeekBySection = [];
$sectionClassMap = [];
foreach ($rows as $row) {
$sectionId = (int) ($row['class_section_id'] ?? 0);
$subject = (string) ($row['subject'] ?? '');
- $weekStart = (string) ($row['week_start'] ?? '');
- if ($sectionId === 0 || $subject === '' || $weekStart === '') {
+ if ($sectionId === 0 || $subject === '') {
continue;
}
if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) {
@@ -429,45 +445,41 @@ class AdminProgressController extends BaseController
if (! isset($sectionClassMap[$sectionId])) {
$sectionClassMap[$sectionId] = $this->classSectionModel->getClassId($sectionId);
}
- if (
- ! isset($latestWeekBySection[$sectionId])
- || $weekStart > $latestWeekBySection[$sectionId]
- ) {
- $latestWeekBySection[$sectionId] = $weekStart;
- }
}
- $curriculumChapters = $this->buildCurriculumChapterMap(array_values(array_filter($sectionClassMap)));
+ $curriculumUnits = $this->buildCurriculumUnitMap(array_values(array_filter($sectionClassMap)));
foreach ($rows as $row) {
$sectionId = (int) ($row['class_section_id'] ?? 0);
$subject = (string) ($row['subject'] ?? '');
- $weekStart = (string) ($row['week_start'] ?? '');
- if ($sectionId === 0 || $subject === '' || $weekStart === '') {
+ if ($sectionId === 0 || $subject === '') {
continue;
}
if (! empty($allowedSubjects) && ! in_array($subject, $allowedSubjects, true)) {
continue;
}
- if (empty($latestWeekBySection[$sectionId]) || $weekStart !== $latestWeekBySection[$sectionId]) {
- continue;
- }
$subjectSlug = $this->resolveSubjectSlug($subject);
$classId = $sectionClassMap[$sectionId] ?? null;
- $chapterSet = [];
- if ($classId && $subjectSlug && ! empty($curriculumChapters[$classId][$subjectSlug])) {
- $chapterSet = $curriculumChapters[$classId][$subjectSlug];
+ $chapterToUnit = [];
+ if ($classId && $subjectSlug && ! empty($curriculumUnits[$classId][$subjectSlug]['chapter_to_unit'])) {
+ $chapterToUnit = $curriculumUnits[$classId][$subjectSlug]['chapter_to_unit'];
+ }
+ $unitKeys = $this->extractUnitKeys((string) ($row['unit_title'] ?? ''), $chapterToUnit);
+ foreach ($unitKeys as $unitKey) {
+ $key = $subjectSlug . '|' . $unitKey;
+ $counts[$sectionId][$key] = true;
}
- $counts[$sectionId] = ($counts[$sectionId] ?? 0) + $this->countChapterSegments(
- (string) ($row['unit_title'] ?? ''),
- $chapterSet
- );
}
- return $counts;
+ $totals = [];
+ foreach ($counts as $sectionId => $unitSet) {
+ $totals[$sectionId] = count($unitSet);
+ }
+
+ return $totals;
}
- protected function buildCurriculumChapterMap(array $classIds): array
+ protected function buildCurriculumUnitMap(array $classIds): array
{
$classIds = array_values(array_filter(array_map('intval', $classIds)));
if (empty($classIds)) {
@@ -483,10 +495,15 @@ class AdminProgressController extends BaseController
$classId = (int) ($row['class_id'] ?? 0);
$subject = (string) ($row['subject'] ?? '');
$chapter = trim((string) ($row['chapter_name'] ?? ''));
+ $unitNumber = $row['unit_number'] ?? null;
if ($classId === 0 || $subject === '' || $chapter === '') {
continue;
}
- $map[$classId][$subject][$chapter] = true;
+ if ($unitNumber === null || $unitNumber === '') {
+ continue;
+ }
+ $unitKey = (string) $unitNumber;
+ $map[$classId][$subject]['chapter_to_unit'][$chapter] = $unitKey;
}
return $map;
@@ -504,46 +521,93 @@ class AdminProgressController extends BaseController
return null;
}
- protected function countChapterSegments(string $unitTitle, array $chapterSet): int
+ protected function countUnitSegments(string $unitTitle, array $chapterToUnit): int
{
$unitTitle = trim($unitTitle);
if ($unitTitle === '') {
- return 1;
+ return 0;
}
$parts = array_filter(array_map('trim', explode(';', $unitTitle)), static fn ($part) => $part !== '');
if (! $parts) {
- return 1;
+ return 0;
}
- $count = 0;
$seen = [];
foreach ($parts as $part) {
- $chapter = $this->extractChapterFromSegment($part);
- $key = $chapter !== '' ? $chapter : $part;
- if (! empty($chapterSet) && $chapter !== '' && empty($chapterSet[$chapter])) {
+ [$unitPart, $chapterPart] = $this->splitUnitChapterSegment($part);
+ $key = $this->resolveUnitKey($unitPart, $chapterPart, $chapterToUnit);
+ if ($key === '') {
$key = $part;
}
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
- $count++;
}
- return $count > 0 ? $count : 1;
+ return count($seen);
}
- protected function extractChapterFromSegment(string $segment): string
+ protected function splitUnitChapterSegment(string $segment): array
{
$segment = trim($segment);
if ($segment === '') {
- return '';
+ return ['', ''];
}
$pos = strrpos($segment, '/');
if ($pos === false) {
- return $segment;
+ return [$segment, ''];
}
- return trim(substr($segment, $pos + 1));
+ $unitPart = trim(substr($segment, 0, $pos));
+ $chapterPart = trim(substr($segment, $pos + 1));
+ return [$unitPart, $chapterPart];
+ }
+
+ protected function extractUnitKeys(string $unitTitle, array $chapterToUnit): array
+ {
+ $unitTitle = trim($unitTitle);
+ if ($unitTitle === '') {
+ return [];
+ }
+ $parts = array_filter(array_map('trim', explode(';', $unitTitle)), static fn ($part) => $part !== '');
+ if (! $parts) {
+ return [];
+ }
+
+ $keys = [];
+ foreach ($parts as $part) {
+ [$unitPart, $chapterPart] = $this->splitUnitChapterSegment($part);
+ $key = $this->resolveUnitKey($unitPart, $chapterPart, $chapterToUnit);
+ if ($key === '') {
+ continue;
+ }
+ $keys[$key] = true;
+ }
+
+ return array_keys($keys);
+ }
+
+ protected function resolveUnitKey(string $unitPart, string $chapterPart, array $chapterToUnit): string
+ {
+ if ($chapterPart !== '' && ! empty($chapterToUnit[$chapterPart])) {
+ return (string) $chapterToUnit[$chapterPart];
+ }
+ if (empty($chapterToUnit)) {
+ if (preg_match('/\bunit\s*(\d+)\b/i', $unitPart, $matches)) {
+ return (string) $matches[1];
+ }
+ return '';
+ }
+ if (preg_match('/\bunit\s*(\d+)\b/i', $unitPart, $matches)) {
+ return (string) $matches[1];
+ }
+ if ($unitPart !== '') {
+ return $unitPart;
+ }
+ if ($chapterPart !== '') {
+ return $chapterPart;
+ }
+ return '';
}
protected function buildSectionStat(int $submitted, int $expectedDays): array
diff --git a/app/Controllers/AuthController.php b/app/Controllers/AuthController.php
index 96aa341..aee212d 100644
--- a/app/Controllers/AuthController.php
+++ b/app/Controllers/AuthController.php
@@ -469,6 +469,7 @@ class AuthController extends Controller
// Generate a secure token for the password reset
helper('text');
$token = bin2hex(random_bytes(48));
+ $tokenHash = hash('sha256', $token);
// Calculate the expiration time for the token (1 hour from now)
$expires_at = Time::now()->addHours(1);
@@ -477,7 +478,7 @@ class AuthController extends Controller
$passwordResetModel = new PasswordResetModel();
$passwordResetModel->insert([
'email' => $email,
- 'token' => $token,
+ 'token' => $tokenHash,
'created_at' => Time::now(),
'expires_at' => $expires_at,
]);
diff --git a/app/Controllers/ClassProgressController.php b/app/Controllers/ClassProgressController.php
index b25f03c..c40ba30 100644
--- a/app/Controllers/ClassProgressController.php
+++ b/app/Controllers/ClassProgressController.php
@@ -98,6 +98,10 @@ class ClassProgressController extends BaseController
return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
}
+ if (! $this->hasIslamicUnitSelection()) {
+ return redirect()->back()->withInput()->with('error', 'Please select at least one Islamic Studies unit.');
+ }
+
$attachmentErrors = $this->validateAttachmentFiles($subjectSections);
if (! empty($attachmentErrors)) {
return redirect()->back()->withInput()->with('errors', $attachmentErrors);
@@ -119,6 +123,32 @@ class ClassProgressController extends BaseController
return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.');
}
+ $confirmOverwrite = (bool) $this->request->getPost('confirm_overwrite');
+ $existingReports = $this->reportModel
+ ->select('id')
+ ->where('class_section_id', $classSectionId)
+ ->where('week_start', $weekStart)
+ ->where('teacher_id', $teacherId)
+ ->findAll();
+
+ if (! $confirmOverwrite && ! empty($existingReports)) {
+ return redirect()->back()
+ ->withInput()
+ ->with('warning', 'A progress report already exists for this week, are you sure you want to override it?')
+ ->with('confirm_overwrite', true);
+ }
+
+ if ($confirmOverwrite && ! empty($existingReports)) {
+ $existingIds = array_values(array_filter(array_map(
+ static fn (array $row): int => (int) ($row['id'] ?? 0),
+ $existingReports
+ )));
+ if (! empty($existingIds)) {
+ $this->attachmentModel->whereIn('report_id', $existingIds)->delete();
+ $this->reportModel->whereIn('id', $existingIds)->delete();
+ }
+ }
+
$status = self::DEFAULT_STATUS;
$reportsCreated = 0;
@@ -276,6 +306,231 @@ class ClassProgressController extends BaseController
]);
}
+ public function edit($id)
+ {
+ $teacherId = (int) session()->get('user_id');
+ $row = $this->reportModel
+ ->select('class_progress_reports.*, cs.class_section_name')
+ ->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
+ ->where('class_progress_reports.id', (int) $id)
+ ->first();
+
+ if (! $row) {
+ throw new PageNotFoundException('Progress report not found.');
+ }
+
+ [$semester, $schoolYear] = $this->resolveCurrentTerm();
+ $allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear);
+ if (empty($allowedTeacherIds)) {
+ if ($teacherId !== (int) $row['teacher_id']) {
+ throw new PageNotFoundException('Progress report not found.');
+ }
+ $allowedTeacherIds = [(int) $row['teacher_id']];
+ } elseif (! in_array($teacherId, $allowedTeacherIds, true)) {
+ throw new PageNotFoundException('Progress report not found.');
+ }
+
+ $weeklyReports = $this->reportModel
+ ->select('class_progress_reports.*')
+ ->whereIn('teacher_id', $allowedTeacherIds)
+ ->where('class_section_id', $row['class_section_id'])
+ ->where('week_start', $row['week_start'])
+ ->orderBy('subject', 'ASC')
+ ->findAll();
+
+ $reportMap = [];
+ foreach ($weeklyReports as $report) {
+ $subject = (string) ($report['subject'] ?? '');
+ if ($subject === '') {
+ continue;
+ }
+ $reportMap[$subject] = $report;
+ }
+
+ $subjectReports = [];
+ foreach (self::SUBJECT_SECTIONS as $slug => $section) {
+ $subjectName = $section['db_subject'] ?? $section['label'] ?? $slug;
+ $report = $reportMap[$subjectName] ?? null;
+ if (! $report) {
+ continue;
+ }
+ $parsed = $this->parseUnitChapterSummary((string) ($report['unit_title'] ?? ''));
+ $subjectReports[$slug] = [
+ 'report_id' => (int) ($report['id'] ?? 0),
+ 'covered' => $report['covered'] ?? '',
+ 'homework' => $report['homework'] ?? '',
+ 'unit_title' => $report['unit_title'] ?? '',
+ 'unit_values' => $parsed['units'],
+ 'chapter_values' => $parsed['chapters'],
+ ];
+ }
+
+ $assignments = $this->loadTeacherSections($teacherId);
+ $classId = null;
+ $classSectionName = $row['class_section_name'] ?? '';
+ foreach ($assignments as $assignment) {
+ if ((int) ($assignment['class_section_id'] ?? 0) === (int) $row['class_section_id']) {
+ $classId = $assignment['class_id'] ?? null;
+ $classSectionName = $assignment['class_section_name'] ?? $classSectionName;
+ break;
+ }
+ }
+
+ $subjectCurriculum = [];
+ if ($classId) {
+ foreach (self::SUBJECT_SECTIONS as $slug => $section) {
+ $subjectCurriculum[$slug] = $this->curriculumModel->getOptionsForClass((int) $classId, $slug);
+ }
+ }
+
+ return view('teacher/class_progress_submit', [
+ 'subjectSections' => self::SUBJECT_SECTIONS,
+ 'subjectCurriculum' => $subjectCurriculum,
+ 'classSectionId' => $row['class_section_id'],
+ 'classSectionName' => $classSectionName,
+ 'classId' => $classId,
+ 'sundayOptions' => [$row['week_start']],
+ 'defaultWeekStart' => $row['week_start'],
+ 'existingWeekEnd' => $row['week_end'],
+ 'existingReports' => $subjectReports,
+ 'isEdit' => true,
+ 'formAction' => base_url('teacher/progress/update/' . (int) $row['id']),
+ 'submitLabel' => 'Update Progress',
+ ]);
+ }
+
+ public function update($id)
+ {
+ $teacherId = (int) session()->get('user_id');
+ $row = $this->reportModel->find((int) $id);
+ if (! $row) {
+ throw new PageNotFoundException('Progress report not found.');
+ }
+
+ [$semester, $schoolYear] = $this->resolveCurrentTerm();
+ $allowedTeacherIds = $this->resolveAssignedTeacherIds((int) $row['class_section_id'], $semester, $schoolYear);
+ if (empty($allowedTeacherIds)) {
+ if ($teacherId !== (int) $row['teacher_id']) {
+ throw new PageNotFoundException('Progress report not found.');
+ }
+ $allowedTeacherIds = [(int) $row['teacher_id']];
+ } elseif (! in_array($teacherId, $allowedTeacherIds, true)) {
+ throw new PageNotFoundException('Progress report not found.');
+ }
+
+ $subjectSections = self::SUBJECT_SECTIONS;
+ $rules = [
+ 'class_section_id' => 'required|integer',
+ 'week_start' => 'required|valid_date[Y-m-d]',
+ 'week_end' => 'required|valid_date[Y-m-d]',
+ ];
+ foreach ($subjectSections as $slug => $section) {
+ $rules["covered_$slug"] = 'required|string';
+ $rules["homework_$slug"] = 'permit_empty|string';
+ $rules["unit_{$slug}.*"] = 'permit_empty|string|max_length[120]';
+ $rules["chapter_{$slug}.*"] = 'permit_empty|string|max_length[120]';
+ }
+
+ if (! $this->validate($rules)) {
+ return redirect()->back()->withInput()->with('errors', $this->validator->getErrors());
+ }
+
+ if (! $this->hasIslamicUnitSelection()) {
+ return redirect()->back()->withInput()->with('error', 'Please select at least one Islamic Studies unit.');
+ }
+
+ $attachmentErrors = $this->validateAttachmentFiles($subjectSections);
+ if (! empty($attachmentErrors)) {
+ return redirect()->back()->withInput()->with('errors', $attachmentErrors);
+ }
+
+ $weekStart = (string) $this->request->getPost('week_start');
+ $weekEnd = (string) $this->request->getPost('week_end');
+ if ($weekStart && ! $weekEnd) {
+ $weekEnd = $this->buildWeekEndFromStart($weekStart);
+ }
+ if ($weekStart && $weekEnd && strtotime($weekEnd) < strtotime($weekStart)) {
+ return redirect()->back()->withInput()->with('error', 'Week end must be the same as or after the week start.');
+ }
+
+ $classSectionId = (int) ($row['class_section_id'] ?? 0);
+ if ($classSectionId === 0) {
+ return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.');
+ }
+
+ $weeklyReports = $this->reportModel
+ ->select('class_progress_reports.*')
+ ->whereIn('teacher_id', $allowedTeacherIds)
+ ->where('class_section_id', $classSectionId)
+ ->where('week_start', $row['week_start'])
+ ->orderBy('subject', 'ASC')
+ ->findAll();
+
+ $reportMap = [];
+ foreach ($weeklyReports as $report) {
+ $subject = (string) ($report['subject'] ?? '');
+ if ($subject === '') {
+ continue;
+ }
+ $reportMap[$subject] = $report;
+ }
+
+ $reportsUpdated = 0;
+ $flagsInput = $this->request->getPost('flags');
+ foreach ($subjectSections as $slug => $section) {
+ $covered = trim((string) $this->request->getPost("covered_$slug"));
+ if ($covered === '') {
+ continue;
+ }
+ $homework = trim((string) $this->request->getPost("homework_$slug"));
+ $unitTitle = $this->buildUnitChapterSummary($slug);
+ $subjectName = $section['db_subject'] ?? $section['label'] ?? $slug;
+ $existing = $reportMap[$subjectName] ?? null;
+ if ($unitTitle === null && $existing) {
+ $unitTitle = $existing['unit_title'] ?? null;
+ }
+
+ $data = [
+ 'class_section_id' => $classSectionId,
+ 'week_start' => $weekStart,
+ 'week_end' => $weekEnd,
+ 'subject' => $subjectName,
+ 'unit_title' => $unitTitle,
+ 'covered' => $covered,
+ 'homework' => $homework ?: null,
+ ];
+ if ($flagsInput !== null) {
+ $data['flags_json'] = $this->normalizeFlags($flagsInput);
+ }
+
+ if ($existing) {
+ $this->reportModel->update((int) $existing['id'], $data);
+ $reportId = (int) $existing['id'];
+ } else {
+ $data['teacher_id'] = $teacherId;
+ $data['status'] = self::DEFAULT_STATUS;
+ $reportId = (int) $this->reportModel->insert($data, true);
+ }
+
+ $attachmentField = "attachment_$slug";
+ $attachments = $this->request->getFileMultiple($attachmentField) ?? [];
+ $storedAttachments = $this->storeAttachments($reportId, $attachments);
+ if (! empty($storedAttachments)) {
+ $this->attachmentModel->insertBatch($storedAttachments);
+ if (empty($existing['attachment_path'] ?? '')) {
+ $this->reportModel->update($reportId, ['attachment_path' => $storedAttachments[0]['file_path']]);
+ }
+ }
+ $reportsUpdated++;
+ }
+
+ if ($reportsUpdated === 0) {
+ return redirect()->back()->withInput()->with('error', 'Please provide progress for at least one subject.');
+ }
+
+ return redirect()->to('teacher/progress/history')->with('success', 'Progress reports updated.');
+ }
+
public function attachment($id)
{
$row = $this->reportModel->find((int)$id);
@@ -447,6 +702,45 @@ class ClassProgressController extends BaseController
return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary;
}
+ protected function hasIslamicUnitSelection(): bool
+ {
+ $unitValues = array_map('trim', (array) $this->request->getPost('unit_islamic'));
+ foreach ($unitValues as $value) {
+ if ($value !== '') {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ protected function parseUnitChapterSummary(string $summary): array
+ {
+ $summary = trim($summary);
+ if ($summary === '') {
+ return ['units' => [], 'chapters' => []];
+ }
+
+ $units = [];
+ $chapters = [];
+ $segments = preg_split('/\s*;\s*/', $summary, -1, PREG_SPLIT_NO_EMPTY);
+ foreach ($segments as $segment) {
+ $segment = trim($segment);
+ if ($segment === '') {
+ continue;
+ }
+ $parts = preg_split('/\s*\/\s*/', $segment, 2);
+ if (count($parts) === 2) {
+ $units[] = trim($parts[0]);
+ $chapters[] = trim($parts[1]);
+ } else {
+ $units[] = $segment;
+ $chapters[] = '';
+ }
+ }
+
+ return ['units' => $units, 'chapters' => $chapters];
+ }
+
protected function buildSundayOptions(int $count = 12): array
{
$range = $this->resolveProgressDateRange();
diff --git a/app/Controllers/ParentProgressController.php b/app/Controllers/ParentProgressController.php
index e32aede..de6f577 100644
--- a/app/Controllers/ParentProgressController.php
+++ b/app/Controllers/ParentProgressController.php
@@ -29,18 +29,12 @@ class ParentProgressController extends BaseController
public function index()
{
- $sectionIds = $this->getParentSectionIds();
- $sectionOptions = $this->buildSectionOptions($sectionIds);
+ $students = $this->getParentStudents();
+ $sectionIds = array_values(array_unique(array_filter(array_map(
+ static fn (array $student): int => (int) ($student['class_section_id'] ?? 0),
+ $students
+ ))));
$subjectSections = ClassProgressController::SUBJECT_SECTIONS;
- $selectedSectionId = (int) $this->request->getGet('class_section_id');
- $validSectionIds = array_keys($sectionOptions);
-
- if ($selectedSectionId === 0 && ! empty($validSectionIds)) {
- $selectedSectionId = $validSectionIds[0];
- }
- if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) {
- $selectedSectionId = $validSectionIds[0] ?? null;
- }
$rows = [];
if (! empty($sectionIds)) {
@@ -50,23 +44,32 @@ class ParentProgressController extends BaseController
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
->whereIn('class_progress_reports.class_section_id', $sectionIds);
- if ($selectedSectionId) {
- $builder->where('class_progress_reports.class_section_id', $selectedSectionId);
- }
-
$rows = $builder
->orderBy('week_start', 'DESC')
->findAll();
}
- $reportGroups = $this->groupReportsByWeek($rows);
+ $studentReportGroups = [];
+ foreach ($students as $student) {
+ $studentId = (int) ($student['student_id'] ?? 0);
+ if ($studentId === 0) {
+ continue;
+ }
+ $classSectionId = (int) ($student['class_section_id'] ?? 0);
+ $studentRows = $classSectionId
+ ? array_values(array_filter(
+ $rows,
+ static fn (array $row): bool => (int) ($row['class_section_id'] ?? 0) === $classSectionId
+ ))
+ : [];
+ $studentReportGroups[$studentId] = $this->groupReportsByWeek($studentRows);
+ }
return view('parent/class_progress_list', [
- 'reportGroups' => $reportGroups,
+ 'students' => $students,
+ 'studentReportGroups' => $studentReportGroups,
'subjectSections' => $subjectSections,
- 'classSectionOptions' => $sectionOptions,
- 'selectedSectionId' => $selectedSectionId,
- 'hasSections' => ! empty($sectionIds),
+ 'hasStudents' => ! empty($students),
]);
}
@@ -198,20 +201,53 @@ class ParentProgressController extends BaseController
return $options;
}
+ protected function getParentStudents(): array
+ {
+ $parentId = (int) session()->get('user_id');
+ if ($parentId === 0) {
+ return [];
+ }
+
+ $rows = $this->db->table('enrollments e')
+ ->select('e.student_id, e.class_section_id, e.updated_at, e.created_at, s.firstname, s.lastname, cs.class_section_name')
+ ->join('students s', 's.id = e.student_id')
+ ->join('classSection cs', 'cs.class_section_id = e.class_section_id', 'left')
+ ->where('e.parent_id', $parentId)
+ ->where('e.is_withdrawn', 0)
+ ->orderBy('e.updated_at', 'DESC')
+ ->orderBy('e.created_at', 'DESC')
+ ->get()
+ ->getResultArray();
+
+ $students = [];
+ foreach ($rows as $row) {
+ $studentId = (int) ($row['student_id'] ?? 0);
+ if ($studentId === 0 || isset($students[$studentId])) {
+ continue;
+ }
+ $students[$studentId] = $row;
+ }
+
+ return array_values($students);
+ }
+
protected function groupReportsByWeek(array $rows): array
{
$reportGroups = [];
foreach ($rows as $row) {
$row['status_label'] = ClassProgressController::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
- $key = $row['week_start'] ?? '';
- if ($key === '') {
+ $weekStart = $row['week_start'] ?? '';
+ $sectionId = (int) ($row['class_section_id'] ?? 0);
+ if ($weekStart === '' || $sectionId === 0) {
continue;
}
+ $key = $weekStart . ':' . $sectionId;
if (! isset($reportGroups[$key])) {
$reportGroups[$key] = [
'week_start' => $row['week_start'] ?? '',
'week_end' => $row['week_end'] ?? '',
'class_section_name' => $row['class_section_name'] ?? '',
+ 'class_section_id' => $sectionId,
'reports' => [],
];
}
diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php
index 6991201..61fb1be 100644
--- a/app/Controllers/View/AdministratorController.php
+++ b/app/Controllers/View/AdministratorController.php
@@ -28,6 +28,8 @@ use App\Models\ScoreCommentModel;
use App\Models\SemesterScoreModel;
use App\Models\TeacherClassModel;
use App\Models\TeacherSubmissionNotificationHistoryModel;
+use App\Models\ExamDraftModel;
+use App\Models\HomeworkModel;
use App\Services\SemesterRangeService;
use CodeIgniter\Events\Events;
@@ -696,15 +698,26 @@ class AdministratorController extends BaseController
public function teacherSubmissionsReport()
{
- $semester = (string)($this->semester ?? '');
- $schoolYear = (string)($this->schoolYear ?? '');
+ $semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
+ $schoolYear = (string)($this->configModel->getConfig('school_year') ?? $this->schoolYear ?? '');
+ $semesterResolver = new SemesterRangeService($this->configModel);
+ $semesterNorm = $semesterResolver->normalizeSemester($semester);
+ $semesterFilter = $semesterNorm !== '' ? $semesterNorm : $semester;
+ $semesterCandidates = $this->buildSemesterCandidates($semesterFilter);
+ $lowProgressRaw = (string) $this->request->getGet('low_progress_sections');
+ $lowProgressSectionIds = array_values(array_unique(array_filter(array_map(
+ 'intval',
+ preg_split('/\s*,\s*/', $lowProgressRaw, -1, PREG_SPLIT_NO_EMPTY)
+ ))));
$scoreComments = new ScoreCommentModel();
$semesterScores = new SemesterScoreModel();
$attendanceDays = new AttendanceDayModel();
+ $examDrafts = new ExamDraftModel();
+ $homeworkModel = new HomeworkModel();
$historyModel = new TeacherSubmissionNotificationHistoryModel();
- $assignmentRows = $this->db->table('teacher_class tc')
+ $assignmentQuery = $this->db->table('teacher_class tc')
->select([
'tc.class_section_id',
'cs.class_section_name',
@@ -715,11 +728,91 @@ class AdministratorController extends BaseController
])
->join('classSection cs', 'cs.class_section_id = tc.class_section_id', 'left')
->join('users u', 'u.id = tc.teacher_id', 'left')
- ->where('tc.school_year', $schoolYear)
- ->where('tc.semester', $semester)
- ->orderBy('cs.class_section_name', 'ASC')
- ->get()
- ->getResultArray();
+ ->orderBy('cs.class_section_name', 'ASC');
+
+ $filteredQuery = clone $assignmentQuery;
+ if ($schoolYear !== '') {
+ $filteredQuery = $filteredQuery->where('tc.school_year', $schoolYear);
+ }
+ if (!empty($semesterCandidates)) {
+ $filteredQuery = $filteredQuery->whereIn('tc.semester', $semesterCandidates);
+ }
+
+ $assignmentRows = $filteredQuery->get()->getResultArray();
+ if (empty($assignmentRows) && ($schoolYear !== '' || $semester !== '')) {
+ $assignmentRows = $assignmentQuery->get()->getResultArray();
+ }
+
+ $studentCounts = $this->studentClassModel->getStudentCountsBySection($schoolYear !== '' ? $schoolYear : null);
+ $sectionRows = $this->classSectionModel
+ ->select('class_section_id, class_section_name')
+ ->orderBy('class_section_name', 'ASC')
+ ->findAll();
+ $sectionMap = [];
+ foreach ($sectionRows as $sectionRow) {
+ $sectionId = (int) ($sectionRow['class_section_id'] ?? 0);
+ if ($sectionId <= 0) {
+ continue;
+ }
+ if (empty($studentCounts[$sectionId])) {
+ continue;
+ }
+ $sectionMap[$sectionId] = $sectionRow['class_section_name'] ?? "Section {$sectionId}";
+ }
+ $sectionIds = array_keys($sectionMap);
+
+ [$progressExpectedWeeks, $progressSubmittedBySection] = $this->buildClassProgressStats($sectionIds);
+ $examDraftCounts = [];
+ $examDraftDeadline = $this->resolveExamDraftDeadline($semester, $schoolYear);
+ $homeworkCounts = [];
+ if (! empty($sectionIds)) {
+ $draftBuilder = $examDrafts
+ ->select('class_section_id')
+ ->whereIn('class_section_id', $sectionIds);
+ if ($schoolYear !== '') {
+ $draftBuilder->where('school_year', $schoolYear);
+ }
+ if (!empty($semesterCandidates)) {
+ $draftBuilder->whereIn('semester', $semesterCandidates);
+ }
+ if ($this->db->fieldExists('is_legacy', 'exam_drafts')) {
+ $draftBuilder->where('is_legacy', 0);
+ }
+ $draftRows = $draftBuilder->findAll();
+ foreach ($draftRows as $draft) {
+ $sectionId = (int) ($draft['class_section_id'] ?? 0);
+ if ($sectionId <= 0) {
+ continue;
+ }
+ $examDraftCounts[$sectionId] = ($examDraftCounts[$sectionId] ?? 0) + 1;
+ }
+
+ $homeworkBuilder = $homeworkModel
+ ->select('class_section_id, homework_index')
+ ->whereIn('class_section_id', $sectionIds);
+ if ($schoolYear !== '') {
+ $homeworkBuilder->where('school_year', $schoolYear);
+ }
+ if (!empty($semesterCandidates)) {
+ $homeworkBuilder->whereIn('semester', $semesterCandidates);
+ }
+ $homeworkRows = $homeworkBuilder
+ ->where('score IS NOT NULL', null, false)
+ ->where('score !=', '')
+ ->groupBy('class_section_id, homework_index')
+ ->findAll();
+ foreach ($homeworkRows as $row) {
+ $sectionId = (int) ($row['class_section_id'] ?? 0);
+ if ($sectionId <= 0) {
+ continue;
+ }
+ $homeworkCounts[$sectionId] = ($homeworkCounts[$sectionId] ?? 0) + 1;
+ }
+ }
+
+ if (empty($lowProgressSectionIds)) {
+ $lowProgressSectionIds = $this->resolveLowProgressSectionIds($sectionIds);
+ }
$teachersBySection = [];
foreach ($assignmentRows as $assignment) {
@@ -745,7 +838,7 @@ class AdministratorController extends BaseController
$entry = &$teachersBySection[$sectionId];
if (!isset($entry)) {
$entry = [
- 'class_section' => $assignment['class_section_name'] ?? "Section {$sectionId}",
+ 'class_section' => $assignment['class_section_name'] ?? ($sectionMap[$sectionId] ?? "Section {$sectionId}"),
'teachers' => [],
];
}
@@ -765,35 +858,49 @@ class AdministratorController extends BaseController
$missingItemCount = 0;
$allTeacherIds = [];
$allClassSectionIds = [];
- foreach ($teachersBySection as $classSectionId => $section) {
+ $examTerm = $this->resolveExamTermLabel($semester);
+ $examScoreField = $examTerm === 'final' ? 'final_exam_score' : 'midterm_exam_score';
+
+ foreach ($sectionMap as $classSectionId => $sectionName) {
$classSectionId = (int)$classSectionId;
if ($classSectionId <= 0) {
continue;
}
- $studentEntries = $this->studentClassModel
+ $studentQuery = $this->studentClassModel
->select('student_id')
->where('class_section_id', $classSectionId)
- ->where('semester', $semester)
- ->where('school_year', $schoolYear)
- ->findAll();
+ ->where('school_year', $schoolYear);
+ if (!empty($semesterCandidates)) {
+ $studentQuery->whereIn('semester', $semesterCandidates);
+ }
+ $studentEntries = $studentQuery->findAll();
+ if (empty($studentEntries)) {
+ $studentEntries = $this->studentClassModel
+ ->select('student_id')
+ ->where('class_section_id', $classSectionId)
+ ->where('school_year', $schoolYear)
+ ->findAll();
+ }
$studentIds = array_filter(array_map(static fn($entry) => (int)($entry['student_id'] ?? 0), $studentEntries));
$expected = count($studentIds);
$midtermStudents = [];
$participationStudents = [];
if ($classSectionId > 0) {
- $scoreRecords = $semesterScores
+ $scoreQuery = $semesterScores
->where('class_section_id', $classSectionId)
- ->where('semester', $semester)
- ->where('school_year', $schoolYear)
- ->findAll();
+ ->where('school_year', $schoolYear);
+ if (!empty($semesterCandidates)) {
+ $scoreQuery->whereIn('semester', $semesterCandidates);
+ }
+ $scoreRecords = $scoreQuery->findAll();
foreach ($scoreRecords as $score) {
$sid = (int)($score['student_id'] ?? 0);
if ($sid <= 0 || ($expected > 0 && !in_array($sid, $studentIds, true))) {
continue;
}
- $midtermValue = trim((string)($score['midterm_exam_score'] ?? ''));
+ $midtermValue = trim((string)($score[$examScoreField] ?? ''));
if ($midtermValue !== '') {
$midtermStudents[$sid] = true;
}
@@ -807,13 +914,15 @@ class AdministratorController extends BaseController
$midtermCommentStudents = [];
$ptapCommentStudents = [];
if (!empty($studentIds)) {
- $comments = $scoreComments
+ $commentQuery = $scoreComments
->select('student_id, score_type, comment')
->whereIn('student_id', $studentIds)
- ->where('semester', $semester)
->where('school_year', $schoolYear)
- ->whereIn('score_type', ['midterm', 'ptap'])
- ->findAll();
+ ->whereIn('score_type', [$examTerm, 'ptap']);
+ if (!empty($semesterCandidates)) {
+ $commentQuery->whereIn('semester', $semesterCandidates);
+ }
+ $comments = $commentQuery->findAll();
foreach ($comments as $comment) {
$sid = (int)($comment['student_id'] ?? 0);
if ($sid <= 0) {
@@ -824,7 +933,7 @@ class AdministratorController extends BaseController
continue;
}
$type = strtolower(trim((string)($comment['score_type'] ?? '')));
- if ($type === 'midterm') {
+ if ($type === $examTerm) {
$midtermCommentStudents[$sid] = true;
}
if ($type === 'ptap') {
@@ -833,14 +942,17 @@ class AdministratorController extends BaseController
}
}
- $attendanceRow = $attendanceDays
+ $attendanceQuery = $attendanceDays
->where('class_section_id', $classSectionId)
- ->where('semester', $semester)
->where('school_year', $schoolYear)
- ->where('date', $today)
- ->first();
+ ->where('date', $today);
+ if (!empty($semesterCandidates)) {
+ $attendanceQuery->whereIn('semester', $semesterCandidates);
+ }
+ $attendanceRow = $attendanceQuery->first();
$attendanceSubmitted = $attendanceRow && in_array(strtolower((string)($attendanceRow['status'] ?? '')), ['submitted', 'published', 'finalized'], true);
+ $section = $teachersBySection[$classSectionId] ?? ['teachers' => []];
$teacherList = $section['teachers'] ?? [];
if (!empty($teacherList)) {
usort($teacherList, function ($a, $b) {
@@ -861,18 +973,27 @@ class AdministratorController extends BaseController
$participationStatus = $this->submissionStatus(count($participationStudents), $expected);
$ptapCommentStatus = $this->submissionStatus(count($ptapCommentStudents), $expected);
$attendanceStatus = $this->attendanceStatus($attendanceSubmitted);
+ $progressSubmitted = (int) ($progressSubmittedBySection[$classSectionId] ?? 0);
+ $classProgressStatus = $this->progressStatus($progressSubmitted, $progressExpectedWeeks);
+ $draftSubmitted = (int) ($examDraftCounts[$classSectionId] ?? 0);
+ $examDraftStatus = $this->draftStatus($draftSubmitted, $examDraftDeadline);
+ $homeworkSubmitted = (int) ($homeworkCounts[$classSectionId] ?? 0);
+ $homeworkStatus = $this->homeworkStatus($homeworkSubmitted);
$statusDetails = [
'midterm_score_status' => $midtermScoreStatus,
'midterm_comment_status' => $midtermCommentStatus,
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
+ 'class_progress_status' => $classProgressStatus,
+ 'exam_draft_status' => $examDraftStatus,
+ 'homework_status' => $homeworkStatus,
];
- $missingItemsForSection = $this->buildMissingItems($statusDetails);
+ $missingItemsForSection = $this->buildMissingItems($statusDetails, $semester);
$missingItemCount += count($missingItemsForSection);
$totalStatuses += count($statusDetails);
$rows[] = [
- 'class_section' => $section['class_section'] ?? "Section {$classSectionId}",
+ 'class_section' => $sectionMap[$classSectionId] ?? ($section['class_section'] ?? "Section {$classSectionId}"),
'class_section_id' => $classSectionId,
'teachers' => $teacherList,
'midterm_score_status' => $midtermScoreStatus,
@@ -880,6 +1001,9 @@ class AdministratorController extends BaseController
'participation_status' => $participationStatus,
'ptap_comment_status' => $ptapCommentStatus,
'attendance_status' => $attendanceStatus,
+ 'class_progress_status' => $classProgressStatus,
+ 'exam_draft_status' => $examDraftStatus,
+ 'homework_status' => $homeworkStatus,
'missing_items' => $missingItemsForSection,
'student_count' => $expected,
];
@@ -941,15 +1065,181 @@ class AdministratorController extends BaseController
'schoolYear' => $schoolYear,
'notificationHistory' => $historyMap,
'summary' => $summary,
+ 'lowProgressSectionIds' => $lowProgressSectionIds,
]);
}
+ private function resolveLowProgressSectionIds(array $sectionIds): array
+ {
+ [$expectedWeeks, $submittedBySection] = $this->buildClassProgressStats($sectionIds);
+ if ($expectedWeeks <= 0) {
+ return [];
+ }
+
+ $lowProgressSectionIds = [];
+ foreach ($sectionIds as $sectionId) {
+ $submitted = (int) ($submittedBySection[$sectionId] ?? 0);
+ $percent = ($submitted / $expectedWeeks) * 100;
+ if ($percent < 50) {
+ $lowProgressSectionIds[] = $sectionId;
+ }
+ }
+
+ return $lowProgressSectionIds;
+ }
+
+ private function buildClassProgressStats(array $sectionIds): array
+ {
+ $sectionIds = array_values(array_unique(array_filter(array_map('intval', $sectionIds))));
+ if (empty($sectionIds)) {
+ return [0, []];
+ }
+
+ $semesterResolver = new SemesterRangeService($this->configModel);
+ $schoolYear = (string)($this->configModel->getConfig('school_year') ?? '');
+ $semester = (string)($this->configModel->getConfig('semester') ?? '');
+ $schoolYearForRange = $schoolYear !== '' ? $schoolYear : (string)($this->configModel->getConfig('school_year') ?? '');
+ [$rangeStart, $rangeEnd] = $semesterResolver->getSchoolYearRange($schoolYearForRange);
+ $semesterNorm = $semesterResolver->normalizeSemester($semester);
+ if ($semesterNorm !== '' && $schoolYearForRange !== '') {
+ $semRange = $semesterResolver->getSemesterRange($schoolYearForRange, $semesterNorm);
+ if ($semRange) {
+ [$rangeStart, $rangeEnd] = $semRange;
+ }
+ }
+
+ $dateList = [];
+ try {
+ $start = new \DateTimeImmutable($rangeStart);
+ $end = new \DateTimeImmutable($rangeEnd);
+ $cursor = $start;
+ $w = (int) $cursor->format('w');
+ if ($w !== 0) {
+ $cursor = $cursor->modify('next sunday');
+ }
+ while ($cursor <= $end) {
+ $dateList[] = $cursor->format('Y-m-d');
+ $cursor = $cursor->modify('+7 days');
+ }
+ } catch (\Throwable $e) {
+ $dateList = [];
+ }
+
+ $noSchoolDays = [];
+ $events = [];
+ try {
+ $calendarModel = new \App\Models\CalendarModel();
+ $events = $calendarModel->getEvents();
+ } catch (\Throwable $e) {
+ $events = [];
+ }
+ foreach ($events as $event) {
+ $d = substr((string) ($event['date'] ?? ''), 0, 10);
+ if ($d === '' || empty($event['no_school'])) {
+ continue;
+ }
+ if ($d < $rangeStart || $d > $rangeEnd) {
+ continue;
+ }
+ $eventYear = trim((string) ($event['school_year'] ?? ''));
+ if ($schoolYearForRange !== '' && $eventYear !== '' && $eventYear !== $schoolYearForRange) {
+ continue;
+ }
+ $noSchoolDays[$d] = true;
+ }
+
+ $anchorSundayYmd = '';
+ try {
+ $tzName = (string) (config('School')->attendance['timezone'] ?? user_timezone());
+ $tzObj = new \DateTimeZone($tzName ?: 'UTC');
+ } catch (\Throwable $e) {
+ try {
+ $tzObj = new \DateTimeZone(user_timezone() ?: 'UTC');
+ } catch (\Throwable $e2) {
+ $tzObj = new \DateTimeZone('UTC');
+ }
+ }
+ try {
+ $nowDate = new \DateTime('now', $tzObj);
+ } catch (\Throwable $e) {
+ $nowDate = new \DateTime('now');
+ }
+ $weekday = (int) $nowDate->format('w');
+ $anchorSundayYmd = $weekday === 0
+ ? $nowDate->format('Y-m-d')
+ : $nowDate->modify('next sunday')->format('Y-m-d');
+
+ $activeDatesSet = [];
+ if (! empty($dateList) && $anchorSundayYmd !== '') {
+ foreach ($dateList as $d) {
+ if ($d <= $anchorSundayYmd && empty($noSchoolDays[$d])) {
+ $activeDatesSet[$d] = true;
+ }
+ }
+ }
+ $expectedWeeks = count($activeDatesSet);
+ if ($expectedWeeks === 0) {
+ return [0, []];
+ }
+
+ $builder = $this->db->table('class_progress_reports')
+ ->select('class_section_id, week_start')
+ ->whereIn('class_section_id', $sectionIds);
+ if (! empty($activeDatesSet)) {
+ $builder->whereIn('week_start', array_keys($activeDatesSet));
+ }
+ $rows = $builder->get()->getResultArray();
+
+ $submittedBySection = [];
+ foreach ($rows as $row) {
+ $sectionId = (int) ($row['class_section_id'] ?? 0);
+ $weekStart = (string) ($row['week_start'] ?? '');
+ if ($sectionId === 0 || $weekStart === '' || empty($activeDatesSet[$weekStart])) {
+ continue;
+ }
+ $submittedBySection[$sectionId][$weekStart] = true;
+ }
+
+ $counts = [];
+ foreach ($sectionIds as $sectionId) {
+ $counts[$sectionId] = isset($submittedBySection[$sectionId])
+ ? count($submittedBySection[$sectionId])
+ : 0;
+ }
+
+ return [$expectedWeeks, $counts];
+ }
+
public function sendTeacherSubmissionNotifications()
{$notify = $this->request->getPost('notify');
if (!is_array($notify)) {
return redirect()->back()->with('info', 'Select at least one teacher to notify.');
}
+ $semester = (string)($this->configModel->getConfig('semester') ?? $this->semester ?? '');
$missingItemsPayload = $this->request->getPost('missing_items') ?? [];
+ $homeworkNotifyAll = (bool) $this->request->getPost('homework_notify_all');
+ $examTerm = $this->resolveExamTermLabel($semester);
+ $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
+ $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
+ $forcedItems = [];
+ if ($this->request->getPost('notify_midterm_score')) {
+ $forcedItems[] = $examScoreLabel;
+ }
+ if ($this->request->getPost('notify_midterm_comment')) {
+ $forcedItems[] = $examCommentLabel;
+ }
+ if ($this->request->getPost('notify_participation')) {
+ $forcedItems[] = 'participation';
+ }
+ if ($this->request->getPost('notify_ptap_comment')) {
+ $forcedItems[] = 'PTAP comments';
+ }
+ if ($this->request->getPost('notify_class_progress')) {
+ $forcedItems[] = 'class progress';
+ }
+ if ($this->request->getPost('notify_exam_draft')) {
+ $forcedItems[] = 'exam draft';
+ }
$targets = [];
foreach ($notify as $sectionIdRaw => $teachers) {
@@ -1006,6 +1296,9 @@ class AdministratorController extends BaseController
$historyModel = new TeacherSubmissionNotificationHistoryModel();
$scoreUrl = site_url('/');
+ $progressUrl = site_url('teacher/progress/history');
+ $examDraftUrl = site_url('teacher/exam-drafts');
+ $homeworkUrl = site_url('teacher/addHomework');
$sentCount = 0;
$failCount = 0;
@@ -1021,6 +1314,13 @@ class AdministratorController extends BaseController
$subject = "Reminder: Complete submissions for {$sectionName}";
$missingPayload = $missingItemsPayload[$classSectionId][$teacherId] ?? '';
$missingItems = $this->parseMissingItemsPayload((string)$missingPayload);
+ $selectedItems = $forcedItems;
+ if ($homeworkNotifyAll && !in_array('homework', $selectedItems, true)) {
+ $selectedItems[] = 'homework';
+ }
+ if (!empty($selectedItems)) {
+ $missingItems = array_values(array_unique($selectedItems));
+ }
if (!empty($missingItems)) {
$missingText = htmlspecialchars(
$this->formatMissingItemsText($missingItems),
@@ -1033,10 +1333,45 @@ class AdministratorController extends BaseController
}
$subject = "Reminder: Complete submissions for {$sectionName}";
+ $progressNote = '';
+ if (in_array('class progress', $missingItems, true)) {
+ $progressNote = "
Class progress submissions can be updated at Teacher Progress History.
";
+ }
+ $examDraftNote = '';
+ if (in_array('exam draft', $missingItems, true)) {
+ $semesterLabel = strtolower(trim((string) $semester));
+ if ($semesterLabel === 'fall') {
+ $draftLabel = 'midterm exam draft';
+ } elseif ($semesterLabel === 'spring') {
+ $draftLabel = 'final exam draft';
+ } else {
+ $draftLabel = 'exam draft';
+ }
+ $examDraftNote = "" . ucfirst($draftLabel) . " submissions can be updated at Teacher Exam Drafts.
";
+ }
+ $homeworkNote = '';
+ if (in_array('homework', $missingItems, true)) {
+ $homeworkNote = "Homework scores can be submitted at Teacher Homework.
";
+ }
+ $hasScoreItems = (bool) array_intersect($missingItems, [
+ 'midterm scores',
+ 'midterm comments',
+ 'final scores',
+ 'final comments',
+ 'participation',
+ 'PTAP comments',
+ 'homework',
+ ]);
+ $nonScoreOnly = ! empty($missingItems) && ! $hasScoreItems;
$body = "Dear {$teacherName},
"
- . "Administration is gently reminding you to wrap up any remaining score submissions and/or comments for {$sectionName}.
"
+ . "Administration is gently reminding you to wrap up any remaining "
+ . ($nonScoreOnly ? "submissions for {$sectionName}." : "score submissions, comments, and related items for {$sectionName}.")
+ . "
"
. $missingNote
- . "Visit Teacher Score Submission to address any remaining items.
"
+ . $progressNote
+ . $examDraftNote
+ . $homeworkNote
+ . ($nonScoreOnly ? '' : "Visit Teacher Score Submission to address any remaining items.
")
. "Thank you,
Al Rahma Administration
";
$email = $teacher['email'] ?? '';
@@ -1098,6 +1433,116 @@ class AdministratorController extends BaseController
];
}
+ private function progressStatus(int $submitted, int $expected): array
+ {
+ if ($expected <= 0) {
+ return [
+ 'label' => 'N/A',
+ 'badge' => 'bg-secondary',
+ 'detail' => '',
+ 'completed' => true,
+ ];
+ }
+ $completed = $submitted >= $expected;
+ return [
+ 'label' => $completed ? 'Submitted' : 'Missing',
+ 'badge' => $completed ? 'bg-success' : 'bg-danger',
+ 'detail' => "{$submitted}/{$expected}",
+ 'completed' => $completed,
+ ];
+ }
+
+ private function homeworkStatus(int $submitted): array
+ {
+ $completed = $submitted > 0;
+ return [
+ 'label' => $completed ? 'Submitted' : 'Missing',
+ 'badge' => $completed ? 'bg-success' : 'bg-danger',
+ 'detail' => $completed ? (string) $submitted : '0',
+ 'completed' => $completed,
+ ];
+ }
+
+ private function draftStatus(int $submitted, ?\DateTimeImmutable $deadline): array
+ {
+ if ($deadline !== null) {
+ $today = new \DateTimeImmutable('today');
+ if ($today < $deadline) {
+ return [
+ 'label' => 'Pending',
+ 'badge' => 'bg-secondary',
+ 'detail' => 'Not due',
+ 'completed' => true,
+ ];
+ }
+ }
+ $completed = $submitted > 0;
+ return [
+ 'label' => $completed ? 'Submitted' : 'Missing',
+ 'badge' => $completed ? 'bg-success' : 'bg-danger',
+ 'detail' => $completed ? (string) $submitted : '0',
+ 'completed' => $completed,
+ ];
+ }
+
+ private function resolveExamDraftDeadline(string $semester, string $schoolYear): ?\DateTimeImmutable
+ {
+ $semesterKey = strtolower(trim($semester));
+ if ($semesterKey === 'fall') {
+ $deadlineValue = (string)($this->configModel->getConfig('fall_exam_deadline') ?? '');
+ } elseif ($semesterKey === 'spring') {
+ $deadlineValue = (string)($this->configModel->getConfig('spring_exam_deadline') ?? '');
+ } else {
+ return null;
+ }
+ $deadlineValue = trim($deadlineValue);
+ if ($deadlineValue === '') {
+ return null;
+ }
+ try {
+ $deadline = new \DateTimeImmutable($deadlineValue);
+ } catch (\Throwable $e) {
+ return null;
+ }
+ if ($schoolYear !== '' && preg_match('/^\d{4}-\d{4}$/', $schoolYear)) {
+ $deadlineYear = $deadline->format('Y');
+ if ($deadlineYear === '1970') {
+ return null;
+ }
+ }
+ return $deadline->setTime(0, 0, 0);
+ }
+
+ private function resolveExamTermLabel(string $semester): string
+ {
+ $semesterKey = strtolower(trim($semester));
+ if ($semesterKey === '') {
+ return 'midterm';
+ }
+ if (str_contains($semesterKey, 'spring')) {
+ return 'final';
+ }
+ if (str_contains($semesterKey, 'fall')) {
+ return 'midterm';
+ }
+ return 'midterm';
+ }
+
+ private function buildSemesterCandidates(string $semester): array
+ {
+ $semester = trim((string) $semester);
+ if ($semester === '') {
+ return [];
+ }
+ $candidates = [
+ $semester,
+ strtolower($semester),
+ strtoupper($semester),
+ ucfirst(strtolower($semester)),
+ ];
+ $candidates = array_values(array_unique(array_filter($candidates, static fn ($v) => $v !== '')));
+ return $candidates;
+ }
private function attendanceStatus(bool $submitted): array
{
return [
@@ -1107,14 +1552,20 @@ class AdministratorController extends BaseController
];
}
- private function buildMissingItems(array $statusMap): array
+ private function buildMissingItems(array $statusMap, string $semester): array
{
+ $examTerm = $this->resolveExamTermLabel($semester);
+ $examScoreLabel = $examTerm === 'final' ? 'final scores' : 'midterm scores';
+ $examCommentLabel = $examTerm === 'final' ? 'final comments' : 'midterm comments';
$labels = [
- 'midterm_score_status' => 'midterm scores',
- 'midterm_comment_status' => 'midterm comments',
+ 'midterm_score_status' => $examScoreLabel,
+ 'midterm_comment_status' => $examCommentLabel,
'participation_status' => 'participation',
'ptap_comment_status' => 'PTAP comments',
'attendance_status' => 'attendance',
+ 'class_progress_status' => 'class progress',
+ 'exam_draft_status' => 'exam draft',
+ 'homework_status' => 'homework',
];
$items = [];
diff --git a/app/Controllers/View/AssignmentController.php b/app/Controllers/View/AssignmentController.php
index 491da9c..1a44497 100644
--- a/app/Controllers/View/AssignmentController.php
+++ b/app/Controllers/View/AssignmentController.php
@@ -119,7 +119,12 @@ class AssignmentController extends BaseController
}
$students = [];
+ $seenStudentIds = [];
foreach ($studentClasses as $studentClass) {
+ $sid = (int)($studentClass['student_id'] ?? 0);
+ if ($sid <= 0 || isset($seenStudentIds[$sid])) {
+ continue;
+ }
if ($sectionSemester === '' && !empty($studentClass['semester'])) {
$sectionSemester = (string)$studentClass['semester'];
}
@@ -149,6 +154,7 @@ class AssignmentController extends BaseController
'tuition_paid' => esc($student['tuition_paid'] ? 'Yes' : 'No'),
'school_id' => esc($student['school_id']),
];
+ $seenStudentIds[$sid] = true;
}
$sectionSemesterDisplay = $sectionSemester !== '' ? $sectionSemester : ((string)($this->semester ?? ''));
diff --git a/app/Controllers/View/AttendanceController.php b/app/Controllers/View/AttendanceController.php
index f3b0962..3fdb1d3 100644
--- a/app/Controllers/View/AttendanceController.php
+++ b/app/Controllers/View/AttendanceController.php
@@ -1004,9 +1004,13 @@ public function showUpdateAttendanceForm()
}
$hasRoster = false;
+ $seenStudents = [];
foreach ($students as $sc) {
$studentId = (int)$sc['student_id'];
+ if ($studentId <= 0 || isset($seenStudents[$studentId])) {
+ continue;
+ }
$student = $this->studentModel
->select('id, firstname, lastname, school_id')
->find($studentId);
@@ -1014,6 +1018,7 @@ public function showUpdateAttendanceForm()
$studentsBySection[$secCode][] = $student;
$hasRoster = true;
+ $seenStudents[$studentId] = true;
// Attendance history
$qb = $this->attendanceDataModel
diff --git a/app/Controllers/View/AuthorizedUsersController.php b/app/Controllers/View/AuthorizedUsersController.php
index 5e9ae4a..a6cc46c 100644
--- a/app/Controllers/View/AuthorizedUsersController.php
+++ b/app/Controllers/View/AuthorizedUsersController.php
@@ -10,6 +10,8 @@ use CodeIgniter\I18n\Time;
class AuthorizedUsersController extends ResourceController
{
+ private const TOKEN_TTL_HOURS = 24;
+
protected $userModel;
protected $authorizedUserModel;
@@ -18,6 +20,30 @@ class AuthorizedUsersController extends ResourceController
$this->userModel = new UserModel();
$this->authorizedUserModel = new AuthorizedUserModel();
}
+
+ private function requireLogin()
+ {
+ if (!session()->get('is_logged_in')) {
+ return $this->failUnauthorized('Authentication required.');
+ }
+
+ return null;
+ }
+
+ private function requireOwnership(array $authorizedUser)
+ {
+ $userId = (int) session()->get('user_id');
+ if ($userId <= 0 || (int) ($authorizedUser['user_id'] ?? 0) !== $userId) {
+ return $this->failForbidden('You do not have access to this resource.');
+ }
+
+ return null;
+ }
+
+ private function hashToken(string $token): string
+ {
+ return hash('sha256', $token);
+ }
/**
* Return a list of authorized users for the logged-in main user.
*
@@ -25,7 +51,10 @@ class AuthorizedUsersController extends ResourceController
*/
public function index()
{
-
+ if ($resp = $this->requireLogin()) {
+ return $resp;
+ }
+
$userId = session()->get('user_id');
$authorizedUsers = $this->authorizedUserModel->where('user_id', $userId)->findAll();
@@ -40,12 +69,20 @@ class AuthorizedUsersController extends ResourceController
*/
public function show($id = null)
{
+ if ($resp = $this->requireLogin()) {
+ return $resp;
+ }
+
$authorizedUser = $this->authorizedUserModel->find($id);
if (!$authorizedUser) {
return $this->failNotFound('Authorized user not found.');
}
+ if ($resp = $this->requireOwnership($authorizedUser)) {
+ return $resp;
+ }
+
return $this->respond($authorizedUser);
}
@@ -56,6 +93,10 @@ class AuthorizedUsersController extends ResourceController
*/
public function create()
{
+ if ($resp = $this->requireLogin()) {
+ return $resp;
+ }
+
$email = strtolower($this->request->getPost('email'));
// Validate email
@@ -66,19 +107,20 @@ class AuthorizedUsersController extends ResourceController
$user = $this->userModel->where('email', $email)->first();
if (!$user) {
- return $this->failNotFound('No user found with this email.');
+ return $this->respondCreated(['message' => 'Authorized user added. A confirmation email has been sent.']);
}
// Generate a token for confirmation
helper('text');
$token = bin2hex(random_bytes(48));
+ $tokenHash = $this->hashToken($token);
// Add entry to the authorized_users table
$this->authorizedUserModel->insert([
'user_id' => session()->get('user_id'), // Main user ID
'authorized_user_id' => $user['id'],
'email' => $email,
- 'token' => $token,
+ 'token' => $tokenHash,
'status' => 'Pending'
]);
@@ -96,6 +138,10 @@ class AuthorizedUsersController extends ResourceController
*/
public function update($id = null)
{
+ if ($resp = $this->requireLogin()) {
+ return $resp;
+ }
+
// Fetch the authorized user
$authorizedUser = $this->authorizedUserModel->find($id);
@@ -103,6 +149,10 @@ class AuthorizedUsersController extends ResourceController
return $this->failNotFound('Authorized user not found.');
}
+ if ($resp = $this->requireOwnership($authorizedUser)) {
+ return $resp;
+ }
+
// Update the authorized user’s information (e.g., email)
$email = strtolower($this->request->getPost('email'));
if ($email && filter_var($email, FILTER_VALIDATE_EMAIL)) {
@@ -122,12 +172,20 @@ class AuthorizedUsersController extends ResourceController
*/
public function delete($id = null)
{
+ if ($resp = $this->requireLogin()) {
+ return $resp;
+ }
+
$authorizedUser = $this->authorizedUserModel->find($id);
if (!$authorizedUser) {
return $this->failNotFound('Authorized user not found.');
}
+ if ($resp = $this->requireOwnership($authorizedUser)) {
+ return $resp;
+ }
+
// Delete the authorized user record
$this->authorizedUserModel->delete($id);
@@ -147,16 +205,28 @@ class AuthorizedUsersController extends ResourceController
return $this->fail('Invalid confirmation link.');
}
- $authorizedUser = $this->authorizedUserModel->where('token', $token)->first();
+ $tokenHash = $this->hashToken($token);
+ $authorizedUser = $this->authorizedUserModel
+ ->groupStart()
+ ->where('token', $tokenHash)
+ ->orWhere('token', $token)
+ ->groupEnd()
+ ->where('created_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString())
+ ->first();
if (!$authorizedUser) {
return $this->fail('Invalid or expired confirmation link.');
}
- // Mark the authorized user as active
- $this->authorizedUserModel->update($authorizedUser['id'], ['status' => 'Active', 'token' => null]);
+ // Mark the authorized user as active and rotate token for password setup
+ $nextToken = bin2hex(random_bytes(48));
+ $nextTokenHash = $this->hashToken($nextToken);
+ $this->authorizedUserModel->update($authorizedUser['id'], [
+ 'status' => 'Active',
+ 'token' => $nextTokenHash,
+ ]);
- return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id']);
+ return redirect()->to('/set_authorized_user_password/' . $authorizedUser['authorized_user_id'] . '?token=' . $nextToken);
}
/**
@@ -167,13 +237,36 @@ class AuthorizedUsersController extends ResourceController
*/
public function setPassword($authorizedUserId)
{
+ $token = (string) $this->request->getGet('token');
+ if ($token === '') {
+ return $this->fail('Invalid confirmation link.');
+ }
+
+ $tokenHash = $this->hashToken($token);
+ $authorizedUser = $this->authorizedUserModel
+ ->groupStart()
+ ->where('token', $tokenHash)
+ ->orWhere('token', $token)
+ ->groupEnd()
+ ->where('authorized_user_id', $authorizedUserId)
+ ->where('status', 'Active')
+ ->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString())
+ ->first();
+
+ if (!$authorizedUser) {
+ return $this->fail('Invalid or expired confirmation link.');
+ }
+
$user = $this->userModel->find($authorizedUserId);
if (!$user) {
return $this->failNotFound('User not found.');
}
- return view('user/set_authorized_user_password', ['userId' => $authorizedUserId]);
+ return view('user/set_authorized_user_password', [
+ 'userId' => $authorizedUserId,
+ 'token' => $token,
+ ]);
}
/**
@@ -181,38 +274,59 @@ class AuthorizedUsersController extends ResourceController
*
* @return ResponseInterface
*/
- /*
- public function savePassword()
+ public function savePassword($authorizedUserId = null)
{
- // Validate the request
$validation = \Config\Services::validation();
$validation->setRules([
- 'password' => 'required|min_length[6]',
+ 'password' => [
+ 'label' => 'Password',
+ 'rules' => 'required|min_length[8]|regex_match[/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@\\-=\\+*#$%&!?])[A-Za-z\\d@\\-=\\+*#$%&!?]{8,}$/]',
+ ],
'password_confirm' => 'required|matches[password]',
- 'user_id' => 'required|integer'
+ 'user_id' => 'required|integer',
+ 'token' => 'required',
]);
if (!$this->validate($validation->getRules())) {
return $this->failValidationErrors($validation->getErrors());
}
- // Get the validated input
- $userId = $this->request->getPost('user_id');
- $password = $this->request->getPost('password');
+ $userId = (int) $this->request->getPost('user_id');
+ $token = (string) $this->request->getPost('token');
+ $authorizedUserId = $authorizedUserId !== null ? (int) $authorizedUserId : $userId;
- $model = new UserModel();
- $user = $model->find($userId);
+ if ($userId <= 0 || $authorizedUserId <= 0 || $userId !== $authorizedUserId) {
+ return $this->fail('Invalid request.');
+ }
+ $tokenHash = $this->hashToken($token);
+ $authorizedUser = $this->authorizedUserModel
+ ->groupStart()
+ ->where('token', $tokenHash)
+ ->orWhere('token', $token)
+ ->groupEnd()
+ ->where('authorized_user_id', $authorizedUserId)
+ ->where('status', 'Active')
+ ->where('updated_at >=', Time::now()->subHours(self::TOKEN_TTL_HOURS)->toDateTimeString())
+ ->first();
+
+ if (!$authorizedUser) {
+ return $this->fail('Invalid or expired confirmation link.');
+ }
+
+ $user = $this->userModel->find($authorizedUserId);
if (!$user) {
return $this->failNotFound('User not found.');
}
- // Save the password
- $model->update($userId, ['password' => password_hash($password, PASSWORD_DEFAULT)]);
+ $password = (string) $this->request->getPost('password');
+ $hashedPassword = pbkdf2_hash($password);
+
+ $this->userModel->update($authorizedUserId, ['password' => $hashedPassword]);
+ $this->authorizedUserModel->update($authorizedUser['id'], ['token' => null]);
return $this->respond(['message' => 'Password has been successfully set.']);
}
-*/
/**
* Sends a confirmation email to the authorized user.
*
@@ -242,4 +356,4 @@ class AuthorizedUsersController extends ResourceController
log_message('error', 'Failed to send authorized user confirmation email to ' . $email);
}
}
-}
\ No newline at end of file
+}
diff --git a/app/Controllers/View/ExamDraftController.php b/app/Controllers/View/ExamDraftController.php
index f95e7a7..43f167a 100644
--- a/app/Controllers/View/ExamDraftController.php
+++ b/app/Controllers/View/ExamDraftController.php
@@ -240,23 +240,23 @@ class ExamDraftController extends BaseController
// Group legacy uploads (admin-uploaded finalized exams) by class_section for separate tab
$legacyByClass = [];
if ($this->hasIsLegacyColumn) {
- // Keep all submissions visible; additionally surface legacy items in a separate tab
- $drafts = $allDrafts;
-
+ // Keep legacy items out of the main submissions list; show them in the legacy tab only.
+ $drafts = [];
foreach ($allDrafts as $d) {
$isLegacy = !empty($d['is_legacy']);
- if (!$isLegacy) {
+ if ($isLegacy) {
+ $cid = (int)($d['class_section_id'] ?? 0);
+ if (!isset($legacyByClass[$cid])) {
+ $legacyByClass[$cid] = [
+ 'class_section_id' => $cid,
+ 'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid,
+ 'items' => [],
+ ];
+ }
+ $legacyByClass[$cid]['items'][] = $d;
continue;
}
- $cid = (int)($d['class_section_id'] ?? 0);
- if (!isset($legacyByClass[$cid])) {
- $legacyByClass[$cid] = [
- 'class_section_id' => $cid,
- 'class_section_name' => $d['class_section_name'] ?? 'Class ' . $cid,
- 'items' => [],
- ];
- }
- $legacyByClass[$cid]['items'][] = $d;
+ $drafts[] = $d;
}
} else {
// Column missing: keep behavior simple and avoid legacy tab
@@ -284,13 +284,17 @@ class ExamDraftController extends BaseController
return redirect()->to('/login');
}
- $classSectionId = (int) ($this->request->getPost('class_section_id') ?? 0);
+ $classSectionIds = $this->request->getPost('class_section_ids');
+ if (!is_array($classSectionIds)) {
+ $classSectionIds = [$classSectionIds];
+ }
+ $classSectionIds = array_values(array_filter(array_map('intval', $classSectionIds)));
$schoolYear = trim((string) ($this->request->getPost('school_year') ?? $this->schoolYear));
$semester = trim((string) ($this->request->getPost('semester') ?? $this->semester));
$examType = trim((string) $this->request->getPost('exam_type'));
- if ($classSectionId <= 0) {
- return redirect()->back()->withInput()->with('error', 'Select a class section.');
+ if (empty($classSectionIds)) {
+ return redirect()->back()->withInput()->with('error', 'Select at least one class section.');
}
if ($schoolYear === '') {
return redirect()->back()->withInput()->with('error', 'School year is required.');
@@ -309,9 +313,17 @@ class ExamDraftController extends BaseController
return redirect()->back()->withInput()->with('error', 'File type not allowed or upload failed.');
}
- $payload = [
+ $pdfName = null;
+ if (strtolower($file->getClientExtension()) === 'pdf') {
+ $pdfName = $stored;
+ } else {
+ $pdfName = $this->convertDocToPdf(
+ $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $stored),
+ self::FINAL_UPLOAD_DIR
+ );
+ }
+ $basePayload = [
'teacher_id' => $adminId, // store under admin user since legacy uploads are admin-only
- 'class_section_id' => $classSectionId,
'semester' => ucfirst(strtolower($semester)),
'school_year' => $schoolYear,
'exam_type' => $examType === '' ? null : $examType,
@@ -325,23 +337,22 @@ class ExamDraftController extends BaseController
'version' => 1,
];
if ($this->hasIsLegacyColumn) {
- $payload['is_legacy'] = 1;
- }
-
- $pdfName = null;
- if (strtolower($file->getClientExtension()) === 'pdf') {
- $pdfName = $stored;
- } else {
- $pdfName = $this->convertDocToPdf(
- $this->fullUploadPath(self::FINAL_UPLOAD_DIR, $stored),
- self::FINAL_UPLOAD_DIR
- );
+ $basePayload['is_legacy'] = 1;
}
if ($pdfName !== null && $this->hasFinalPdfColumn) {
- $payload['final_pdf_file'] = $pdfName;
+ $basePayload['final_pdf_file'] = $pdfName;
}
- if ($this->examDraftModel->insert($payload)) {
+ $saved = 0;
+ foreach ($classSectionIds as $classSectionId) {
+ $payload = $basePayload;
+ $payload['class_section_id'] = $classSectionId;
+ if ($this->examDraftModel->insert($payload)) {
+ $saved++;
+ }
+ }
+
+ if ($saved > 0) {
return redirect()->to('/administrator/exam-drafts')->with('success', 'Old exam uploaded successfully.');
}
diff --git a/app/Controllers/View/FlagController.php b/app/Controllers/View/FlagController.php
index 6e6e7ef..6a62edc 100644
--- a/app/Controllers/View/FlagController.php
+++ b/app/Controllers/View/FlagController.php
@@ -421,17 +421,45 @@ class FlagController extends Controller
log_message('debug', 'Flag state: ' . $this->request->getPost('flag_state'));
$currentFlagModel = new CurrentFlagModel();
+ $userId = session()->get('user_id');
// Get the new flag state from the form
$newState = $this->request->getPost('flag_state');
+ $stateDescription = (string) ($this->request->getPost('state_description') ?? '');
+ $actionTaken = (string) ($this->request->getPost('action_taken') ?? '');
if (!$newState) {
session()->setFlashdata('error', 'incident state not provided.');
return $this->index();
}
+ $update = ['flag_state' => $newState];
+ if ($newState === 'Closed') {
+ $update['updated_by_closed'] = $userId;
+ if ($stateDescription !== '') {
+ $update['close_description'] = $stateDescription;
+ }
+ if ($actionTaken !== '') {
+ $update['action_taken'] = $actionTaken;
+ }
+ } elseif ($newState === 'Canceled') {
+ $update['updated_by_canceled'] = $userId;
+ if ($stateDescription !== '') {
+ $update['cancel_description'] = $stateDescription;
+ }
+ if ($actionTaken !== '') {
+ $update['action_taken'] = $actionTaken;
+ }
+ }
+
// Update the flag state in the database
- if ($currentFlagModel->update($id, ['flag_state' => $newState])) {
+ if ($currentFlagModel->update($id, $update)) {
+ if ($newState === 'Closed' || $newState === 'Canceled') {
+ $flagData = $currentFlagModel->find($id);
+ if ($flagData) {
+ return $this->moveToHistory($flagData);
+ }
+ }
session()->setFlashdata('success', 'Incident state updated successfully!');
} else {
$errors = $currentFlagModel->errors();
diff --git a/app/Controllers/View/GradingController.php b/app/Controllers/View/GradingController.php
index f323d6e..9d385c0 100644
--- a/app/Controllers/View/GradingController.php
+++ b/app/Controllers/View/GradingController.php
@@ -278,7 +278,7 @@ class GradingController extends Controller
$semEsc = $this->db->escape($semester);
$yrEsc = $this->db->escape($schoolYear);
- $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear);
+ $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester);
// Preload quiz/homework/project/participation/midterm score counts to distinguish true zeros from empty scores
$quizCounts = [];
@@ -424,7 +424,7 @@ class GradingController extends Controller
}
}
// Reload rows after refresh
- $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear);
+ $rows = $this->buildGradingRows($semEsc, $yrEsc, $schoolYear, $semester);
}
// Build structures keyed by BUSINESS section id
@@ -1216,6 +1216,14 @@ class GradingController extends Controller
$flagModel = new CurrentFlagModel();
$semKey = strtolower(trim($semester));
+ $redirectUrl = base_url('grading/below-60');
+ $query = http_build_query([
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ ]);
+ if ($query !== '') {
+ $redirectUrl .= '?' . $query;
+ }
$existing = $flagModel
->where('student_id', $studentId)
@@ -1226,11 +1234,14 @@ class GradingController extends Controller
$userId = (int)(session()->get('user_id') ?? 0) ?: null;
$now = utc_now();
+ $ok = true;
if ($existing) {
$data = [
'flag_state' => $status,
'flag_datetime' => $now,
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
'updated_at' => $now,
];
if ($status === 'Open') {
@@ -1246,7 +1257,7 @@ class GradingController extends Controller
$data['close_description'] = trim($prev . PHP_EOL . $note);
}
}
- $flagModel->update((int)$existing['id'], $data);
+ $ok = (bool) $flagModel->update((int)$existing['id'], $data);
} else {
$row = $this->fetchBelowSixtyEmailRow($studentId, $schoolYear, $semester);
$studentName = trim((string)($row['firstname'] ?? '') . ' ' . (string)($row['lastname'] ?? ''));
@@ -1269,10 +1280,21 @@ class GradingController extends Controller
$data['updated_by_closed'] = $userId;
if ($note !== '') $data['close_description'] = $note;
}
- $flagModel->insert($data);
+ $ok = (bool) $flagModel->insert($data);
}
- return redirect()->back()->with('status', 'Status updated.');
+ if (!$ok) {
+ log_message('error', 'updateBelowSixtyStatus failed', [
+ 'student_id' => $studentId,
+ 'semester' => $semester,
+ 'school_year' => $schoolYear,
+ 'status' => $status,
+ 'errors' => $flagModel->errors(),
+ ]);
+ return redirect()->to($redirectUrl)->with('error', 'Failed to update status.');
+ }
+
+ return redirect()->to($redirectUrl)->with('status', 'Status updated.');
}
public function scheduleBelowSixty()
@@ -1555,7 +1577,7 @@ class GradingController extends Controller
* @param string $schoolYear Raw school year value for filtering student_class
* @return array
*/
- private function buildGradingRows(string $semEsc, string $yrEsc, string $schoolYear): array
+ private function buildGradingRows(string $semEsc, string $yrEsc, string $schoolYear, string $semesterRaw): array
{
$builder = $this->db->table('student_class sc')
->select([
@@ -1583,6 +1605,7 @@ class GradingController extends Controller
'ss_b.class_section_id AS matched_biz_csid',
'ss_p.class_section_id AS matched_pk_csid'
])
+ ->distinct()
->join('`classSection` cs', 'cs.class_section_id = sc.class_section_id', 'left')
->join('students s', 's.id = sc.student_id', 'inner')
->join(
@@ -1797,9 +1820,10 @@ class GradingController extends Controller
}
$statusMap = [];
+ $noteMap = [];
if (!empty($studentIds)) {
$flagRows = $this->db->table('current_flag')
- ->select('student_id, flag_state')
+ ->select('student_id, flag_state, open_description, close_description')
->where('flag', 'grade')
->where('school_year', $schoolYear)
->where("LOWER(TRIM(semester))", $semesterKey)
@@ -1810,6 +1834,12 @@ class GradingController extends Controller
$sid = (int)($row['student_id'] ?? 0);
if ($sid <= 0) continue;
$statusMap[$sid] = (string)($row['flag_state'] ?? '');
+ $openNote = trim((string)($row['open_description'] ?? ''));
+ $closeNote = trim((string)($row['close_description'] ?? ''));
+ $noteMap[$sid] = [
+ 'open' => $openNote,
+ 'closed' => $closeNote,
+ ];
}
}
@@ -1818,6 +1848,15 @@ class GradingController extends Controller
$row['comment'] = $commentMap[$sid] ?? '';
$flagState = strtolower(trim((string)($statusMap[$sid] ?? '')));
$row['status'] = ($flagState === 'closed' || $flagState === 'canceled') ? 'Closed' : 'Open';
+ $noteBag = $noteMap[$sid] ?? ['open' => '', 'closed' => ''];
+ $rawNote = $row['status'] === 'Closed' ? (string)$noteBag['closed'] : (string)$noteBag['open'];
+ if ($rawNote !== '') {
+ $lines = preg_split('/\R/', $rawNote);
+ $lines = array_values(array_filter(array_map('trim', $lines), static fn($val) => $val !== ''));
+ $row['note'] = $lines ? end($lines) : '';
+ } else {
+ $row['note'] = '';
+ }
}
unset($row);
diff --git a/app/Controllers/View/HomeworkController.php b/app/Controllers/View/HomeworkController.php
index 47594e7..b0a238d 100644
--- a/app/Controllers/View/HomeworkController.php
+++ b/app/Controllers/View/HomeworkController.php
@@ -482,6 +482,7 @@ class HomeworkController extends Controller
// Step 1: Get student IDs from student_class table
$studentClassRows = $this->studentClassModel
->select('student_id')
+ ->distinct()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->findAll();
@@ -534,10 +535,7 @@ class HomeworkController extends Controller
private function getStudentsWithHomeworkScores($classSectionId, $homeworkHeaders, $semester, $schoolYear)
{
$semVariants = $this->getSemesterVariants($semester);
- $studentClasses = $this->studentClassModel
- ->active()
- ->where('student_class.class_section_id', $classSectionId)
- ->findAll();
+ $studentClasses = $this->studentClassModel->getClassStudents($classSectionId, $schoolYear, null);
$students = [];
foreach ($studentClasses as $sc) {
diff --git a/app/Controllers/View/ParentController.php b/app/Controllers/View/ParentController.php
index d563aa7..027d08f 100644
--- a/app/Controllers/View/ParentController.php
+++ b/app/Controllers/View/ParentController.php
@@ -717,6 +717,7 @@ class ParentController extends BaseController
// Step 1: Generate a secure token for email verification
$token = bin2hex(random_bytes(48));
+ $tokenHash = hash('sha256', $token);
// Step 2: Determine user type based on relationship
$userType = in_array(strtolower($relationToStudent), ['wife', 'husband']) ? 'Secondary' : 'Tertiary';
@@ -776,7 +777,7 @@ class ParentController extends BaseController
'state' => strtoupper($userData['state']),
'zip' => $userData['zip'],
'accept_school_policy' => $userData['accept_school_policy'] ?? 0,
- 'token' => $token,
+ 'token' => $tokenHash,
'is_verified' => 0,
'status' => 'Inactive',
'user_type' => $userType,
diff --git a/app/Controllers/View/ProjectController.php b/app/Controllers/View/ProjectController.php
index 21eea66..69c2901 100644
--- a/app/Controllers/View/ProjectController.php
+++ b/app/Controllers/View/ProjectController.php
@@ -438,6 +438,7 @@ class ProjectController extends Controller
// Step 1: Get student IDs from student_class table
$studentClassRows = $studentClassModel
->select('student_id')
+ ->distinct()
->where('class_section_id', $classSectionId)
->where('school_year', $schoolYear)
->findAll();
@@ -494,10 +495,7 @@ class ProjectController extends Controller
$studentModel = new StudentModel();
$projectModel = new ProjectModel();
- $studentClasses = $studentClassModel
- ->active()
- ->where('student_class.class_section_id', $classSectionId)
- ->findAll();
+ $studentClasses = $studentClassModel->getClassStudents($classSectionId, $this->schoolYear, null);
$students = [];
foreach ($studentClasses as $sc) {
diff --git a/app/Controllers/View/RegisterController.php b/app/Controllers/View/RegisterController.php
index fe22344..4970690 100644
--- a/app/Controllers/View/RegisterController.php
+++ b/app/Controllers/View/RegisterController.php
@@ -173,16 +173,8 @@ class RegisterController extends Controller
$existingUser = $this->userModel->where('email', $post['email'])->first();
if ($existingUser) {
- // Step 2: Check if the user has a token (i.e., not verified yet)
- if (!empty($existingUser['token']) && $existingUser['is_verified'] == 0) {
- // User exists and is unverified
- return redirect()->back()->withInput()->with('error',
- 'This email address is already registered and is pending activation. Please check your email to activate your account.');
- } else {
- // User exists and is already active or has no token
- return redirect()->back()->withInput()->with('error',
- 'The email address you entered is already in use. Please try a different one.');
- }
+ return redirect()->back()->withInput()->with('error',
+ 'This email address is already registered. Please check your email or log in.');
}
/* ───────────── 6. Determine role ───────────── */
@@ -194,6 +186,7 @@ class RegisterController extends Controller
/* ───────────── 7. Build & insert user ───────────── */
$token = bin2hex(random_bytes(48));
+ $tokenHash = hash('sha256', $token);
$userData = [
'firstname' => $post['firstname'],
'lastname' => $post['lastname'],
@@ -205,7 +198,7 @@ class RegisterController extends Controller
'city' => $post['city'],
'state' => $post['state'],
'zip' => $post['zip'],
- 'token' => $token,
+ 'token' => $tokenHash,
'is_verified'=> 0,
'accept_school_policy' => (int) $post['accept_school_policy'],
'status' => 'Inactive',
@@ -357,4 +350,4 @@ class RegisterController extends Controller
-}
\ No newline at end of file
+}
diff --git a/app/Controllers/View/ScorePredictor.php b/app/Controllers/View/ScorePredictor.php
index a17d1ef..80d54f5 100644
--- a/app/Controllers/View/ScorePredictor.php
+++ b/app/Controllers/View/ScorePredictor.php
@@ -81,10 +81,10 @@ class ScorePredictor extends Controller
s.school_id,
s.firstname,
s.lastname,
- fall.semester_score as fall_score,
- spring.semester_score as spring_score');
- // Also select class section for per-class trophy decision
- $builder->select('sc.class_section_id as class_section_id');
+ MAX(fall.semester_score) as fall_score,
+ MAX(spring.semester_score) as spring_score');
+ // Reduce duplication from restored students while keeping a stable class section.
+ $builder->select('MAX(sc.class_section_id) as class_section_id');
$yearEsc = $this->db->escape($selectedYear);
$builder->join('student_class sc', 'sc.student_id = s.id AND sc.school_year = ' . $yearEsc, 'left');
$builder->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'left');
diff --git a/app/Controllers/View/SubjectCurriculumController.php b/app/Controllers/View/SubjectCurriculumController.php
index adcb695..2273b72 100644
--- a/app/Controllers/View/SubjectCurriculumController.php
+++ b/app/Controllers/View/SubjectCurriculumController.php
@@ -99,6 +99,7 @@ class SubjectCurriculumController extends BaseController
->orderBy('classes.class_name', 'ASC')
->orderBy('subject', 'ASC')
->orderBy('unit_number', 'ASC')
+ ->orderBy("CAST(SUBSTRING_INDEX(subject_curriculum_items.chapter_name, '.', 1) AS UNSIGNED)", 'ASC', false)
->orderBy('chapter_name', 'ASC')
->get()
->getResultArray();
diff --git a/app/Controllers/View/UserController.php b/app/Controllers/View/UserController.php
index f8d1c6f..2f7e64e 100644
--- a/app/Controllers/View/UserController.php
+++ b/app/Controllers/View/UserController.php
@@ -21,6 +21,7 @@ require_once APPPATH . 'Helpers/pbkdf2_helper.php';
class UserController extends BaseController
{
+ private const ACTIVATION_TTL_HOURS = 48;
protected $userModel;
protected $roleModel;
protected $userRoleModel;
@@ -49,6 +50,37 @@ class UserController extends BaseController
$this->resetRequestModel = new PasswordResetRequestModel();
}
+ private function denyAccess(string $message)
+ {
+ if ($this->request->isAJAX() || $this->request->getHeaderLine('Accept') === 'application/json') {
+ return service('response')
+ ->setStatusCode(403)
+ ->setJSON(['status' => 'error', 'message' => $message]);
+ }
+
+ session()->setFlashdata('error', $message);
+ return redirect()->to('/access_denied');
+ }
+
+ private function requirePermission(string $permission)
+ {
+ if (!session()->get('is_logged_in')) {
+ return redirect()->to('/login');
+ }
+
+ $userId = (int) session()->get('user_id');
+ if ($userId <= 0 || !has_permission($userId, $permission)) {
+ return $this->denyAccess("You don't have permission to use this feature.");
+ }
+
+ return null;
+ }
+
+ private function hashToken(string $token): string
+ {
+ return hash('sha256', $token);
+ }
+
// Method to show the home page
public function home()
{
@@ -75,6 +107,10 @@ class UserController extends BaseController
public function userList()
{
+ if ($resp = $this->requirePermission('read_user')) {
+ return $resp;
+ }
+
helper('url');
return view('user/user_list', [
@@ -85,6 +121,10 @@ class UserController extends BaseController
// Method to show the list of users
public function index()
{
+ if ($resp = $this->requirePermission('read_user')) {
+ return $resp;
+ }
+
// Fetch users along with their assigned roles
$builder = $this->db->table('users');
$builder->select('users.id, users.firstname, users.lastname, users.email, user_roles.role_id, roles.name as role, users.status, users.updated_at');
@@ -122,6 +162,10 @@ class UserController extends BaseController
public function userListData()
{
+ if ($resp = $this->requirePermission('read_user')) {
+ return $resp;
+ }
+
return $this->response->setJSON([
'users' => $this->buildUsersWithRoles(),
]);
@@ -253,6 +297,10 @@ class UserController extends BaseController
// Method to store a new user
public function store()
{
+ if ($resp = $this->requirePermission('edit_user')) {
+ return $resp;
+ }
+
// Validate input data
$validation = \Config\Services::validation();
$validation->setRules([
@@ -315,6 +363,10 @@ class UserController extends BaseController
// Method to show the form for editing an existing user
public function edit($id)
{
+ if ($resp = $this->requirePermission('edit_user')) {
+ return $resp;
+ }
+
$data['user'] = $this->userModel->find($id);
$data['roles'] = $this->roleModel->findAll();
$userRoles = $this->userRoleModel->where('user_id', $id)->findAll();
@@ -327,6 +379,10 @@ class UserController extends BaseController
// Method to delete an existing user
public function delete($id)
{
+ if ($resp = $this->requirePermission('edit_user')) {
+ return $resp;
+ }
+
$this->userModel->delete($id);
// Delete the user's roles from the user_roles table
@@ -369,27 +425,21 @@ class UserController extends BaseController
$email = strtolower($this->request->getPost('email'));
$user = $this->userModel->where('email', $email)->first();
- // --- Handle unknown email ---
- if (!$user) {
- session()->setFlashdata('error', 'If this email is registered, you will receive a reset link.');
- log_message('info', "Password reset requested for non-existing user {$email}");
- return redirect()->back();
- }
-
- // --- Handle unverified accounts ---
- if ((int) $user['is_verified'] === 0) {
- session()->setFlashdata('error', 'Please check your email and complete the account activation process before resetting your password.');
- log_message('info', "Password reset blocked for unverified user {$email}");
+ // --- Handle unknown or unverified email ---
+ if (!$user || (int) $user['is_verified'] === 0) {
+ session()->setFlashdata('success', 'If this email is registered, you will receive a reset link.');
+ log_message('info', "Password reset requested for {$email} (user missing or unverified).");
return redirect()->back();
}
// --- Verified user: continue with reset ---
$token = bin2hex(random_bytes(48));
+ $tokenHash = $this->hashToken($token);
$expires_at = Time::now()->addHours(1);
$this->passwordResetModel->insert([
'email' => $email,
- 'token' => $token,
+ 'token' => $tokenHash,
'created_at' => Time::now(),
'expires_at' => $expires_at,
]);
@@ -447,7 +497,12 @@ class UserController extends BaseController
}
// You may want to validate the token here
- $resetEntry = $this->passwordResetModel->where('token', $token)
+ $tokenHash = $this->hashToken($token);
+ $resetEntry = $this->passwordResetModel
+ ->groupStart()
+ ->where('token', $tokenHash)
+ ->orWhere('token', $token)
+ ->groupEnd()
->where('expires_at >=', Time::now())
->first();
@@ -462,6 +517,10 @@ class UserController extends BaseController
//This function processes the new password submission, validating the token, updating the user's password, and cleaning up the reset entry.
public function processResetPassword()
{
+ if (strtolower($this->request->getMethod()) !== 'post') {
+ return redirect()->to('/')->with('error', 'Invalid request.');
+ }
+
$token = $this->request->getPost('token');
$newPassword = $this->request->getPost('password');
$passConfirm = $this->request->getPost('pass_confirm');
@@ -490,7 +549,12 @@ class UserController extends BaseController
}
// Find the password reset entry
- $resetEntry = $this->passwordResetModel->where('token', $token)
+ $tokenHash = $this->hashToken($token);
+ $resetEntry = $this->passwordResetModel
+ ->groupStart()
+ ->where('token', $tokenHash)
+ ->orWhere('token', $token)
+ ->groupEnd()
->where('expires_at >=', Time::now())
->first();
@@ -519,7 +583,12 @@ class UserController extends BaseController
]);
// Delete the used token from the password reset table
- $this->passwordResetModel->where('token', $token)->delete();
+ $this->passwordResetModel
+ ->groupStart()
+ ->where('token', $tokenHash)
+ ->orWhere('token', $token)
+ ->groupEnd()
+ ->delete();
// Retrieve the user's IP address from the request
$ipAddress = $this->request->getIPAddress();
@@ -546,10 +615,16 @@ class UserController extends BaseController
public function confirm($token)
{
- log_message('info', 'Processing email confirmation with token: ' . $token);
+ log_message('info', 'Processing email confirmation.');
-
- $user = $this->userModel->where('token', $token)->first();
+ $tokenHash = $this->hashToken($token);
+ $user = $this->userModel
+ ->groupStart()
+ ->where('token', $tokenHash)
+ ->orWhere('token', $token)
+ ->groupEnd()
+ ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString())
+ ->first();
if (!$user || $user['is_verified'] == 1) {
return redirect()->to('/invalid_token');
@@ -570,7 +645,14 @@ class UserController extends BaseController
{
//echo "Reached setPassword with token: " . esc($token);
//echo "Token received: " . $token;
- $user = $this->userModel->where('token', $token)->first();
+ $tokenHash = $this->hashToken($token);
+ $user = $this->userModel
+ ->groupStart()
+ ->where('token', $tokenHash)
+ ->orWhere('token', $token)
+ ->groupEnd()
+ ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString())
+ ->first();
if (!$user || $user['is_verified'] == 1) {
return redirect()->to('/invalid_token');
@@ -584,6 +666,10 @@ class UserController extends BaseController
public function savePassword()
{
+ if (strtolower($this->request->getMethod()) !== 'post') {
+ return redirect()->to('/')->with('error', 'Invalid request.');
+ }
+
$validation = \Config\Services::validation();
$validation->setRules([
'password' => [
@@ -615,9 +701,17 @@ class UserController extends BaseController
$token = $this->request->getPost('token');
$password = $this->request->getPost('password');
- $user = $this->userModel->where('id', $userId)->where('token', $token)->first();
+ $tokenHash = $this->hashToken($token);
+ $user = $this->userModel
+ ->where('id', $userId)
+ ->groupStart()
+ ->where('token', $tokenHash)
+ ->orWhere('token', $token)
+ ->groupEnd()
+ ->where('created_at >=', Time::now()->subHours(self::ACTIVATION_TTL_HOURS)->toDateTimeString())
+ ->first();
- log_message('debug', "Attempting to set password for user $userId with token $token");
+ log_message('debug', "Attempting to set password for user $userId");
if (!$user || $user['is_verified'] == 1) {
return redirect()->to('/invalid_token');
@@ -670,20 +764,39 @@ class UserController extends BaseController
$roleKey = (string) $this->request->getPost('role');
log_message('info', 'Role selected: ' . $roleKey);
- $roleModel = new RoleModel();
- $route = $roleModel->getRouteByNameOrSlug($roleKey);
-
- if ($route === null) {
- log_message('error', 'Invalid or inactive role selected: ' . $roleKey);
- return redirect()->back()->with('error', 'Invalid role selected.');
- }
-
$userId = (int) session()->get('user_id');
log_message('info', 'User ID: ' . $userId);
+ if ($userId <= 0) {
+ return $this->denyAccess("You don't have permission to use this feature.");
+ }
+
+ $roleRow = $this->db->table('user_roles ur')
+ ->join('roles r', 'r.id = ur.role_id', 'inner')
+ ->select('r.name, r.slug, r.dashboard_route')
+ ->where('ur.user_id', $userId)
+ ->where('r.is_active', 1)
+ ->groupStart()
+ ->where('LOWER(r.name)', strtolower($roleKey))
+ ->orWhere('LOWER(r.slug)', strtolower($roleKey))
+ ->groupEnd()
+ ->get()
+ ->getRowArray();
+
+ if (empty($roleRow)) {
+ log_message('error', 'Invalid or unassigned role selected: ' . $roleKey);
+ return redirect()->back()->with('error', 'Invalid role selected.');
+ }
+
+ $route = $roleRow['dashboard_route'] ?? null;
+ if ($route === null) {
+ log_message('error', 'No dashboard route configured for role: ' . $roleKey);
+ return redirect()->back()->with('error', 'Invalid role selected.');
+ }
+
// Persist the *exact* role name or slug—choose your convention.
- // If you want to store the canonical name, fetch the row and use $row['name'].
- $this->userModel->update($userId, ['role' => $roleKey]);
+ // Store the canonical name to avoid arbitrary role strings.
+ $this->userModel->update($userId, ['role' => $roleRow['name']]);
log_message('info', 'Role updated in database.');
log_message('info', 'Redirecting to role dashboard: ' . $route);
@@ -702,6 +815,10 @@ class UserController extends BaseController
public function delete_role($roleId)
{
+ if ($resp = $this->requirePermission('edit_user')) {
+ return $resp;
+ }
+
// Fetch the role to be deleted
$role = $this->roleModel->find($roleId);
if (!$role) {
@@ -731,6 +848,10 @@ class UserController extends BaseController
public function loginActivity()
{
+ if ($resp = $this->requirePermission('view_login_activity')) {
+ return $resp;
+ }
+
helper('url');
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
@@ -743,6 +864,10 @@ class UserController extends BaseController
public function loginActivityData()
{
+ if ($resp = $this->requirePermission('view_login_activity')) {
+ return $resp;
+ }
+
$perPage = (int) ($this->request->getGet('per_page') ?? 25);
$page = (int) ($this->request->getGet('page') ?? 1);
@@ -752,6 +877,10 @@ class UserController extends BaseController
// Method to update an existing user
public function updateUser()
{
+ if ($resp = $this->requirePermission('edit_user')) {
+ return $resp;
+ }
+
if (strtolower($this->request->getMethod()) !== 'post') {
return redirect()->to(site_url('user/user_list'))->with('error', 'Invalid request.');
}
@@ -800,9 +929,6 @@ class UserController extends BaseController
'status' => trim((string)$this->request->getPost('status')),
'is_suspended' => $toBool('is_suspended'),
'is_verified' => $toBool('is_verified'),
- 'token' => trim((string)$this->request->getPost('token')),
- 'updated_at' => $toDT('updated_at'),
- 'created_at' => $toDT('created_at'),
];
// Validation
diff --git a/app/Models/ConfigurationModel.php b/app/Models/ConfigurationModel.php
index 86ca89b..0a6463d 100644
--- a/app/Models/ConfigurationModel.php
+++ b/app/Models/ConfigurationModel.php
@@ -22,11 +22,13 @@ class ConfigurationModel extends Model
*/
public function getConfigValueByKey(string $key)
{
- // Deterministic read in case historical duplicates exist
- $result = $this->where('config_key', $key)
+ // Use a fresh builder to avoid stale state from shared model builder.
+ $builder = $this->db->table($this->table);
+ $result = $builder->where('config_key', $key)
->orderBy('id', 'DESC')
- ->first();
- return $result ? $result['config_value'] : null;
+ ->get(1)
+ ->getRowArray();
+ return $result['config_value'] ?? null;
}
/**
diff --git a/app/Models/SubjectCurriculumModel.php b/app/Models/SubjectCurriculumModel.php
index 24a5196..d83e308 100644
--- a/app/Models/SubjectCurriculumModel.php
+++ b/app/Models/SubjectCurriculumModel.php
@@ -27,6 +27,7 @@ class SubjectCurriculumModel extends Model
return $this->where('class_id', $classId)
->where('subject', $subject)
->orderBy('unit_number', 'ASC')
+ ->orderBy("CAST(SUBSTRING_INDEX(chapter_name, '.', 1) AS UNSIGNED)", 'ASC', false)
->orderBy('chapter_name', 'ASC')
->findAll();
}
diff --git a/app/Services/TimeService.php b/app/Services/TimeService.php
index d805bbb..15cd02c 100644
--- a/app/Services/TimeService.php
+++ b/app/Services/TimeService.php
@@ -167,8 +167,13 @@ class TimeService
return null;
}
- $sourceTz = $sourceTz ?: $this->serverTimezone;
$targetTz = $targetTz ?: $this->userTimezone();
+ if ($sourceTz === null && $this->isDateOnlyString($value)) {
+ // Date-only strings should not shift across timezones.
+ $sourceTz = $targetTz;
+ } else {
+ $sourceTz = $sourceTz ?: $this->serverTimezone;
+ }
try {
if ($value instanceof Time) {
@@ -204,4 +209,14 @@ class TimeService
{
return (string) ($this->toUTC($value, $fromTz, $format) ?? '');
}
+
+ private function isDateOnlyString($value): bool
+ {
+ if (!is_string($value)) {
+ return false;
+ }
+
+ $value = trim($value);
+ return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value);
+ }
}
diff --git a/app/Views/admin/class_progress_list.php b/app/Views/admin/class_progress_list.php
index fcaea57..2fa881a 100644
--- a/app/Views/admin/class_progress_list.php
+++ b/app/Views/admin/class_progress_list.php
@@ -25,6 +25,23 @@
Class Progress Reports
Filter by week, class, and status
+
@@ -115,7 +132,7 @@
$submissionLabel = $expectedDays > 0
? ('Submitted: ' . (int) $stat['submitted'] . ' / ' . (int) $expectedDays . ' (' . $percentLabel . ')')
: 'Submitted: N/A';
- $subjectLabel = 'Subjects: ' . $subjectCount;
+ $subjectLabel = 'Units: ' . $subjectCount;
?>
- = esc($teacherName ?: '-') ?> |
+ = esc($teacherLabel) ?> |
View
diff --git a/app/Views/administrator/daily_attendance.php b/app/Views/administrator/daily_attendance.php
index c80d250..97e1485 100644
--- a/app/Views/administrator/daily_attendance.php
+++ b/app/Views/administrator/daily_attendance.php
@@ -696,7 +696,17 @@
|
-
+
+
$sections) {
+ foreach ($sections as $section) {
+ $sectionKey = (string)($section['class_section_id'] ?? ($section['id'] ?? ''));
+ if ($sectionKey === '') continue;
+ $secNameRaw = trim((string)($section['class_section_name'] ?? ''));
+ $sectionLabelByKey[$sectionKey] = $secNameRaw !== '' ? $secNameRaw : ('Section ' . $sectionKey);
+ }
+ }
+
+ $studentIssueRows = [];
+ foreach ($studentsBySection as $sectionKey => $students) {
+ foreach ($students as $stu) {
+ $sid = (int)($stu['id'] ?? 0);
+ if ($sid <= 0) continue;
+ $entries = $attendanceData[$sectionKey][$sid] ?? [];
+ if (!is_array($entries)) continue;
+ $abs = 0;
+ $late = 0;
+ foreach ($entries as $e) {
+ $d = substr((string)($e['date'] ?? ''), 0, 10);
+ if ($d === '') continue;
+ if ($filterStart !== '' && $d < $filterStart) continue;
+ if ($filterEnd !== '' && $d > $filterEnd) continue;
+ $st = strtolower(trim((string)($e['status'] ?? '')));
+ if ($st === 'absent') {
+ $abs++;
+ } elseif ($st === 'late') {
+ $late++;
+ }
+ }
+ if (($abs + $late) <= 0) continue;
+ $studentIssueRows[] = [
+ 'name' => trim((string)($stu['firstname'] ?? '') . ' ' . (string)($stu['lastname'] ?? '')),
+ 'section' => $sectionLabelByKey[(string)$sectionKey] ?? ('Section ' . $sectionKey),
+ 'absent' => $abs,
+ 'late' => $late,
+ ];
+ }
+ }
+
+ usort($studentIssueRows, static function ($a, $b) {
+ $sec = strcmp($a['section'], $b['section']);
+ if ($sec !== 0) return $sec;
+ return strcmp($a['name'], $b['name']);
+ });
+
$totalDaysForPercent = 0;
if ($filterStart === '' && $filterEnd === '' && !empty($totalPassedDays)) {
$totalDaysForPercent = (int)$totalPassedDays;
@@ -373,6 +421,37 @@
+
+
Students With Absences / Late
+
+
@@ -480,6 +559,13 @@
info: false,
order: [[0, 'asc']]
});
+ $('#studentIssueTable').DataTable({
+ paging: true,
+ searching: true,
+ info: true,
+ order: [[1, 'asc'], [0, 'asc']],
+ pageLength: 25
+ });
}
});
diff --git a/app/Views/administrator/exam_drafts.php b/app/Views/administrator/exam_drafts.php
index 7450dec..fc03e51 100644
--- a/app/Views/administrator/exam_drafts.php
+++ b/app/Views/administrator/exam_drafts.php
@@ -182,13 +182,13 @@