diff --git a/app/Config/Routes.php b/app/Config/Routes.php index eba839e..ddddb58 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1304,6 +1304,45 @@ $routes->get('/access_denied', 'ErrorController::accessDenied'); $routes->post('administrator/students/update', 'View\StudentController::editStudentData', ['filter' => 'auth:edit_student,update']); +// Shared assessment management (all administrative roles). +$assessmentAdminFilter = 'auth:admin_category'; +$routes->group('administrator/assessments', ['filter' => $assessmentAdminFilter], static function ($routes) { + $routes->get('', 'View\AssessmentController::forms'); + $routes->get('pools', 'View\AssessmentController::pools'); + $routes->get('pools/(:num)', 'View\AssessmentController::pool/$1'); + $routes->post('pools/(:num)/questions', 'View\AssessmentController::storeQuestion/$1'); + $routes->post('questions/(:num)/update', 'View\AssessmentController::updateQuestion/$1'); + $routes->post('questions/(:num)/delete', 'View\AssessmentController::deleteQuestion/$1'); + $routes->post('questions/(:num)/move', 'View\AssessmentController::moveQuestion/$1'); + $routes->get('forms', 'View\AssessmentController::forms'); + $routes->get('forms/new', 'View\AssessmentController::newForm'); + $routes->post('forms', 'View\AssessmentController::storeForm'); + $routes->get('forms/(:num)/edit', 'View\AssessmentController::editForm/$1'); + $routes->post('forms/(:num)', 'View\AssessmentController::updateForm/$1'); + $routes->post('forms/(:num)/publish', 'View\AssessmentController::publishForm/$1'); + $routes->get('forms/(:num)/preview', 'View\AssessmentController::preview/$1'); + $routes->get('students', 'View\AssessmentController::newStudentRoster'); + $routes->get('students/(:num)', 'View\AssessmentController::studentAssignments/$1'); + $routes->post('students/(:num)/start', 'View\AssessmentController::startInterview/$1'); + $routes->post('students/(:num)/assign', 'View\AssessmentController::assign/$1'); + $routes->post('attempts/(:num)/progress', 'View\AssessmentController::saveAdminProgress/$1'); + $routes->post('attempts/(:num)/note', 'View\AssessmentController::saveAdminNote/$1'); + $routes->post('attempts/(:num)/complete', 'View\AssessmentController::completeAdminInterview/$1'); + $routes->get('review/(:num)', 'View\AssessmentController::grade/$1'); + $routes->post('review/(:num)', 'View\AssessmentController::saveGrade/$1'); + $routes->get('grade/(:num)', 'View\AssessmentController::grade/$1'); + $routes->post('grade/(:num)', 'View\AssessmentController::saveGrade/$1'); + $routes->get('results/(:num)', 'View\AssessmentController::results/$1'); +}); + +$routes->group('student/assessments', ['filter' => 'auth:student|parent'], static function ($routes) { + $routes->get('', 'View\AssessmentController::myAssessments'); + $routes->get('(:num)', 'View\AssessmentController::take/$1'); + $routes->post('(:num)/progress', 'View\AssessmentController::saveProgress/$1'); + $routes->post('(:num)/submit', 'View\AssessmentController::submit/$1'); + $routes->get('(:num)/results', 'View\AssessmentController::myResults/$1'); +}); + /* * -------------------------------------------------------------------- * routes for View\SupportController diff --git a/app/Controllers/View/AdministratorController.php b/app/Controllers/View/AdministratorController.php index 6d4af84..10d7c1c 100644 --- a/app/Controllers/View/AdministratorController.php +++ b/app/Controllers/View/AdministratorController.php @@ -619,9 +619,12 @@ class AdministratorController extends BaseController { $selectedYear = $this->currentSchoolYearName((string) ($this->schoolYear ?? '')); + $data = service('administratorDirectory')->studentProfiles($selectedYear); + $data['assessmentByStudent'] = $this->assessmentActionsForStudents(array_column($data['students'] ?? [], 'id'), $selectedYear); + return view( 'administrator/student_profiles', - service('administratorDirectory')->studentProfiles($selectedYear) + $data ); } @@ -676,10 +679,9 @@ class AdministratorController extends BaseController public function showNewStudents() { - return view( - 'enroll_withdraw/new-students', - service('enrollmentWithdrawal')->newStudents((string) $this->schoolYear) - ); + $data = service('enrollmentWithdrawal')->newStudents((string) $this->schoolYear); + $data['assessmentByStudent'] = $this->assessmentActionsForStudents(array_column($data['new_students'] ?? [], 'id'), (string) $this->schoolYear); + return view('enroll_withdraw/new-students', $data); } public function adminEnrollmentWithdrawalHandler() @@ -694,4 +696,31 @@ class AdministratorController extends BaseController return redirect()->to(base_url('enroll_withdraw/enrollment_withdrawal')) ->with(!empty($result['ok']) ? 'success' : 'error', (string) ($result['message'] ?? '')); } + + private function assessmentActionsForStudents(array $studentIds, string $schoolYear): array + { + $studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds)))); + $db = db_connect(); + if ($studentIds === [] || ! $db->tableExists('student_assessments')) return []; + + $rows = $db->table('student_assessments sa') + ->select('sa.id, sa.student_id, sa.form_id, sa.status, sa.assigned_at, f.name AS form_name') + ->join('assessment_forms f', 'f.id = sa.form_id') + ->whereIn('sa.student_id', $studentIds) + ->where('f.school_year', $schoolYear) + ->orderBy('sa.assigned_at', 'DESC')->orderBy('sa.id', 'DESC')->get()->getResultArray(); + $map = []; + foreach ($rows as $row) { + $studentId = (int) $row['student_id']; + $priority = match ((string) $row['status']) { + 'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => 0, + }; + $current = $map[$studentId]['status'] ?? null; + $currentPriority = match ($current) { + 'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => -1, + }; + if ($priority > $currentPriority) $map[$studentId] = $row; + } + return $map; + } } diff --git a/app/Controllers/View/AssessmentController.php b/app/Controllers/View/AssessmentController.php new file mode 100644 index 0000000..d3bba7d --- /dev/null +++ b/app/Controllers/View/AssessmentController.php @@ -0,0 +1,681 @@ +db = db_connect(); + $this->poolModel = new QuestionPoolModel(); + $this->questionModel = new AssessmentQuestionModel(); + $this->formModel = new AssessmentFormModel(); + $this->assessmentModel = new StudentAssessmentModel(); + $this->studentModel = new StudentModel(); + } + + public function pools() + { + $pool = $this->primaryPool(); + if (! $pool) { + throw new PageNotFoundException('The New Student Assessment pool is not installed. Run the database migrations first.'); + } + $questions = $this->questionModel->where('pool_id', $pool['id'])->orderBy('order_index', 'ASC')->orderBy('id', 'ASC')->findAll(); + return view('assessments/pool', ['pool' => $pool, 'questions' => $questions]); + } + + public function pool(int $id) + { + $pool = $this->primaryPool(); + if (! $pool || (int) $pool['id'] !== $id) { + return redirect()->to('administrator/assessments/pools'); + } + $questions = $this->questionModel->where('pool_id', $id)->orderBy('order_index', 'ASC')->orderBy('id', 'ASC')->findAll(); + return view('assessments/pool', ['pool' => $pool, 'questions' => $questions]); + } + + public function storeQuestion(int $poolId) + { + $this->requirePool($poolId); + if (! $this->validateQuestion()) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + $max = $this->db->table('assessment_questions')->selectMax('order_index')->where('pool_id', $poolId)->get()->getRowArray(); + $this->questionModel->insert($this->questionPayload($poolId, ((int) ($max['order_index'] ?? 0)) + 1)); + return redirect()->to('administrator/assessments/pools')->with('success', 'Question added.'); + } + + public function updateQuestion(int $id) + { + $question = $this->requireQuestion($id); + if (! $this->validateQuestion()) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + $this->questionModel->update($id, $this->questionPayload((int) $question['pool_id'], (int) $question['order_index'])); + return redirect()->to('administrator/assessments/pools')->with('success', 'Question updated.'); + } + + public function deleteQuestion(int $id) + { + $question = $this->requireQuestion($id); + if ($this->db->table('assessment_form_questions')->where('question_id', $id)->countAllResults() > 0) { + return redirect()->back()->with('error', 'This question is used by a form and cannot be deleted.'); + } + $this->questionModel->delete($id); + $this->normalizeQuestionOrder((int) $question['pool_id']); + return redirect()->to('administrator/assessments/pools')->with('success', 'Question deleted.'); + } + + public function moveQuestion(int $id) + { + $question = $this->requireQuestion($id); + $direction = (string) $this->request->getPost('direction'); + $operator = $direction === 'up' ? '<' : '>'; + $sort = $direction === 'up' ? 'DESC' : 'ASC'; + $other = $this->db->table('assessment_questions') + ->where('pool_id', $question['pool_id'])->where('order_index ' . $operator, $question['order_index']) + ->orderBy('order_index', $sort)->limit(1)->get()->getRowArray(); + if ($other) { + $this->db->transStart(); + $this->questionModel->update($id, ['order_index' => $other['order_index']]); + $this->questionModel->update((int) $other['id'], ['order_index' => $question['order_index']]); + $this->db->transComplete(); + } + return redirect()->to('administrator/assessments/pools'); + } + + public function forms() + { + $forms = $this->db->table('assessment_forms f') + ->select('f.*, p.name AS pool_name, COUNT(DISTINCT fq.question_id) AS question_count, COUNT(DISTINCT sa.id) AS assignment_count') + ->join('question_pools p', 'p.id = f.pool_id') + ->join('assessment_form_questions fq', 'fq.form_id = f.id', 'left') + ->join('student_assessments sa', 'sa.form_id = f.id', 'left') + ->groupBy('f.id')->orderBy('f.created_at', 'DESC')->get()->getResultArray(); + return view('assessments/forms', ['forms' => $forms]); + } + + public function newForm() + { + $pool = $this->primaryPool(); + $selectedSchoolYear = $this->currentSchoolYearName((string) session()->get('school_year')); + return view('assessments/form_builder', [ + 'form' => null, + 'pools' => $pool ? [$pool] : [], + 'questions' => $pool ? $this->questionModel->where('pool_id', $pool['id'])->orderBy('order_index', 'ASC')->findAll() : [], + 'selectedIds' => [], 'locked' => false, 'schoolYears' => $this->schoolYearOptions(), + 'selectedSchoolYear' => $selectedSchoolYear, + ]); + } + + public function storeForm() + { + return $this->saveForm(null); + } + + public function editForm(int $id) + { + $form = $this->requireForm($id); + $questions = $this->questionModel->where('pool_id', $form['pool_id'])->orderBy('order_index', 'ASC')->findAll(); + $selectedRows = $this->db->table('assessment_form_questions')->where('form_id', $id)->orderBy('order_index', 'ASC')->get()->getResultArray(); + return view('assessments/form_builder', [ + 'form' => $form, + 'pools' => $this->poolModel->orderBy('name', 'ASC')->findAll(), + 'questions' => $questions, + 'selectedIds' => array_map('intval', array_column($selectedRows, 'question_id')), + 'locked' => $this->db->table('student_assessments')->where('form_id', $id)->countAllResults() > 0, + 'schoolYears' => $this->schoolYearOptions(), + 'selectedSchoolYear' => (string) ($form['school_year'] ?? ''), + ]); + } + + public function updateForm(int $id) + { + $this->requireForm($id); + return $this->saveForm($id); + } + + public function publishForm(int $id) + { + $form = $this->requireForm($id); + $questionCount = $this->db->table('assessment_form_questions')->where('form_id', $id)->countAllResults(); + if ($questionCount === 0) { + return redirect()->to('administrator/assessments/forms')->with('error', 'Add at least one question before publishing this form.'); + } + if (! preg_match('/^\d{4}-\d{4}$/', (string) ($form['school_year'] ?? ''))) { + return redirect()->to('administrator/assessments/forms/' . $id . '/edit') + ->with('error', 'Select a school year before publishing this form.'); + } + if ($form['status'] !== 'published') { + $this->formModel->update($id, ['status' => 'published']); + } + return redirect()->to('administrator/assessments/forms')->with('success', 'Assessment form published.'); + } + + public function preview(int $id) + { + $data = $this->formWithQuestions($id); + $data['preview'] = true; + return view('assessments/take', $data); + } + + public function newStudentRoster() + { + $schoolYear = $this->currentSchoolYearName((string) session()->get('school_year')); + $data = service('enrollmentWithdrawal')->newStudents($schoolYear); + $data['schoolYear'] = $schoolYear; + $data['assessmentByStudent'] = $this->assessmentActionsForStudents(array_column($data['new_students'] ?? [], 'id'), $schoolYear); + return view('assessments/new_students', $data); + } + + public function studentAssignments(int $studentId) + { + $student = $this->studentModel->find($studentId); + if (! $student) { + throw new PageNotFoundException('Student not found.'); + } + $schoolYear = $this->currentSchoolYearName((string) session()->get('school_year')); + $active = $this->activeAssessmentForStudent($studentId, $schoolYear); + if ($active) { + return view('assessments/admin_take', $this->assessmentDetails((int) $active['id'])); + } + $forms = $this->db->table('assessment_forms f') + ->select('f.*, p.name AS pool_name, COUNT(fq.question_id) AS question_count') + ->join('question_pools p', 'p.id = f.pool_id') + ->join('assessment_form_questions fq', 'fq.form_id = f.id', 'left') + ->where('f.status', 'published')->where('f.school_year', $schoolYear) + ->groupBy('f.id')->orderBy('f.name', 'ASC')->get()->getResultArray(); + $assignments = $this->db->table('student_assessments sa') + ->select('sa.*, f.name AS form_name, f.school_year')->join('assessment_forms f', 'f.id = sa.form_id') + ->where('sa.student_id', $studentId)->orderBy('sa.assigned_at', 'DESC')->get()->getResultArray(); + return view('assessments/student_assignments', ['student' => $student, 'forms' => $forms, 'assignments' => $assignments]); + } + + public function startInterview(int $studentId) + { + if (! $this->studentModel->find($studentId)) { + throw new PageNotFoundException('Student not found.'); + } + $schoolYear = $this->currentSchoolYearName((string) session()->get('school_year')); + $active = $this->activeAssessmentForStudent($studentId, $schoolYear); + if ($active) return redirect()->to("administrator/assessments/students/{$studentId}"); + + $assignedFormIds = array_map('intval', array_column( + $this->assessmentModel->select('form_id')->where('student_id', $studentId)->findAll(), + 'form_id' + )); + $formBuilder = $this->formModel->where('status', 'published')->where('school_year', $schoolYear) + ->orderBy('created_at', 'DESC')->orderBy('id', 'DESC'); + if ($assignedFormIds !== []) $formBuilder->whereNotIn('id', $assignedFormIds); + $form = $formBuilder->first(); + if (! $form) { + return redirect()->to('administrator/assessments/students/' . $studentId) + ->with('error', 'No unassigned published assessment form is available for this student.'); + } + $this->assessmentModel->insert([ + 'form_id' => (int) $form['id'], 'student_id' => $studentId, 'status' => 'not_started', + 'assigned_at' => date('Y-m-d H:i:s'), + ]); + return redirect()->to("administrator/assessments/students/{$studentId}"); + } + + public function assign(int $studentId) + { + if (! $this->studentModel->find($studentId)) { + throw new PageNotFoundException('Student not found.'); + } + $formId = (int) $this->request->getPost('form_id'); + $schoolYear = $this->currentSchoolYearName((string) session()->get('school_year')); + $form = $this->formModel->where('id', $formId)->where('status', 'published')->where('school_year', $schoolYear)->first(); + if (! $form) { + return redirect()->back()->with('error', 'Choose a published assessment form.'); + } + if ($this->assessmentModel->where(['form_id' => $formId, 'student_id' => $studentId])->first()) { + return redirect()->back()->with('error', 'This form is already assigned to this student.'); + } + try { + $inserted = $this->assessmentModel->insert([ + 'form_id' => $formId, 'student_id' => $studentId, 'status' => 'not_started', + 'assigned_at' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + log_message('warning', 'Assessment assignment rejected: ' . $e->getMessage()); + $inserted = false; + } + if ($inserted === false) { + return redirect()->back()->with('error', 'This form is already assigned to this student.'); + } + return redirect()->to("administrator/assessments/students/{$studentId}")->with('success', 'Assessment assigned.'); + } + + public function grade(int $assessmentId) + { + $data = $this->assessmentDetails($assessmentId); + if (! in_array($data['assessment']['status'], ['completed', 'graded'], true)) { + return redirect()->back()->with('error', 'The student must complete the assessment before it can be reviewed.'); + } + return view('assessments/grade', $data); + } + + public function saveGrade(int $assessmentId) + { + $data = $this->assessmentDetails($assessmentId); + if (! in_array($data['assessment']['status'], ['completed', 'graded'], true)) { + return redirect()->back()->with('error', 'The assessment is not ready for review.'); + } + $this->persistEducationCommitteeNote($assessmentId); + $this->db->transStart(); + $this->db->table('student_answers')->where('student_assessment_id', $assessmentId)->update([ + 'is_correct' => null, 'points_awarded' => null, 'updated_at' => date('Y-m-d H:i:s'), + ]); + $this->assessmentModel->update($assessmentId, [ + 'status' => 'graded', 'score' => null, 'graded_at' => date('Y-m-d H:i:s'), + 'graded_by' => (int) session()->get('user_id') ?: null, + ]); + $this->db->transComplete(); + return redirect()->to("administrator/assessments/results/{$assessmentId}")->with('success', 'Assessment review completed.'); + } + + public function results(int $assessmentId) + { + $data = $this->assessmentDetails($assessmentId); + $data['adminView'] = true; + return view('assessments/results', $data); + } + + public function saveAdminProgress(int $assessmentId) + { + $assessment = $this->assessmentModel->find($assessmentId); + if (! $assessment) throw new PageNotFoundException('Assessment not found.'); + if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) { + return $this->response->setStatusCode(409)->setJSON(['status' => false, 'message' => 'This assessment can no longer be changed.']); + } + $this->persistEducationCommitteeNote($assessmentId); + $this->persistAnswers($assessment, (array) $this->request->getPost('answers')); + return $this->response->setJSON([ + 'status' => true, 'message' => 'Saved', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), + ]); + } + + public function saveAdminNote(int $assessmentId) + { + if (! $this->assessmentModel->find($assessmentId)) { + throw new PageNotFoundException('Assessment not found.'); + } + $this->persistEducationCommitteeNote($assessmentId); + return $this->response->setJSON([ + 'status' => true, 'message' => 'Note saved', 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), + ]); + } + + public function completeAdminInterview(int $assessmentId) + { + $assessment = $this->assessmentModel->find($assessmentId); + if (! $assessment) throw new PageNotFoundException('Assessment not found.'); + if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) { + return redirect()->to('administrator/assessments/students/' . $assessment['student_id']) + ->with('error', 'This assessment has already been completed.'); + } + $this->persistEducationCommitteeNote($assessmentId); + $this->persistAnswers($assessment, (array) $this->request->getPost('answers')); + $this->assessmentModel->update($assessmentId, ['status' => 'completed', 'submitted_at' => date('Y-m-d H:i:s')]); + return redirect()->to("administrator/assessments/review/{$assessmentId}") + ->with('success', 'Assessment completed. Review the responses below.'); + } + + public function myAssessments() + { + $studentIds = $this->accessibleStudentIds(); + $assignments = []; + if ($studentIds) { + $assignments = $this->db->table('student_assessments sa') + ->select('sa.*, f.name AS form_name, f.school_year, s.firstname, s.lastname') + ->join('assessment_forms f', 'f.id = sa.form_id') + ->join('students s', 's.id = sa.student_id') + ->whereIn('sa.student_id', $studentIds)->orderBy('sa.assigned_at', 'DESC')->get()->getResultArray(); + } + return view('assessments/my_assessments', ['assignments' => $assignments]); + } + + public function take(int $assessmentId) + { + $assessment = $this->requireAccessibleAssessment($assessmentId); + if ($assessment['status'] === 'graded') { + return redirect()->to("student/assessments/{$assessmentId}/results"); + } + if ($assessment['status'] === 'completed') { + return redirect()->to('student/assessments')->with('success', 'This assessment has already been submitted and is awaiting review.'); + } + $data = $this->formWithQuestions((int) $assessment['form_id']); + $answerRows = $this->db->table('student_answers')->where('student_assessment_id', $assessmentId)->get()->getResultArray(); + $data['answers'] = array_column($answerRows, 'answer_value', 'question_id'); + $data['assessment'] = $assessment; + $data['preview'] = false; + return view('assessments/take', $data); + } + + public function saveProgress(int $assessmentId) + { + $assessment = $this->requireAccessibleAssessment($assessmentId); + if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) { + return $this->response->setStatusCode(409)->setJSON(['status' => false, 'message' => 'This assessment can no longer be changed.']); + } + $answers = (array) $this->request->getPost('answers'); + $this->persistAnswers($assessment, $answers); + if ($this->request->isAJAX()) { + return $this->response->setJSON([ + 'status' => true, 'message' => 'Progress saved.', + 'csrfName' => csrf_token(), 'csrfHash' => csrf_hash(), + ]); + } + return redirect()->back()->with('success', 'Progress saved.'); + } + + public function submit(int $assessmentId) + { + $assessment = $this->requireAccessibleAssessment($assessmentId); + if (! in_array($assessment['status'], ['not_started', 'in_progress'], true)) { + return redirect()->to('student/assessments')->with('error', 'This assessment was already submitted.'); + } + $this->persistAnswers($assessment, (array) $this->request->getPost('answers')); + $this->assessmentModel->update($assessmentId, ['status' => 'completed', 'submitted_at' => date('Y-m-d H:i:s')]); + return redirect()->to('student/assessments')->with('success', 'Assessment submitted. Your responses will appear after review.'); + } + + public function myResults(int $assessmentId) + { + $assessment = $this->requireAccessibleAssessment($assessmentId); + if ($assessment['status'] !== 'graded') { + return redirect()->to('student/assessments')->with('error', 'Results are not available yet.'); + } + $data = $this->assessmentDetails($assessmentId); + $data['adminView'] = false; + return view('assessments/results', $data); + } + + private function saveForm(?int $id) + { + $rules = [ + 'name' => 'required|string|max_length[150]', 'pool_id' => 'required|is_natural_no_zero', + 'school_year' => 'required|regex_match[/^\d{4}-\d{4}$/]', + 'status' => 'required|in_list[draft,published,archived]', + 'education_committee_note' => 'permit_empty|string|max_length[10000]', + ]; + if (! $this->validate($rules)) { + return redirect()->back()->withInput()->with('errors', $this->validator->getErrors()); + } + $existingForm = $id !== null ? $this->requireForm($id) : null; + $submittedSchoolYear = trim((string) $this->request->getPost('school_year')); + if ($this->db->tableExists('school_years') + && $this->db->table('school_years')->where('name', $submittedSchoolYear)->countAllResults() === 0) { + return redirect()->back()->withInput()->with('error', 'Select a valid school year.'); + } + $poolId = $existingForm ? (int) $existingForm['pool_id'] : (int) $this->request->getPost('pool_id'); + if (! $this->poolModel->find($poolId)) { + return redirect()->back()->withInput()->with('error', 'Question pool not found.'); + } + $locked = $id !== null && $this->db->table('student_assessments')->where('form_id', $id)->countAllResults() > 0; + $selected = array_values(array_unique(array_map('intval', (array) $this->request->getPost('question_ids')))); + if ($this->request->getPost('use_all')) { + $selected = array_map('intval', array_column($this->questionModel->where('pool_id', $poolId)->orderBy('order_index', 'ASC')->findAll(), 'id')); + } + if (! $locked) { + $validRows = empty($selected) ? [] : $this->questionModel->where('pool_id', $poolId)->whereIn('id', $selected)->findAll(); + $validIds = array_map('intval', array_column($validRows, 'id')); + $selected = array_values(array_filter($selected, static fn ($qid) => in_array($qid, $validIds, true))); + usort($selected, function ($a, $b) { + return ((int) $this->request->getPost('order_' . $a)) <=> ((int) $this->request->getPost('order_' . $b)); + }); + if (empty($selected)) { + return redirect()->back()->withInput()->with('error', 'Select at least one question.'); + } + } + $payload = [ + 'name' => trim((string) $this->request->getPost('name')), + 'school_year' => $locked ? (string) $existingForm['school_year'] : $submittedSchoolYear, + 'status' => $this->request->getPost('status'), + 'education_committee_note' => $this->nullableString($this->request->getPost('education_committee_note')), + ]; + if ($id === null) $payload['pool_id'] = $poolId; + $this->db->transStart(); + if ($id === null) { + $payload['created_by'] = (int) session()->get('user_id') ?: null; + $id = (int) $this->formModel->insert($payload, true); + } else { + $this->formModel->update($id, $payload); + } + if (! $locked) { + $this->db->table('assessment_form_questions')->where('form_id', $id)->delete(); + foreach ($selected as $index => $questionId) { + $this->db->table('assessment_form_questions')->insert(['form_id' => $id, 'question_id' => $questionId, 'order_index' => $index + 1]); + } + } + $this->db->transComplete(); + return redirect()->to('administrator/assessments/forms')->with('success', 'Assessment form saved.'); + } + + private function validateQuestion(): bool + { + $valid = $this->validate([ + 'type' => 'required|in_list[multiple_choice,short_answer,true_false,essay]', + 'text' => 'required|string|max_length[10000]', 'options_text' => 'permit_empty|string|max_length[10000]', + 'correct_answer' => 'permit_empty|string|max_length[10000]', + ]); + if ($valid && $this->request->getPost('type') === 'multiple_choice') { + $choices = preg_split('/\r\n|\r|\n/', trim((string) $this->request->getPost('options_text'))) ?: []; + $choices = array_filter(array_map('trim', $choices), static fn ($choice) => $choice !== ''); + if (count($choices) < 2) { + $this->validator->setError('options_text', 'Multiple-choice questions need at least two choices.'); + return false; + } + } + return $valid; + } + + private function questionPayload(int $poolId, int $order): array + { + $type = (string) $this->request->getPost('type'); + $options = null; + if ($type === 'true_false') { + $options = json_encode(['True', 'False']); + } elseif ($type === 'multiple_choice') { + $values = preg_split('/\r\n|\r|\n/', trim((string) $this->request->getPost('options_text'))); + $values = array_values(array_filter(array_map('trim', $values ?: []), static fn ($v) => $v !== '')); + $options = $values ? json_encode($values, JSON_UNESCAPED_UNICODE) : null; + } + return [ + 'pool_id' => $poolId, 'type' => $type, 'text' => trim((string) $this->request->getPost('text')), + 'options' => $options, 'correct_answer' => $this->nullableString($this->request->getPost('correct_answer')), + 'points' => 0, 'order_index' => $order, + ]; + } + + private function formWithQuestions(int $id): array + { + $form = $this->requireForm($id); + $questions = $this->db->table('assessment_form_questions fq') + ->select('q.*, fq.order_index AS form_order')->join('assessment_questions q', 'q.id = fq.question_id') + ->where('fq.form_id', $id)->orderBy('fq.order_index', 'ASC')->get()->getResultArray(); + return ['form' => $form, 'questions' => $questions, 'answers' => []]; + } + + private function assessmentDetails(int $id): array + { + $assessment = $this->db->table('student_assessments sa') + ->select('sa.*, f.name AS form_name, f.id AS assessment_form_id, f.school_year, f.education_committee_note AS form_education_committee_note, s.firstname, s.lastname, s.school_id') + ->join('assessment_forms f', 'f.id = sa.form_id')->join('students s', 's.id = sa.student_id') + ->where('sa.id', $id)->get()->getRowArray(); + if (! $assessment) { + throw new PageNotFoundException('Assessment not found.'); + } + $questions = $this->db->table('assessment_form_questions fq') + ->select('q.*, fq.order_index AS form_order, a.answer_value') + ->join('assessment_questions q', 'q.id = fq.question_id') + ->join('student_answers a', 'a.question_id = q.id AND a.student_assessment_id = ' . (int) $id, 'left') + ->where('fq.form_id', $assessment['form_id'])->orderBy('fq.order_index', 'ASC')->get()->getResultArray(); + return ['assessment' => $assessment, 'questions' => $questions]; + } + + private function requirePool(int $id): array + { + $row = $this->poolModel->find($id); + if (! $row) throw new PageNotFoundException('Question pool not found.'); + return $row; + } + + private function primaryPool(): ?array + { + $pool = $this->poolModel->where('name', 'New Student Assessment')->first(); + if ($pool) return $pool; + + return $this->poolModel->orderBy('id', 'ASC')->first() ?: null; + } + + private function schoolYearOptions(): array + { + if (! $this->db->tableExists('school_years')) return []; + return $this->db->table('school_years')->select('name, status')->orderBy('name', 'DESC')->get()->getResultArray(); + } + + private function requireQuestion(int $id): array + { + $row = $this->questionModel->find($id); + if (! $row) throw new PageNotFoundException('Question not found.'); + return $row; + } + + private function requireForm(int $id): array + { + $row = $this->formModel->find($id); + if (! $row) throw new PageNotFoundException('Assessment form not found.'); + return $row; + } + + private function normalizeQuestionOrder(int $poolId): void + { + $rows = $this->questionModel->where('pool_id', $poolId)->orderBy('order_index', 'ASC')->orderBy('id', 'ASC')->findAll(); + foreach ($rows as $index => $row) $this->questionModel->update($row['id'], ['order_index' => $index + 1]); + } + + private function nullableString($value): ?string + { + $value = trim((string) $value); + return $value === '' ? null : $value; + } + + private function assessmentActionsForStudents(array $studentIds, string $schoolYear): array + { + $studentIds = array_values(array_unique(array_filter(array_map('intval', $studentIds)))); + if ($studentIds === []) return []; + $rows = $this->db->table('student_assessments sa') + ->select('sa.*, f.name AS form_name')->join('assessment_forms f', 'f.id = sa.form_id') + ->where('f.school_year', $schoolYear) + ->whereIn('sa.student_id', $studentIds)->orderBy('sa.assigned_at', 'DESC')->orderBy('sa.id', 'DESC') + ->get()->getResultArray(); + $map = []; + foreach ($rows as $row) { + $studentId = (int) $row['student_id']; + $priority = match ((string) $row['status']) { + 'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => 0, + }; + $currentStatus = $map[$studentId]['status'] ?? null; + $currentPriority = match ($currentStatus) { + 'completed' => 3, 'not_started', 'in_progress' => 2, 'graded' => 1, default => -1, + }; + if ($priority > $currentPriority) $map[$studentId] = $row; + } + return $map; + } + + private function activeAssessmentForStudent(int $studentId, string $schoolYear): ?array + { + return $this->db->table('student_assessments sa') + ->select('sa.*')->join('assessment_forms f', 'f.id = sa.form_id') + ->where('sa.student_id', $studentId)->where('f.school_year', $schoolYear) + ->whereIn('sa.status', ['not_started', 'in_progress']) + ->orderBy('sa.assigned_at', 'DESC')->orderBy('sa.id', 'DESC')->get()->getRowArray() ?: null; + } + + private function persistAnswers(array $assessment, array $answers): void + { + $questionIds = array_map('intval', array_column( + $this->db->table('assessment_form_questions')->select('question_id')->where('form_id', $assessment['form_id'])->get()->getResultArray(), + 'question_id' + )); + $now = date('Y-m-d H:i:s'); + $this->db->transStart(); + foreach ($questionIds as $questionId) { + if (! array_key_exists($questionId, $answers)) continue; + $value = is_array($answers[$questionId]) ? implode(', ', $answers[$questionId]) : trim((string) $answers[$questionId]); + $existing = $this->db->table('student_answers')->where([ + 'student_assessment_id' => $assessment['id'], 'question_id' => $questionId, + ])->get()->getRowArray(); + $payload = ['answer_value' => $value, 'updated_at' => $now]; + if ($existing) { + $this->db->table('student_answers')->where('id', $existing['id'])->update($payload); + } else { + $payload += ['student_assessment_id' => $assessment['id'], 'question_id' => $questionId, 'created_at' => $now]; + $this->db->table('student_answers')->insert($payload); + } + } + if ($assessment['status'] === 'not_started') { + $this->assessmentModel->update($assessment['id'], ['status' => 'in_progress', 'started_at' => $now]); + } + $this->db->transComplete(); + } + + private function persistEducationCommitteeNote(int $assessmentId): void + { + $note = trim((string) $this->request->getPost('education_committee_note')); + if (mb_strlen($note) > 10000) $note = mb_substr($note, 0, 10000); + $this->assessmentModel->update($assessmentId, [ + 'education_committee_note' => $note === '' ? null : $note, + ]); + } + + private function requireAccessibleAssessment(int $id): array + { + $assessment = $this->assessmentModel->find($id); + if (! $assessment || ! in_array((int) $assessment['student_id'], $this->accessibleStudentIds(), true)) { + throw new PageNotFoundException('Assessment not found.'); + } + return $assessment; + } + + private function accessibleStudentIds(): array + { + $userId = (int) session()->get('user_id'); + if ($userId <= 0) return []; + $ids = []; + $user = $this->db->table('users')->select('school_id')->where('id', $userId)->get()->getRowArray(); + $schoolId = trim((string) ($user['school_id'] ?? '')); + if ($schoolId !== '') { + $row = $this->db->table('students')->select('id')->where('school_id', $schoolId)->get()->getRowArray(); + if ($row) $ids[] = (int) $row['id']; + } + $parentRows = $this->db->table('students')->select('id')->where('parent_id', $userId)->get()->getResultArray(); + $ids = array_merge($ids, array_map('intval', array_column($parentRows, 'id'))); + if ($this->db->tableExists('family_guardians') && $this->db->tableExists('family_students')) { + $familyRows = $this->db->table('family_guardians fg')->select('fs.student_id') + ->join('family_students fs', 'fs.family_id = fg.family_id')->where('fg.user_id', $userId)->get()->getResultArray(); + $ids = array_merge($ids, array_map('intval', array_column($familyRows, 'student_id'))); + } + return array_values(array_unique(array_filter($ids))); + } +} diff --git a/app/Database/Migrations/2026-09-09-000100_CreateStudentAssessmentTables.php b/app/Database/Migrations/2026-09-09-000100_CreateStudentAssessmentTables.php new file mode 100644 index 0000000..d2247d0 --- /dev/null +++ b/app/Database/Migrations/2026-09-09-000100_CreateStudentAssessmentTables.php @@ -0,0 +1,122 @@ +db->tableExists('question_pools')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'name' => ['type' => 'VARCHAR', 'constraint' => 150], + 'subject_tag' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('subject_tag'); + $this->forge->createTable('question_pools', true); + } + + if (! $this->db->tableExists('assessment_questions')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'pool_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'type' => ['type' => 'ENUM', 'constraint' => ['multiple_choice', 'short_answer', 'true_false', 'essay']], + 'text' => ['type' => 'TEXT'], + 'options' => ['type' => 'TEXT', 'null' => true], + 'correct_answer' => ['type' => 'TEXT', 'null' => true], + 'points' => ['type' => 'DECIMAL', 'constraint' => '8,2', 'default' => 0], + 'order_index' => ['type' => 'INT', 'constraint' => 11, 'default' => 0], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['pool_id', 'order_index']); + $this->forge->addForeignKey('pool_id', 'question_pools', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('assessment_questions', true); + } + + if (! $this->db->tableExists('assessment_forms')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'name' => ['type' => 'VARCHAR', 'constraint' => 150], + 'pool_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'created_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'status' => ['type' => 'ENUM', 'constraint' => ['draft', 'published', 'archived'], 'default' => 'draft'], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey(['pool_id', 'status']); + $this->forge->addForeignKey('pool_id', 'question_pools', 'id', 'RESTRICT', 'CASCADE'); + $this->forge->createTable('assessment_forms', true); + } + + if (! $this->db->tableExists('assessment_form_questions')) { + $this->forge->addField([ + 'form_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'question_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'order_index' => ['type' => 'INT', 'constraint' => 11, 'default' => 0], + ]); + $this->forge->addKey(['form_id', 'question_id'], true); + $this->forge->addKey(['form_id', 'order_index']); + $this->forge->addForeignKey('form_id', 'assessment_forms', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('question_id', 'assessment_questions', 'id', 'RESTRICT', 'CASCADE'); + $this->forge->createTable('assessment_form_questions', true); + } + + if (! $this->db->tableExists('student_assessments')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'form_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'student_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'status' => ['type' => 'ENUM', 'constraint' => ['not_started', 'in_progress', 'completed', 'graded'], 'default' => 'not_started'], + 'assigned_at' => ['type' => 'DATETIME', 'null' => true], + 'started_at' => ['type' => 'DATETIME', 'null' => true], + 'submitted_at' => ['type' => 'DATETIME', 'null' => true], + 'graded_at' => ['type' => 'DATETIME', 'null' => true], + 'graded_by' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'null' => true], + 'score' => ['type' => 'DECIMAL', 'constraint' => '10,2', 'null' => true], + 'education_committee_note' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['form_id', 'student_id'], 'uq_assessment_form_student'); + $this->forge->addKey(['student_id', 'status']); + $this->forge->addForeignKey('form_id', 'assessment_forms', 'id', 'RESTRICT', 'CASCADE'); + $this->forge->addForeignKey('student_id', 'students', 'id', 'CASCADE', 'CASCADE'); + $this->forge->createTable('student_assessments', true); + } + + if (! $this->db->tableExists('student_answers')) { + $this->forge->addField([ + 'id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], + 'student_assessment_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'question_id' => ['type' => 'INT', 'constraint' => 11, 'unsigned' => true], + 'answer_value' => ['type' => 'TEXT', 'null' => true], + 'is_correct' => ['type' => 'TINYINT', 'constraint' => 1, 'null' => true], + 'points_awarded' => ['type' => 'DECIMAL', 'constraint' => '8,2', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['student_assessment_id', 'question_id'], 'uq_student_answer_question'); + $this->forge->addForeignKey('student_assessment_id', 'student_assessments', 'id', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('question_id', 'assessment_questions', 'id', 'RESTRICT', 'CASCADE'); + $this->forge->createTable('student_answers', true); + } + } + + public function down() + { + foreach (['student_answers', 'student_assessments', 'assessment_form_questions', 'assessment_forms', 'assessment_questions', 'question_pools'] as $table) { + $this->forge->dropTable($table, true); + } + } +} diff --git a/app/Database/Migrations/2026-09-09-000200_AddAssessmentNavigation.php b/app/Database/Migrations/2026-09-09-000200_AddAssessmentNavigation.php new file mode 100644 index 0000000..3b9dc2e --- /dev/null +++ b/app/Database/Migrations/2026-09-09-000200_AddAssessmentNavigation.php @@ -0,0 +1,85 @@ + 'Assessment Management', + 'student/assessments' => 'My Assessments', + ]; + + public function up(): void + { + if (! $this->db->tableExists('nav_items')) return; + $parentColumn = $this->parentColumn(); + $studentAffairsId = null; + if ($parentColumn !== null) { + $parent = $this->db->table('nav_items')->select('id')->where('label', 'Student-Affairs')->where($parentColumn, null)->get()->getRowArray(); + $studentAffairsId = $parent ? (int) $parent['id'] : null; + } + $adminId = $this->upsertItem(self::ITEMS['administrator/assessments'], 'administrator/assessments', $studentAffairsId, 7); + $studentId = $this->upsertItem(self::ITEMS['student/assessments'], 'student/assessments', null, 80); + if ($this->db->tableExists('role_nav_items') && $this->db->tableExists('roles')) { + $roles = $this->db->table('roles')->select('id, name')->get()->getResultArray(); + foreach ($roles as $role) { + $token = strtolower(str_replace([' ', '-'], '_', trim((string) ($role['name'] ?? '')))); + if (in_array($token, ['parent', 'student'], true)) { + $this->grant((int) $role['id'], $studentId); + } elseif (! in_array($token, ['guest', 'teacher', 'teacher_assistant', 'assistant_teacher', 'ta'], true)) { + $this->grant((int) $role['id'], $adminId); + } + } + } + cache()->clean(); + } + + public function down(): void + { + if (! $this->db->tableExists('nav_items')) return; + foreach (array_keys(self::ITEMS) as $url) { + $row = $this->db->table('nav_items')->select('id')->where('url', $url)->get()->getRowArray(); + if ($row && $this->db->tableExists('role_nav_items')) { + $this->db->table('role_nav_items')->where('nav_item_id', (int) $row['id'])->delete(); + } + $this->db->table('nav_items')->where('url', $url)->delete(); + } + cache()->clean(); + } + + private function upsertItem(string $label, string $url, ?int $parentId, int $sort): int + { + $existing = $this->db->table('nav_items')->select('id')->where('url', $url)->get()->getRowArray(); + $data = ['label' => $label, 'is_enabled' => 1, 'sort_order' => $sort, 'updated_at' => date('Y-m-d H:i:s')]; + $parentColumn = $this->parentColumn(); + if ($parentColumn !== null) $data[$parentColumn] = $parentId; + if ($this->db->fieldExists('icon_class', 'nav_items')) $data['icon_class'] = 'bi bi-ui-checks-grid'; + if ($existing) { + $this->db->table('nav_items')->where('id', (int) $existing['id'])->update($data); + return (int) $existing['id']; + } + $data += ['url' => $url, 'created_at' => date('Y-m-d H:i:s')]; + $this->db->table('nav_items')->insert($data); + return (int) $this->db->insertID(); + } + + private function grant(int $roleId, int $navItemId): void + { + if ($roleId <= 0 || $navItemId <= 0) return; + $builder = $this->db->table('role_nav_items'); + if ($builder->where(['role_id' => $roleId, 'nav_item_id' => $navItemId])->countAllResults() > 0) return; + $data = ['role_id' => $roleId, 'nav_item_id' => $navItemId]; + if ($this->db->fieldExists('created_at', 'role_nav_items')) $data['created_at'] = date('Y-m-d H:i:s'); + if ($this->db->fieldExists('updated_at', 'role_nav_items')) $data['updated_at'] = date('Y-m-d H:i:s'); + $this->db->table('role_nav_items')->insert($data); + } + + private function parentColumn(): ?string + { + if ($this->db->fieldExists('menu_parent_id', 'nav_items')) return 'menu_parent_id'; + if ($this->db->fieldExists('parent_id', 'nav_items')) return 'parent_id'; + return null; + } +} diff --git a/app/Database/Migrations/2026-09-10-000100_SeedNewStudentAssessmentQuestions.php b/app/Database/Migrations/2026-09-10-000100_SeedNewStudentAssessmentQuestions.php new file mode 100644 index 0000000..184123b --- /dev/null +++ b/app/Database/Migrations/2026-09-10-000100_SeedNewStudentAssessmentQuestions.php @@ -0,0 +1,129 @@ + 'essay', + 'text' => 'What is the student’s Islamic education background?', + ], + [ + 'type' => 'short_answer', + 'text' => 'What is the student’s Arabic competency level?', + ], + [ + 'type' => 'short_answer', + 'text' => 'What is the student’s English competency level?', + ], + [ + 'type' => 'essay', + 'text' => 'Has the student attended any Sunday/Islamic school in the past? If YES, which one?', + ], + [ + 'type' => 'essay', + 'text' => 'Has the student memorized any Surahs? If yes, which one(s)?', + ], + [ + 'type' => 'essay', + 'text' => 'Why have the parents chosen AlRahma Sunday School for their child(ren)?', + ], + [ + 'type' => 'essay', + 'text' => 'Any serious/special medical condition about the student which we have to be aware of?', + ], + [ + 'type' => 'essay', + 'text' => 'If surnames of enrolled students are different, please confirm if they are all siblings; if NOT, then please specify their relationship to each other:', + ], + [ + 'type' => 'essay', + 'text' => 'Any other questions / concerns that the parents may have?', + ], + ]; + + public function up(): void + { + if (! $this->db->tableExists('question_pools') || ! $this->db->tableExists('assessment_questions')) { + return; + } + + $pool = $this->db->table('question_pools') + ->select('id') + ->where('name', self::POOL_NAME) + ->get() + ->getRowArray(); + + if ($pool === null) { + $now = date('Y-m-d H:i:s'); + $this->db->table('question_pools')->insert([ + 'name' => self::POOL_NAME, + 'subject_tag' => 'New Student Intake', + 'created_by' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $poolId = (int) $this->db->insertID(); + } else { + $poolId = (int) $pool['id']; + } + + if ($poolId <= 0) return; + + $now = date('Y-m-d H:i:s'); + $maxOrder = $this->db->table('assessment_questions')->selectMax('order_index')->where('pool_id', $poolId)->get()->getRowArray(); + $nextOrder = ((int) ($maxOrder['order_index'] ?? 0)) + 1; + foreach (self::QUESTIONS as $question) { + $exists = $this->db->table('assessment_questions') + ->where('pool_id', $poolId) + ->where('text', $question['text']) + ->countAllResults() > 0; + if ($exists) continue; + + $this->db->table('assessment_questions')->insert([ + 'pool_id' => $poolId, + 'type' => $question['type'], + 'text' => $question['text'], + 'options' => null, + 'correct_answer' => null, + 'points' => 0, + 'order_index' => $nextOrder++, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + if (! $this->db->tableExists('question_pools') || ! $this->db->tableExists('assessment_questions')) { + return; + } + + $pool = $this->db->table('question_pools')->select('id')->where('name', self::POOL_NAME)->get()->getRowArray(); + if ($pool === null) return; + + $poolId = (int) $pool['id']; + foreach (array_column(self::QUESTIONS, 'text') as $text) { + $question = $this->db->table('assessment_questions')->select('id')->where(['pool_id' => $poolId, 'text' => $text])->get()->getRowArray(); + if ($question === null) continue; + if ($this->db->tableExists('assessment_form_questions') + && $this->db->table('assessment_form_questions')->where('question_id', (int) $question['id'])->countAllResults() > 0) { + continue; + } + $this->db->table('assessment_questions')->where('id', (int) $question['id'])->delete(); + } + + $hasQuestions = $this->db->table('assessment_questions')->where('pool_id', $poolId)->countAllResults() > 0; + $hasForms = $this->db->tableExists('assessment_forms') + && $this->db->table('assessment_forms')->where('pool_id', $poolId)->countAllResults() > 0; + if (! $hasQuestions && ! $hasForms) { + $this->db->table('question_pools')->where('id', $poolId)->delete(); + } + } +} diff --git a/app/Database/Migrations/2026-09-10-000200_AddEducationCommitteeNoteToAssessmentForms.php b/app/Database/Migrations/2026-09-10-000200_AddEducationCommitteeNoteToAssessmentForms.php new file mode 100644 index 0000000..c7fff47 --- /dev/null +++ b/app/Database/Migrations/2026-09-10-000200_AddEducationCommitteeNoteToAssessmentForms.php @@ -0,0 +1,32 @@ +db->tableExists('assessment_forms') + || $this->db->fieldExists('education_committee_note', 'assessment_forms')) { + return; + } + + $this->forge->addColumn('assessment_forms', [ + 'education_committee_note' => [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'status', + ], + ]); + } + + public function down(): void + { + if ($this->db->tableExists('assessment_forms') + && $this->db->fieldExists('education_committee_note', 'assessment_forms')) { + $this->forge->dropColumn('assessment_forms', 'education_committee_note'); + } + } +} diff --git a/app/Database/Migrations/2026-09-10-000300_AddSchoolYearToAssessmentForms.php b/app/Database/Migrations/2026-09-10-000300_AddSchoolYearToAssessmentForms.php new file mode 100644 index 0000000..5957e1c --- /dev/null +++ b/app/Database/Migrations/2026-09-10-000300_AddSchoolYearToAssessmentForms.php @@ -0,0 +1,53 @@ +db->tableExists('assessment_forms')) return; + + if (! $this->db->fieldExists('school_year', 'assessment_forms')) { + $this->forge->addColumn('assessment_forms', [ + 'school_year' => [ + 'type' => 'VARCHAR', + 'constraint' => 9, + 'null' => true, + 'after' => 'pool_id', + ], + ]); + } + + $schoolYear = $this->activeSchoolYear(); + if ($schoolYear !== null) { + $this->db->table('assessment_forms') + ->groupStart()->where('school_year', null)->orWhere('school_year', '')->groupEnd() + ->update(['school_year' => $schoolYear]); + } + } + + public function down(): void + { + if ($this->db->tableExists('assessment_forms') && $this->db->fieldExists('school_year', 'assessment_forms')) { + $this->forge->dropColumn('assessment_forms', 'school_year'); + } + } + + private function activeSchoolYear(): ?string + { + if ($this->db->tableExists('school_years')) { + $row = $this->db->table('school_years')->select('name')->where('status', 'active')->orderBy('id', 'DESC')->get()->getRowArray(); + $name = trim((string) ($row['name'] ?? '')); + if (preg_match('/^\d{4}-\d{4}$/', $name)) return $name; + } + if ($this->db->tableExists('configuration')) { + $row = $this->db->table('configuration')->select('config_value')->where('config_key', 'school_year')->orderBy('id', 'DESC')->get()->getRowArray(); + $name = trim((string) ($row['config_value'] ?? '')); + if (preg_match('/^\d{4}-\d{4}$/', $name)) return $name; + } + return null; + } +} diff --git a/app/Database/Migrations/2026-09-10-000400_AddEducationCommitteeNoteToStudentAssessments.php b/app/Database/Migrations/2026-09-10-000400_AddEducationCommitteeNoteToStudentAssessments.php new file mode 100644 index 0000000..c0be29b --- /dev/null +++ b/app/Database/Migrations/2026-09-10-000400_AddEducationCommitteeNoteToStudentAssessments.php @@ -0,0 +1,32 @@ +db->tableExists('student_assessments') + || $this->db->fieldExists('education_committee_note', 'student_assessments')) { + return; + } + + $this->forge->addColumn('student_assessments', [ + 'education_committee_note' => [ + 'type' => 'TEXT', + 'null' => true, + 'after' => 'score', + ], + ]); + } + + public function down(): void + { + if ($this->db->tableExists('student_assessments') + && $this->db->fieldExists('education_committee_note', 'student_assessments')) { + $this->forge->dropColumn('student_assessments', 'education_committee_note'); + } + } +} diff --git a/app/Filters/AuthFilter.php b/app/Filters/AuthFilter.php index 566f3e9..41f97f7 100644 --- a/app/Filters/AuthFilter.php +++ b/app/Filters/AuthFilter.php @@ -163,6 +163,9 @@ class AuthFilter implements FilterInterface foreach ($alternatives as $alternative) { $candidate = strtolower($alternative); + if ($candidate === 'admin_category' && (bool) session()->get('is_admin')) { + return true; + } if (in_array($candidate, $normalizedRoles, true)) { return true; } diff --git a/app/Filters/SchoolYearWritableFilter.php b/app/Filters/SchoolYearWritableFilter.php index 5bf1761..0e19d11 100644 --- a/app/Filters/SchoolYearWritableFilter.php +++ b/app/Filters/SchoolYearWritableFilter.php @@ -29,6 +29,9 @@ final class SchoolYearWritableFilter implements FilterInterface 'user/select_role', 'set-role', 'parent_dashboard/job-openings-popup', + // Assessments are globally shared and are not tied to a school-year record. + 'administrator/assessments', + 'student/assessments', 'processForgotPassword', 'user/forgot_password', 'user/processResetPassword', diff --git a/app/Models/AssessmentFormModel.php b/app/Models/AssessmentFormModel.php new file mode 100644 index 0000000..f78c6b5 --- /dev/null +++ b/app/Models/AssessmentFormModel.php @@ -0,0 +1,14 @@ +Allergies Photo Consent Registration Date + Assessment Action @@ -179,6 +180,20 @@ $selectedYear = trim((string)($selectedYear ?? '')); + + + +
+ + View Status +
+ + Review + + View Results +
Reviewed
+ + + + + +endSection() ?> diff --git a/app/Views/assessments/form_builder.php b/app/Views/assessments/form_builder.php new file mode 100644 index 0000000..41cb36a --- /dev/null +++ b/app/Views/assessments/form_builder.php @@ -0,0 +1,32 @@ +extend('layout/management_layout') ?> +section('content') ?> + $selectedSchoolYear, 'status' => '']; +?> +
+

