Files
alrahma_sunday_school/app/Controllers/ClassProgressController.php
T
2026-02-26 23:27:50 -05:00

591 lines
22 KiB
PHP

<?php
namespace App\Controllers;
use App\Models\ClassProgressReportModel;
use App\Models\ClassProgressAttachmentModel;
use App\Models\ConfigurationModel;
use App\Models\SubjectCurriculumModel;
use App\Models\TeacherClassModel;
use App\Services\SemesterRangeService;
use CodeIgniter\Exceptions\PageNotFoundException;
class ClassProgressController extends BaseController
{
public const STATUS_OPTIONS = [
'on_track' => 'On track',
'slightly_behind' => 'Slightly behind',
'behind' => 'Behind',
];
private const DEFAULT_STATUS = 'on_track';
public const SUBJECT_SECTIONS = [
'islamic' => [
'label' => 'Islamic Studies',
'db_subject' => 'Islamic Studies',
],
'quran' => [
'label' => 'Quran/Arabic',
'db_subject' => 'Quran/Arabic',
],
];
protected ClassProgressReportModel $reportModel;
protected ClassProgressAttachmentModel $attachmentModel;
protected TeacherClassModel $teacherClassModel;
protected ConfigurationModel $configModel;
protected string $attachmentStoragePath;
protected string $attachmentPublicBase;
protected SubjectCurriculumModel $curriculumModel;
public function __construct()
{
helper(['form', 'url']);
$this->reportModel = new ClassProgressReportModel();
$this->attachmentModel = new ClassProgressAttachmentModel();
$this->teacherClassModel = new TeacherClassModel();
$this->configModel = new ConfigurationModel();
$this->curriculumModel = new SubjectCurriculumModel();
$this->attachmentStoragePath = WRITEPATH . 'uploads/class_material/';
$this->attachmentPublicBase = 'writable/uploads/class_material/';
}
public function create()
{
$teacherId = (int) session()->get('user_id');
$assignments = $this->loadTeacherSections($teacherId);
$first = $assignments[0] ?? null;
$classSectionId = $first['class_section_id'] ?? null;
$classSectionName = $first['class_section_name'] ?? null;
$classId = $first['class_id'] ?? null;
$sundayOptions = $this->buildSundayOptions();
$defaultWeekStart = $this->pickDefaultWeekStart($sundayOptions);
$subjectCurriculum = [];
if ($classId) {
foreach (self::SUBJECT_SECTIONS as $slug => $section) {
$subjectCurriculum[$slug] = $this->curriculumModel->getOptionsForClass((int) $classId, $slug);
}
}
$data = [
'subjectSections' => self::SUBJECT_SECTIONS,
'subjectCurriculum' => $subjectCurriculum,
'classSectionId' => $classSectionId,
'classSectionName' => $classSectionName,
'classId' => $classId,
'sundayOptions' => $sundayOptions,
'defaultWeekStart' => $defaultWeekStart,
];
return view('teacher/class_progress_submit', $data);
}
public function store()
{
$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]',
'support_needed' => 'permit_empty|string',
'flags' => 'permit_empty',
];
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());
}
$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.');
}
$teacherId = (int) session()->get('user_id');
$classSectionId = $this->request->getPost('class_section_id');
$classSectionId = $classSectionId ? (int) $classSectionId : null;
if (! $classSectionId) {
return redirect()->back()->withInput()->with('error', 'No class assignment found for this report.');
}
$status = self::DEFAULT_STATUS;
$reportsCreated = 0;
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);
$data = [
'teacher_id' => $teacherId,
'class_section_id' => $classSectionId,
'week_start' => $weekStart,
'week_end' => $weekEnd,
'subject' => $section['db_subject'] ?? $section['label'] ?? $slug,
'unit_title' => $unitTitle,
'covered' => $covered,
'homework' => $homework ?: null,
'status' => $status,
'flags_json' => $this->normalizeFlags($this->request->getPost('flags')),
];
$reportId = $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);
$this->reportModel->update($reportId, ['attachment_path' => $storedAttachments[0]['file_path']]);
}
$reportsCreated++;
}
if ($reportsCreated === 0) {
return redirect()->back()->withInput()->with('error', 'Please provide progress for at least one subject.');
}
return redirect()->to('teacher/progress/submit')->with('success', 'Progress reports saved.');
}
public function history()
{
$teacherId = (int) session()->get('user_id');
$assignments = $this->loadTeacherSections($teacherId);
$selectedSectionId = (int) $this->request->getGet('class_section_id');
$validSectionIds = array_column($assignments, 'class_section_id');
if ($selectedSectionId === 0 && ! empty($validSectionIds)) {
$selectedSectionId = $validSectionIds[0];
}
if ($selectedSectionId && ! in_array($selectedSectionId, $validSectionIds, true)) {
$selectedSectionId = $validSectionIds[0] ?? null;
}
[$semester, $schoolYear] = $this->resolveCurrentTerm();
$allowedTeacherIds = $this->resolveAssignedTeacherIds($selectedSectionId, $semester, $schoolYear);
if (empty($allowedTeacherIds)) {
$allowedTeacherIds = [$teacherId];
}
$builder = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
->whereIn('teacher_id', $allowedTeacherIds);
if ($selectedSectionId) {
$builder->where('class_progress_reports.class_section_id', $selectedSectionId);
}
$rows = $builder
->orderBy('week_start', 'DESC')
->findAll();
$reportGroups = [];
foreach ($rows as $row) {
$row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
$key = $row['week_start'] ?? '';
if ($key === '') {
continue;
}
if (! isset($reportGroups[$key])) {
$reportGroups[$key] = [
'week_start' => $row['week_start'],
'week_end' => $row['week_end'],
'class_section_name' => $row['class_section_name'] ?? '',
'reports' => [],
];
}
$reportGroups[$key]['reports'][$row['subject']] = $row;
}
$sectionOptions = [];
foreach ($assignments as $assignment) {
$sectionOptions[$assignment['class_section_id']] = $assignment['class_section_name'] ?? '';
}
return view('teacher/class_progress_history', [
'reportGroups' => $reportGroups,
'subjectSections' => self::SUBJECT_SECTIONS,
'classSectionOptions' => $sectionOptions,
'selectedSectionId' => $selectedSectionId,
]);
}
public function view($id)
{
$teacherId = (int) session()->get('user_id');
$row = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->join('users u', 'u.id = class_progress_reports.teacher_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.');
}
$row['status_label'] = self::STATUS_OPTIONS[$row['status']] ?? 'Unknown';
$weeklyReports = $this->reportModel
->select('class_progress_reports.*, cs.class_section_name, CONCAT(IFNULL(u.firstname, ""), " ", IFNULL(u.lastname, "")) AS teacher_name')
->join('classSection cs', 'cs.class_section_id = class_progress_reports.class_section_id', 'left')
->join('users u', 'u.id = class_progress_reports.teacher_id', 'left')
->whereIn('teacher_id', $allowedTeacherIds)
->where('class_progress_reports.class_section_id', $row['class_section_id'])
->where('week_start', $row['week_start'])
->orderBy('subject', 'ASC')
->findAll();
$attachmentMap = $this->loadAttachmentsForReports(array_column($weeklyReports, 'id'));
foreach ($weeklyReports as &$report) {
$report['status_label'] = self::STATUS_OPTIONS[$report['status']] ?? 'Unknown';
$report['attachments'] = $attachmentMap[$report['id']] ?? [];
if (empty($report['attachments']) && ! empty($report['attachment_path'])) {
$report['attachments'][] = [
'id' => $report['id'],
'name' => basename((string) $report['attachment_path']),
'legacy' => true,
];
}
}
return view('teacher/class_progress_view', [
'row' => $row,
'weeklyReports' => $weeklyReports,
'subjectSections' => self::SUBJECT_SECTIONS,
]);
}
public function attachment($id)
{
$row = $this->reportModel->find((int)$id);
if (! $row || empty($row['attachment_path'])) {
throw new PageNotFoundException('Attachment not found.');
}
$file = $this->resolveAttachmentFile($row);
if (! $file) {
throw new PageNotFoundException('Attachment missing.');
}
return $this->response->download($file, null)->setFileName(basename($file));
}
public function attachmentFile($id)
{
$attachment = $this->attachmentModel->find((int) $id);
if (! $attachment || empty($attachment['file_path'])) {
throw new PageNotFoundException('Attachment not found.');
}
$file = $this->resolveAttachmentPath($attachment['file_path']);
if (! $file) {
throw new PageNotFoundException('Attachment missing.');
}
$downloadName = $attachment['original_name'] ?: basename($file);
return $this->response->download($file, null)->setFileName($downloadName);
}
protected function resolveAttachmentFile(array $row): ?string
{
$path = trim((string) ($row['attachment_path'] ?? ''));
if ($path === '') {
return null;
}
return $this->resolveAttachmentPath($path);
}
protected function resolveAttachmentPath(string $path): ?string
{
$relative = preg_replace('#^writable/uploads/#', '', $path);
$absolute = WRITEPATH . 'uploads/' . ltrim($relative, '/');
return is_file($absolute) ? $absolute : null;
}
protected function validateAttachmentFiles(array $subjectSections): array
{
$errors = [];
foreach ($subjectSections as $slug => $section) {
$label = $section['label'] ?? $slug;
$field = "attachment_$slug";
$files = $this->request->getFileMultiple($field) ?? [];
foreach ($files as $file) {
if (! $file || $file->getError() === UPLOAD_ERR_NO_FILE) {
continue;
}
if (! $file->isValid()) {
$errors[] = "Invalid attachment uploaded for {$label}.";
continue;
}
if ($file->getSize() > 5 * 1024 * 1024) {
$errors[] = "Each attachment for {$label} must be 5MB or smaller.";
}
$ext = strtolower((string) $file->getClientExtension());
if ($ext === '' || ! in_array($ext, ['pdf', 'jpg', 'jpeg', 'png'], true)) {
$errors[] = "Only PDF, JPG, JPEG, or PNG files are allowed for {$label}.";
}
}
}
return $errors;
}
protected function storeAttachments(int $reportId, array $attachments): array
{
if (empty($attachments)) {
return [];
}
$stored = [];
$now = date('Y-m-d H:i:s');
foreach ($attachments as $file) {
if (! $file || $file->getError() === UPLOAD_ERR_NO_FILE) {
continue;
}
if (! $file->isValid() || $file->hasMoved()) {
continue;
}
$stored[] = [
'report_id' => $reportId,
'file_path' => $this->storeAttachment($file),
'original_name' => $file->getClientName(),
'mime_type' => $file->getClientMimeType(),
'file_size' => $file->getSize(),
'created_at' => $now,
];
}
return $stored;
}
protected function loadAttachmentsForReports(array $reportIds): array
{
$reportIds = array_values(array_filter(array_map('intval', $reportIds)));
if (empty($reportIds)) {
return [];
}
$rows = $this->attachmentModel
->whereIn('report_id', $reportIds)
->orderBy('id', 'ASC')
->findAll();
$map = [];
foreach ($rows as $row) {
$reportId = (int) ($row['report_id'] ?? 0);
if ($reportId === 0) {
continue;
}
$map[$reportId][] = [
'id' => (int) $row['id'],
'name' => $row['original_name'] ?: basename((string) $row['file_path']),
];
}
return $map;
}
protected function loadTeacherSections(int $teacherId): array
{
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
return $this->teacherClassModel->getClassAssignmentsByUserId($teacherId, $schoolYear, $semester);
}
protected function normalizeFlags($flags): ?string
{
$flags = array_values(array_filter((array) $flags, static fn ($item) => $item !== '' && $item !== null));
return $flags ? json_encode($flags) : null;
}
protected function buildUnitChapterSummary(string $slug): ?string
{
$unitValues = array_map('trim', (array) $this->request->getPost("unit_$slug"));
$chapterValues = array_map('trim', (array) $this->request->getPost("chapter_$slug"));
$parts = [];
$count = max(count($unitValues), count($chapterValues));
for ($i = 0; $i < $count; $i++) {
$unit = $unitValues[$i] ?? '';
$chapter = $chapterValues[$i] ?? '';
if ($unit === '' && $chapter === '') {
continue;
}
$segment = $unit;
if ($chapter !== '') {
$segment = $segment !== '' ? $segment . ' / ' . $chapter : $chapter;
}
if ($segment === '') {
continue;
}
$parts[] = $segment;
}
if (! $parts) {
return null;
}
$summary = implode(' ; ', $parts);
return mb_strlen($summary) > 120 ? mb_substr($summary, 0, 120) : $summary;
}
protected function buildSundayOptions(int $count = 12): array
{
$range = $this->resolveProgressDateRange();
if ($range === null) {
return $this->buildUpcomingSundayOptions($count);
}
[$rangeStart, $rangeEnd] = $range;
try {
$start = new \DateTime($rangeStart);
$end = new \DateTime($rangeEnd);
} catch (\Exception $e) {
return $this->buildUpcomingSundayOptions($count);
}
if ($end < $start) {
[$start, $end] = [$end, $start];
}
$weekday = (int) $start->format('w');
if ($weekday !== 0) {
$start->modify('next sunday');
}
$options = [];
while ($start <= $end) {
$options[] = $start->format('Y-m-d');
$start->modify('+7 days');
}
return $options;
}
protected function buildUpcomingSundayOptions(int $count): array
{
$start = new \DateTime('today');
$weekday = (int) $start->format('w');
if ($weekday !== 0) {
$start->modify('next sunday');
}
$options = [];
for ($i = 0; $i < $count; $i++) {
$options[] = $start->format('Y-m-d');
$start->modify('+7 days');
}
return $options;
}
protected function resolveProgressDateRange(): ?array
{
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
if ($schoolYear === '') {
return null;
}
$semesterResolver = new SemesterRangeService($this->configModel);
$semester = $semesterResolver->normalizeSemester((string) ($this->configModel->getConfig('semester') ?? ''));
if ($semester === '') {
$semester = $semesterResolver->getSemesterForDate();
}
if ($semester !== '') {
$range = $semesterResolver->getSemesterRange($schoolYear, $semester);
if ($range !== null) {
return $range;
}
}
return $semesterResolver->getSchoolYearRange($schoolYear);
}
protected function pickDefaultWeekStart(array $options): string
{
if (empty($options)) {
return '';
}
$today = date('Y-m-d');
$default = '';
foreach ($options as $option) {
if ($option <= $today) {
$default = $option;
continue;
}
break;
}
return $default !== '' ? $default : $options[0];
}
protected function buildWeekEndFromStart(string $weekStart): string
{
try {
$dt = new \DateTime($weekStart);
$dt->modify('+6 days');
return $dt->format('Y-m-d');
} catch (\Exception $e) {
return $weekStart;
}
}
protected function storeAttachment($file): string
{
$this->ensureAttachmentPath();
$name = $file->getRandomName();
$file->move($this->attachmentStoragePath, $name);
return $this->attachmentPublicBase . $name;
}
protected function ensureAttachmentPath(): void
{
if (! is_dir($this->attachmentStoragePath)) {
mkdir($this->attachmentStoragePath, 0755, true);
}
}
protected function resolveCurrentTerm(): array
{
$schoolYear = (string) ($this->configModel->getConfig('school_year') ?? '');
$semester = (string) ($this->configModel->getConfig('semester') ?? '');
return [$semester, $schoolYear];
}
protected function resolveAssignedTeacherIds(?int $classSectionId, string $semester, string $schoolYear): array
{
if (! $classSectionId || $schoolYear === '') {
return [];
}
$rows = $this->teacherClassModel->assignedForSectionTerm($classSectionId, $semester, $schoolYear);
$ids = [];
foreach ($rows as $row) {
$id = (int) ($row['teacher_id'] ?? 0);
if ($id > 0) {
$ids[$id] = true;
}
}
return array_keys($ids);
}
}