Assessment Form

Choose questions from one shared pool and set their order.

Back
+ include('assessments/_alerts') ?> +
This form has student assignments. Its school year, pool, and question list are locked, but its name, note, and status can still be changed.
+
+
+
+
+
+
+
+
+
Pick individual questions or select “Use all questions.”
+
Questions
+

There are no questions available. Add questions to a pool first.

+

Select a pool to see its questions.

+ $question): ?>
>
>
+
+ +
+
+ +endSection() ?> diff --git a/app/Views/assessments/forms.php b/app/Views/assessments/forms.php new file mode 100644 index 0000000..66c0d07 --- /dev/null +++ b/app/Views/assessments/forms.php @@ -0,0 +1,43 @@ +extend('layout/management_layout') ?> +section('content') ?> +
+
+

Assessment Forms

Build, publish, preview, and track individual assessments.

+ +
+ include('assessments/_alerts') ?> +
+ + + + + + + + + + + + + + + + + +
NameSchool YearPoolEducation Committee NoteStatusQuestionsAssignmentsActions
No assessment forms yet.
+ Edit + Preview + +
+ + +
+ +
+
+
+endSection() ?> diff --git a/app/Views/assessments/grade.php b/app/Views/assessments/grade.php new file mode 100644 index 0000000..36571a2 --- /dev/null +++ b/app/Views/assessments/grade.php @@ -0,0 +1,9 @@ +extend('layout/management_layout') ?> +section('content') ?> +

Review:

·

Back
include('assessments/_alerts') ?> +
+$q): ?>
.
Response
+
This note remains editable and saves automatically.
+
+ +endSection() ?> diff --git a/app/Views/assessments/my_assessments.php b/app/Views/assessments/my_assessments.php new file mode 100644 index 0000000..52449d1 --- /dev/null +++ b/app/Views/assessments/my_assessments.php @@ -0,0 +1,9 @@ +extend('layout/management_layout') ?> +section('content') ?> +

My Assessments

Open assigned assessments, continue saved work, or review completed responses.

include('assessments/_alerts') ?> +
There are no assigned assessments.
+

+ View ResponsesAwaiting review +
+
+endSection() ?> diff --git a/app/Views/assessments/new_students.php b/app/Views/assessments/new_students.php new file mode 100644 index 0000000..b87ed3d --- /dev/null +++ b/app/Views/assessments/new_students.php @@ -0,0 +1,25 @@ +extend('layout/management_layout') ?> +section('content') ?> +
+
+

New Student Assessments

New students from the enrollment roster.

+ Assessment Forms +
+ include('assessments/_alerts') ?> +
+ + + + +
StudentSchool IDAgeCurrent ClassEnrollment StatusAssessment
No new students found for this school year.
+
+ View Status
+ Review + View Results +
+
+
+endSection() ?> +section('scripts') ?> + +endSection() ?> diff --git a/app/Views/assessments/pool.php b/app/Views/assessments/pool.php new file mode 100644 index 0000000..77b8f9d --- /dev/null +++ b/app/Views/assessments/pool.php @@ -0,0 +1,35 @@ +extend('layout/management_layout') ?> +section('content') ?> + 'Multiple choice', 'short_answer' => 'Short answer', 'true_false' => 'True / False', 'essay' => 'Essay']; ?> +
+

Question Pool

Add, edit, delete, or reorder questions in the shared assessment pool.

Assessment Forms
+ include('assessments/_alerts') ?> +
Add a question
+
+
+
+
+
+ +
+
+
This pool has no questions yet.
+ $question): ?> + +
# +
+
+
+
+
+
+
+
+ +
+
+
+ +
+ +endSection() ?> diff --git a/app/Views/assessments/results.php b/app/Views/assessments/results.php new file mode 100644 index 0000000..18e4a6d --- /dev/null +++ b/app/Views/assessments/results.php @@ -0,0 +1,9 @@ +extend('layout/management_layout') ?> +section('content') ?> +
+
+

Reviewed
include('assessments/_alerts') ?> +$q): ?>
.

Response:

+
This note remains editable and saves automatically.
+ +endSection() ?> diff --git a/app/Views/assessments/student_assignments.php b/app/Views/assessments/student_assignments.php new file mode 100644 index 0000000..8c0b279 --- /dev/null +++ b/app/Views/assessments/student_assignments.php @@ -0,0 +1,17 @@ +extend('layout/management_layout') ?> +section('content') ?> + +
+

Assessments:

Back to Students
+ include('assessments/_alerts') ?> +
Assign a published form
+

No published assessment forms are available.

+ +
FormPoolQuestionsStatus / Action
+
+
Assignment history
+ + +
FormSchool YearAssignedStatusAction
Nothing assigned yet.
ReviewView ResultsView Status
+
+endSection() ?> diff --git a/app/Views/assessments/take.php b/app/Views/assessments/take.php new file mode 100644 index 0000000..01dd8f1 --- /dev/null +++ b/app/Views/assessments/take.php @@ -0,0 +1,22 @@ +extend('layout/management_layout') ?> +section('content') ?> + +

· questions

Exit Preview
+
Preview mode — answers cannot be saved or submitted.
Changes are saved as you work.
+
+ $q): $options=(array)json_decode((string)($q['options']??'[]'),true); $value=(string)($answers[$q['id']]??''); ?> +
>
+
>
+ + +
+ +
+
+
+ +endSection() ?> diff --git a/app/Views/enroll_withdraw/new-students.php b/app/Views/enroll_withdraw/new-students.php index ef2f1d1..45f3c80 100644 --- a/app/Views/enroll_withdraw/new-students.php +++ b/app/Views/enroll_withdraw/new-students.php @@ -19,7 +19,7 @@
- +
@@ -30,6 +30,7 @@ + @@ -107,6 +108,20 @@ + + - + @@ -169,52 +184,7 @@ section('scripts') ?> endSection() ?> diff --git a/tests/app/Config/AssessmentFeatureIntegrityTest.php b/tests/app/Config/AssessmentFeatureIntegrityTest.php new file mode 100644 index 0000000..e53b1e5 --- /dev/null +++ b/tests/app/Config/AssessmentFeatureIntegrityTest.php @@ -0,0 +1,144 @@ +assertStringContainsString("group('administrator/assessments'", $routes); + $this->assertStringContainsString("\$assessmentAdminFilter = 'auth:admin_category'", $routes); + $this->assertStringContainsString('AssessmentController::saveGrade', $routes); + $this->assertStringContainsString("group('student/assessments'", $routes); + $this->assertStringContainsString("'filter' => 'auth:student|parent'", $routes); + } + + public function testMigrationEnforcesSingleAssignmentAndAnswerPerQuestion(): void + { + $migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-09-000100_CreateStudentAssessmentTables.php') ?: ''; + + $this->assertStringContainsString("addUniqueKey(['form_id', 'student_id'], 'uq_assessment_form_student')", $migration); + $this->assertStringContainsString("addUniqueKey(['student_assessment_id', 'question_id'], 'uq_student_answer_question')", $migration); + $this->assertStringContainsString("['not_started', 'in_progress', 'completed', 'graded']", $migration); + } + + public function testStudentProfilesExposeAssessmentActions(): void + { + $view = file_get_contents(ROOTPATH . 'app/Views/administrator/student_profiles.php') ?: ''; + + foreach (['Assign Assessment', 'View Status', 'Review', 'View Results'] as $label) { + $this->assertStringContainsString($label, $view); + } + } + + public function testAssessmentRosterUsesTheNewStudentEnrollmentSource(): void + { + $controller = file_get_contents(ROOTPATH . 'app/Controllers/View/AssessmentController.php') ?: ''; + $newStudentView = file_get_contents(ROOTPATH . 'app/Views/enroll_withdraw/new-students.php') ?: ''; + + $this->assertStringContainsString("service('enrollmentWithdrawal')->newStudents(\$schoolYear)", $controller); + $this->assertStringContainsString('administrator/assessments/students/', $newStudentView); + $this->assertStringContainsString('Assign Assessment', $newStudentView); + } + + public function testSharedAssessmentsAreNotBlockedByHistoricalYearContext(): void + { + $filter = file_get_contents(ROOTPATH . 'app/Filters/SchoolYearWritableFilter.php') ?: ''; + + $this->assertStringContainsString("'administrator/assessments'", $filter); + $this->assertStringContainsString("'student/assessments'", $filter); + } + + public function testNewStudentQuestionPoolSeedsAllIntakeQuestions(): void + { + $migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-10-000100_SeedNewStudentAssessmentQuestions.php') ?: ''; + + $this->assertStringContainsString("POOL_NAME = 'New Student Assessment'", $migration); + preg_match_all("/^ 'text' => '[^\n]+',$/m", $migration, $questionLines); + $this->assertCount(9, $questionLines[0]); + $this->assertStringContainsString('Arabic competency level', $migration); + $this->assertStringContainsString('questions / concerns', $migration); + } + + public function testAdminInterviewAutosavesWithoutSaveButton(): void + { + $view = file_get_contents(ROOTPATH . 'app/Views/assessments/admin_take.php') ?: ''; + + $this->assertStringContainsString('Responses save automatically as you type.', $view); + $this->assertStringContainsString('/progress', $view); + $this->assertStringContainsString('Complete Assessment', $view); + $this->assertStringNotContainsString('Save Progress', $view); + } + + public function testAssessmentFormsIncludeEducationCommitteeNote(): void + { + $builder = file_get_contents(ROOTPATH . 'app/Views/assessments/form_builder.php') ?: ''; + $migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-10-000200_AddEducationCommitteeNoteToAssessmentForms.php') ?: ''; + + $this->assertStringContainsString('Education Committee Note', $builder); + $this->assertStringContainsString('education_committee_note', $builder); + $this->assertStringContainsString("'type' => 'TEXT'", $migration); + } + + public function testStudentAssessmentHasAutosavingEducationCommitteeNote(): void + { + $view = file_get_contents(ROOTPATH . 'app/Views/assessments/admin_take.php') ?: ''; + $review = file_get_contents(ROOTPATH . 'app/Views/assessments/grade.php') ?: ''; + $results = file_get_contents(ROOTPATH . 'app/Views/assessments/results.php') ?: ''; + $routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php') ?: ''; + $controller = file_get_contents(ROOTPATH . 'app/Controllers/View/AssessmentController.php') ?: ''; + $migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-10-000400_AddEducationCommitteeNoteToStudentAssessments.php') ?: ''; + + $this->assertStringContainsString('Education Committee Note', $view); + $this->assertStringContainsString('name="education_committee_note"', $view); + $this->assertStringContainsString('assessment-answer', $view); + $this->assertStringContainsString('persistEducationCommitteeNote', $controller); + $this->assertStringContainsString("addColumn('student_assessments'", $migration); + $this->assertStringContainsString('name="education_committee_note"', $review); + $this->assertStringContainsString('name="education_committee_note"', $results); + $this->assertStringContainsString("attempts/(:num)/note", $routes); + $this->assertStringContainsString('saveAdminNote', $controller); + $this->assertStringContainsString('saves automatically', $review); + } + + public function testSchoolYearBelongsOnlyToAssessmentForms(): void + { + $migration = file_get_contents(ROOTPATH . 'app/Database/Migrations/2026-09-10-000300_AddSchoolYearToAssessmentForms.php') ?: ''; + $model = file_get_contents(ROOTPATH . 'app/Models/AssessmentFormModel.php') ?: ''; + $controller = file_get_contents(ROOTPATH . 'app/Controllers/View/AssessmentController.php') ?: ''; + + $this->assertStringContainsString("addColumn('assessment_forms'", $migration); + $this->assertStringNotContainsString("addColumn('student_assessments'", $migration); + $this->assertStringNotContainsString("addColumn('student_answers'", $migration); + $this->assertStringContainsString("'school_year'", $model); + $this->assertStringContainsString("where('f.school_year', \$schoolYear)", $controller); + } + + public function testAssessmentsAreQualitativeAndHaveNoPointControls(): void + { + $pool = file_get_contents(ROOTPATH . 'app/Views/assessments/pool.php') ?: ''; + $take = file_get_contents(ROOTPATH . 'app/Views/assessments/take.php') ?: ''; + $review = file_get_contents(ROOTPATH . 'app/Views/assessments/grade.php') ?: ''; + $results = file_get_contents(ROOTPATH . 'app/Views/assessments/results.php') ?: ''; + + $this->assertStringNotContainsString('name="points"', $pool); + $this->assertStringNotContainsString('total points', $take); + $this->assertStringNotContainsString('points_awarded', $review); + $this->assertStringNotContainsString('is_correct', $review); + $this->assertStringNotContainsString("assessment['score']", $results); + $this->assertStringContainsString('Complete Review', $review); + } + + public function testAssessmentResultsProvideReturnNavigation(): void + { + $results = file_get_contents(ROOTPATH . 'app/Views/assessments/results.php') ?: ''; + + $this->assertStringContainsString('Back to Students', $results); + $this->assertStringContainsString("site_url('administrator/assessments/students')", $results); + $this->assertStringContainsString('Back to My Assessments', $results); + } +}
Actual Status Age Registration Date Assessment Action
+ + +
+ + View Status +
+ + Review + + View Results + +
No students found.No students found.