add assessment feature
Deploy to Shared Hosting / Shared hosting deploy (push) Failing after 49s
Tests / PHPUnit (push) Successful in 1m36s

This commit is contained in:
root
2026-09-10 06:44:24 -04:00
parent ac19226bf0
commit b5e719382d
31 changed files with 1712 additions and 65 deletions
+39
View File
@@ -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
@@ -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;
}
}
@@ -0,0 +1,681 @@
<?php
namespace App\Controllers\View;
use App\Controllers\BaseController;
use App\Models\AssessmentFormModel;
use App\Models\AssessmentQuestionModel;
use App\Models\QuestionPoolModel;
use App\Models\StudentAssessmentModel;
use App\Models\StudentModel;
use CodeIgniter\Exceptions\PageNotFoundException;
class AssessmentController extends BaseController
{
private $db;
private QuestionPoolModel $poolModel;
private AssessmentQuestionModel $questionModel;
private AssessmentFormModel $formModel;
private StudentAssessmentModel $assessmentModel;
private StudentModel $studentModel;
public function __construct()
{
helper(['form']);
$this->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)));
}
}
@@ -0,0 +1,122 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateStudentAssessmentTables extends Migration
{
public function up()
{
if (! $this->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);
}
}
}
@@ -0,0 +1,85 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddAssessmentNavigation extends Migration
{
private const ITEMS = [
'administrator/assessments' => '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;
}
}
@@ -0,0 +1,129 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class SeedNewStudentAssessmentQuestions extends Migration
{
private const POOL_NAME = 'New Student Assessment';
private const QUESTIONS = [
[
'type' => 'essay',
'text' => 'What is the students Islamic education background?',
],
[
'type' => 'short_answer',
'text' => 'What is the students Arabic competency level?',
],
[
'type' => 'short_answer',
'text' => 'What is the students 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();
}
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddEducationCommitteeNoteToAssessmentForms extends Migration
{
public function up(): void
{
if (! $this->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');
}
}
}
@@ -0,0 +1,53 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddSchoolYearToAssessmentForms extends Migration
{
public function up(): void
{
if (! $this->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;
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddEducationCommitteeNoteToStudentAssessments extends Migration
{
public function up(): void
{
if (! $this->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');
}
}
}
+3
View File
@@ -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;
}
+3
View File
@@ -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',
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AssessmentFormModel extends Model
{
protected $table = 'assessment_forms';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['name', 'pool_id', 'school_year', 'created_by', 'status', 'education_committee_note'];
protected $useTimestamps = true;
}
@@ -0,0 +1,17 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AssessmentFormQuestionModel extends Model
{
protected $table = 'assessment_form_questions';
// CI's Model API accepts one primary-key field; the database enforces the
// actual composite key (form_id, question_id).
protected $primaryKey = 'form_id';
protected $useAutoIncrement = false;
protected $returnType = 'array';
protected $allowedFields = ['form_id', 'question_id', 'order_index'];
protected $useTimestamps = false;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class AssessmentQuestionModel extends Model
{
protected $table = 'assessment_questions';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['pool_id', 'type', 'text', 'options', 'correct_answer', 'points', 'order_index'];
protected $useTimestamps = true;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class QuestionPoolModel extends Model
{
protected $table = 'question_pools';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['name', 'subject_tag', 'created_by'];
protected $useTimestamps = true;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class StudentAnswerModel extends Model
{
protected $table = 'student_answers';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['student_assessment_id', 'question_id', 'answer_value', 'is_correct', 'points_awarded'];
protected $useTimestamps = true;
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class StudentAssessmentModel extends Model
{
protected $table = 'student_assessments';
protected $primaryKey = 'id';
protected $returnType = 'array';
protected $allowedFields = ['form_id', 'student_id', 'status', 'assigned_at', 'started_at', 'submitted_at', 'graded_at', 'graded_by', 'score', 'education_committee_note'];
protected $useTimestamps = true;
}
@@ -91,6 +91,7 @@ $selectedYear = trim((string)($selectedYear ?? ''));
<th>Allergies</th>
<th>Photo Consent</th>
<th>Registration Date</th>
<th style="min-width: 170px;">Assessment</th>
<th style="min-width: 220px;">Action</th>
</tr>
</thead>
@@ -179,6 +180,20 @@ $selectedYear = trim((string)($selectedYear ?? ''));
<td><?= esc($student['allergies'] ?? '') ?></td>
<td><?= !empty($student['photo_consent']) ? 'Yes' : 'No' ?></td>
<td><?= esc($regDisp) ?></td>
<td>
<?php $studentAssessment = ($assessmentByStudent ?? [])[(int)$student['id']] ?? null; ?>
<?php if (!$studentAssessment): ?>
<form method="post" action="<?= site_url('administrator/assessments/students/' . $student['id'] . '/start') ?>"><?= csrf_field() ?><button class="btn btn-sm btn-outline-primary">Assign Assessment</button></form>
<?php elseif (in_array($studentAssessment['status'], ['not_started', 'in_progress'], true)): ?>
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/students/' . $student['id']) ?>">View Status</a>
<div class="small text-muted mt-1"><?= esc(ucwords(str_replace('_', ' ', $studentAssessment['status']))) ?></div>
<?php elseif ($studentAssessment['status'] === 'completed'): ?>
<a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/' . $studentAssessment['id']) ?>">Review</a>
<?php else: ?>
<a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/' . $studentAssessment['id']) ?>">View Results</a>
<div class="small text-muted mt-1">Reviewed</div>
<?php endif; ?>
</td>
<td class="d-flex flex-wrap gap-2">
<!-- Contact Modal Trigger -->
<button class="btn btn-warning btn-sm" data-bs-toggle="modal" data-bs-target="#<?= $modalIdContact ?>">
+11
View File
@@ -0,0 +1,11 @@
<?php if (session()->getFlashdata('success')): ?>
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('error')): ?>
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
<?php endif; ?>
<?php if (session()->getFlashdata('errors')): ?>
<div class="alert alert-danger"><ul class="mb-0">
<?php foreach ((array) session()->getFlashdata('errors') as $error): ?><li><?= esc($error) ?></li><?php endforeach; ?>
</ul></div>
<?php endif; ?>
+21
View File
@@ -0,0 +1,21 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container py-4" style="max-width:900px">
<div class="d-flex justify-content-between align-items-start mb-3"><div><h2><?= esc($assessment['form_name']) ?></h2><p class="text-muted mb-0">Interviewing <?= esc($assessment['firstname'].' '.$assessment['lastname']) ?> · <?= esc($assessment['school_year']) ?></p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/students') ?>">New Students</a></div>
<?= $this->include('assessments/_alerts') ?>
<?php if(!empty($assessment['form_education_committee_note'])): ?><div class="alert alert-info"><strong>Form instructions</strong><div class="mt-1" style="white-space:pre-wrap"><?= esc($assessment['form_education_committee_note']) ?></div></div><?php endif; ?>
<div id="saveNotice" class="alert alert-light border py-2">Responses save automatically as you type.</div>
<form id="adminAssessmentForm" method="post" action="<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/complete') ?>"><?= csrf_field() ?>
<?php foreach($questions as $index=>$q): ?><div class="card shadow-sm mb-3"><div class="card-body"><label class="form-label fw-semibold" for="answer-<?= (int)$q['id'] ?>"><?= $index+1 ?>. <?= esc($q['text']) ?></label>
<?php if($q['type']==='short_answer'): ?><input id="answer-<?= (int)$q['id'] ?>" class="form-control assessment-answer" name="answers[<?= (int)$q['id'] ?>]" value="<?= esc($q['answer_value']??'') ?>" autocomplete="off">
<?php else: ?><textarea id="answer-<?= (int)$q['id'] ?>" class="form-control assessment-answer" name="answers[<?= (int)$q['id'] ?>]" rows="4"><?= esc($q['answer_value']??'') ?></textarea><?php endif; ?>
</div></div><?php endforeach; ?>
<div class="card shadow-sm mb-3"><div class="card-body"><label class="form-label fw-semibold" for="educationCommitteeNote">Education Committee Note</label><textarea id="educationCommitteeNote" class="form-control assessment-answer" name="education_committee_note" rows="5" maxlength="10000" placeholder="Add the Education Committee's note for this student."><?= esc($assessment['education_committee_note'] ?? '') ?></textarea><div class="form-text">This note is saved automatically with the responses.</div></div></div>
<button class="btn btn-success" onclick="return confirm('Complete this assessment and review the responses?')">Complete Assessment</button>
</form>
</div>
<script>(function(){var form=document.getElementById('adminAssessmentForm'),notice=document.getElementById('saveNotice'),timer=null,savePromise=null,pending=false,completing=false;
async function save(){if(savePromise){pending=true;return savePromise}savePromise=(async function(){notice.textContent='Saving…';try{var response=await fetch('<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/progress') ?>',{method:'POST',body:new FormData(form),headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),data=await response.json();if(!response.ok)throw new Error(data.message||'Save failed');if(data.csrfName&&data.csrfHash){var token=form.querySelector('input[name="'+data.csrfName+'"]');if(token)token.value=data.csrfHash}notice.textContent='All responses saved.'}catch(e){notice.textContent='Autosave failed. Keep this page open and continue typing while it retries.'}})();await savePromise;savePromise=null;if(pending){pending=false;return save()}}
document.querySelectorAll('.assessment-answer').forEach(function(field){field.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,700)});field.addEventListener('change',save)});
form.addEventListener('submit',async function(event){if(completing)return;event.preventDefault();clearTimeout(timer);await save();completing=true;form.submit()});})();</script>
<?= $this->endSection() ?>
+32
View File
@@ -0,0 +1,32 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php
$isEdit = !empty($form);
$singlePool = count($pools) === 1;
$action = $isEdit ? site_url('administrator/assessments/forms/' . $form['id']) : site_url('administrator/assessments/forms');
$yearNames = array_column($schoolYears ?? [], 'name');
if ($selectedSchoolYear !== '' && !in_array($selectedSchoolYear, $yearNames, true)) $schoolYears[] = ['name' => $selectedSchoolYear, 'status' => ''];
?>
<div class="container py-3" style="max-width:1000px">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2><?= $isEdit ? 'Edit' : 'Create' ?> Assessment Form</h2><p class="text-muted mb-0">Choose questions from one shared pool and set their order.</p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Back</a></div>
<?= $this->include('assessments/_alerts') ?>
<?php if ($locked): ?><div class="alert alert-info">This form has student assignments. Its school year, pool, and question list are locked, but its name, note, and status can still be changed.</div><?php endif; ?>
<form method="post" action="<?= $action ?>"><?= csrf_field() ?>
<div class="card shadow-sm mb-3"><div class="card-body"><div class="row g-3">
<div class="col-md-4"><label class="form-label">Form name</label><input class="form-control" name="name" maxlength="150" required value="<?= set_value('name', $form['name'] ?? '') ?>"></div>
<div class="col-md-3"><label class="form-label">Question pool</label><select id="poolSelect" class="form-select" name="pool_id" required <?= ($isEdit || $singlePool) ? 'disabled' : '' ?>><option value="">Select a pool</option><?php foreach ($pools as $pool): ?><option value="<?= (int)$pool['id'] ?>" <?= ($singlePool || (int)($form['pool_id'] ?? 0)===(int)$pool['id']) ? 'selected' : '' ?>><?= esc($pool['name']) ?></option><?php endforeach; ?></select><?php if ($isEdit || $singlePool): ?><input type="hidden" name="pool_id" value="<?= (int)($form['pool_id'] ?? $pools[0]['id'] ?? 0) ?>"><?php endif; ?></div>
<div class="col-md-3"><label class="form-label">School year</label><select class="form-select" name="school_year" required <?= $locked ? 'disabled' : '' ?>><option value="">Select school year</option><?php foreach (($schoolYears ?? []) as $year): ?><option value="<?= esc($year['name']) ?>" <?= set_select('school_year', $year['name'], $selectedSchoolYear === $year['name']) ?>><?= esc($year['name']) ?><?= !empty($year['status']) ? ' — '.esc(ucfirst($year['status'])) : '' ?></option><?php endforeach; ?></select><?php if($locked): ?><input type="hidden" name="school_year" value="<?= esc($selectedSchoolYear) ?>"><?php endif; ?></div>
<div class="col-md-2"><label class="form-label">Status</label><select class="form-select" name="status"><option value="draft" <?= ($form['status'] ?? 'draft')==='draft'?'selected':'' ?>>Draft</option><option value="published" <?= ($form['status'] ?? '')==='published'?'selected':'' ?>>Published</option><option value="archived" <?= ($form['status'] ?? '')==='archived'?'selected':'' ?>>Archived</option></select></div>
<div class="col-12"><label class="form-label">Education Committee Note <span class="text-muted">(optional, admin only)</span></label><textarea class="form-control" name="education_committee_note" rows="3" maxlength="10000" placeholder="Add context or instructions for the Education Committee."><?= set_value('education_committee_note', $form['education_committee_note'] ?? '') ?></textarea></div>
</div></div></div>
<?php if (!$isEdit): ?><div id="newFormHint" class="alert alert-secondary">Pick individual questions or select “Use all questions.”</div><?php endif; ?>
<div class="card shadow-sm"><div class="card-header d-flex justify-content-between"><strong>Questions</strong><?php if (!$locked): ?><label><input type="checkbox" name="use_all" value="1"> Use all questions</label><?php endif; ?></div><div class="card-body">
<?php if (empty($questions)): ?><p class="text-muted">There are no questions available. Add questions to a pool first.</p><?php endif; ?>
<p id="poolQuestionHint" class="text-muted <?= $isEdit ? 'd-none' : '' ?>">Select a pool to see its questions.</p>
<?php foreach ($questions as $index => $question): ?><div class="border rounded p-3 mb-2 question-choice" data-pool-id="<?= (int)$question['pool_id'] ?>"><div class="row align-items-start"><div class="col-auto"><input class="form-check-input" type="checkbox" name="question_ids[]" value="<?= (int)$question['id'] ?>" <?= in_array((int)$question['id'], $selectedIds, true)?'checked':'' ?> <?= $locked?'disabled':'' ?>></div><div class="col"><div><?= esc($question['text']) ?></div><small class="text-muted"><?= esc(str_replace('_',' ',ucfirst($question['type']))) ?></small></div><div class="col-auto"><label class="small">Order</label><input style="width:80px" class="form-control form-control-sm" type="number" min="1" name="order_<?= (int)$question['id'] ?>" value="<?= ($pos=array_search((int)$question['id'],$selectedIds,true))!==false ? $pos+1 : $index+1 ?>" <?= $locked?'disabled':'' ?>></div></div></div><?php endforeach; ?>
</div></div>
<button class="btn btn-primary mt-3" <?= $isEdit && empty($questions) && !$locked ? 'disabled' : '' ?>>Save Form</button>
</form>
</div>
<?php if (!$isEdit): ?><script>(function(){var select=document.getElementById('poolSelect'),hint=document.getElementById('poolQuestionHint');function sync(){var pool=select.value,shown=0;document.querySelectorAll('.question-choice').forEach(function(row){var active=pool!==''&&row.dataset.poolId===pool;row.classList.toggle('d-none',!active);row.querySelectorAll('input').forEach(function(input){input.disabled=!active;if(!active&&input.type==='checkbox')input.checked=false});if(active)shown++});hint.textContent=pool===''?'Select a pool to see its questions.':(shown?'':'This pool has no questions.');hint.classList.toggle('d-none',shown>0)}select.addEventListener('change',sync);sync()})();</script><?php endif; ?>
<?= $this->endSection() ?>
+43
View File
@@ -0,0 +1,43 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div><h2 class="mb-1">Assessment Forms</h2><p class="text-muted mb-0">Build, publish, preview, and track individual assessments.</p></div>
<div class="d-flex gap-2">
<a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/students') ?>">New Students</a>
<a class="btn btn-outline-primary" href="<?= site_url('administrator/assessments/pools') ?>">Question Pool</a>
<a class="btn btn-primary" href="<?= site_url('administrator/assessments/forms/new') ?>">Create Form</a>
</div>
</div>
<?= $this->include('assessments/_alerts') ?>
<div class="card shadow-sm"><div class="card-body"><div class="table-responsive">
<table class="table align-middle no-mgmt-sticky" data-no-mgmt-sticky>
<thead><tr><th>Name</th><th>School Year</th><th>Pool</th><th>Education Committee Note</th><th>Status</th><th>Questions</th><th>Assignments</th><th>Actions</th></tr></thead>
<tbody>
<?php if (empty($forms)): ?><tr><td colspan="8" class="text-muted text-center">No assessment forms yet.</td></tr><?php endif; ?>
<?php foreach ($forms as $form): ?>
<tr>
<td><?= esc($form['name']) ?></td>
<td><?= esc($form['school_year'] ?: '—') ?></td>
<td><?= esc($form['pool_name']) ?></td>
<td style="max-width:280px;white-space:pre-wrap"><?= esc($form['education_committee_note'] ?: '—') ?></td>
<td><span class="badge <?= $form['status'] === 'published' ? 'bg-success' : ($form['status'] === 'archived' ? 'bg-secondary' : 'bg-warning text-dark') ?>"><?= esc(ucfirst($form['status'])) ?></span></td>
<td><?= (int) $form['question_count'] ?></td>
<td><?= (int) $form['assignment_count'] ?></td>
<td><div class="d-flex flex-wrap gap-1">
<a class="btn btn-sm btn-outline-primary" href="<?= site_url('administrator/assessments/forms/' . $form['id'] . '/edit') ?>">Edit</a>
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/forms/' . $form['id'] . '/preview') ?>">Preview</a>
<?php if ($form['status'] !== 'published'): ?>
<form method="post" action="<?= site_url('administrator/assessments/forms/' . $form['id'] . '/publish') ?>" onsubmit="return confirm('Publish this assessment form?')">
<?= csrf_field() ?>
<button class="btn btn-sm btn-success" <?= (int)$form['question_count'] === 0 ? 'disabled title="Add questions before publishing"' : '' ?>>Publish</button>
</form>
<?php endif; ?>
</div></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div></div></div>
</div>
<?= $this->endSection() ?>
+9
View File
@@ -0,0 +1,9 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container py-4" style="max-width:950px"><div class="d-flex justify-content-between"><div><h2>Review: <?= esc($assessment['form_name']) ?></h2><p class="text-muted"><?= esc($assessment['firstname'].' '.$assessment['lastname']) ?> · <?= esc($assessment['school_year']) ?></p></div><a class="btn btn-outline-secondary align-self-start" href="<?= site_url('administrator/assessments/students/'.$assessment['student_id']) ?>">Back</a></div><?= $this->include('assessments/_alerts') ?>
<form method="post" action="<?= site_url('administrator/assessments/review/'.$assessment['id']) ?>"><?= csrf_field() ?>
<?php foreach($questions as $index=>$q): ?><div class="card shadow-sm mb-3"><div class="card-body"><h5><?= $index+1 ?>. <?= esc($q['text']) ?></h5><div class="p-3 bg-light rounded"><strong>Response</strong><div class="mt-1" style="white-space:pre-wrap"><?= esc($q['answer_value'] ?? 'No answer') ?></div></div></div></div><?php endforeach; ?>
<div class="card shadow-sm mb-3"><div class="card-body"><label class="form-label fw-semibold" for="reviewCommitteeNote">Education Committee Note</label><textarea id="reviewCommitteeNote" class="form-control" name="education_committee_note" rows="5" maxlength="10000"><?= esc($assessment['education_committee_note'] ?? '') ?></textarea><div id="noteSaveNotice" class="form-text">This note remains editable and saves automatically.</div></div></div>
<button class="btn btn-success">Complete Review</button></form></div>
<script>(function(){var form=document.querySelector('form[action*="/review/"]'),field=document.getElementById('reviewCommitteeNote'),notice=document.getElementById('noteSaveNotice'),timer;if(!form||!field)return;async function save(){notice.textContent='Saving note…';try{var data=new FormData();var token=form.querySelector('input[type="hidden"]');if(token)data.append(token.name,token.value);data.append('education_committee_note',field.value);var response=await fetch('<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/note') ?>',{method:'POST',body:data,headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),json=await response.json();if(!response.ok)throw new Error(json.message||'Save failed');if(json.csrfName&&json.csrfHash){var csrf=form.querySelector('input[name="'+json.csrfName+'"]');if(csrf)csrf.value=json.csrfHash}notice.textContent='Note saved.'}catch(e){notice.textContent='Note could not be saved. Keep this page open and try typing again.'}}field.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,700)});field.addEventListener('change',save)})();</script>
<?= $this->endSection() ?>
+9
View File
@@ -0,0 +1,9 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container py-4" style="max-width:900px"><h2>My Assessments</h2><p class="text-muted">Open assigned assessments, continue saved work, or review completed responses.</p><?= $this->include('assessments/_alerts') ?>
<div class="row g-3"><?php if(empty($assignments)): ?><div class="col-12"><div class="alert alert-info">There are no assigned assessments.</div></div><?php endif; ?>
<?php foreach($assignments as $a): ?><div class="col-md-6"><div class="card h-100 shadow-sm"><div class="card-body"><h5><?= esc($a['form_name']) ?></h5><p class="mb-1"><?= esc($a['firstname'].' '.$a['lastname']) ?></p><p class="text-muted mb-2"><?= esc($a['school_year'] ?? '—') ?></p><p><span class="badge <?= $a['status']==='graded'?'bg-success':($a['status']==='completed'?'bg-warning text-dark':'bg-primary') ?>"><?= $a['status']==='graded' ? 'Reviewed' : esc(ucwords(str_replace('_',' ',$a['status']))) ?></span></p>
<?php if(in_array($a['status'],['not_started','in_progress'],true)): ?><a class="btn btn-primary" href="<?= site_url('student/assessments/'.$a['id']) ?>"><?= $a['status']==='in_progress'?'Continue':'Start' ?></a><?php elseif($a['status']==='graded'): ?><a class="btn btn-success" href="<?= site_url('student/assessments/'.$a['id'].'/results') ?>">View Responses</a><?php else: ?><span class="text-muted">Awaiting review</span><?php endif; ?>
</div></div></div><?php endforeach; ?></div>
</div>
<?= $this->endSection() ?>
+25
View File
@@ -0,0 +1,25 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container-fluid py-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<div><h2 class="mb-1">New Student Assessments</h2><p class="text-muted mb-0">New students from the <?= esc($schoolYear) ?> enrollment roster.</p></div>
<a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Assessment Forms</a>
</div>
<?= $this->include('assessments/_alerts') ?>
<div class="card shadow-sm"><div class="card-body"><div class="table-responsive">
<table id="assessmentStudentTable" class="table table-striped align-middle no-mgmt-sticky" data-no-mgmt-sticky><thead><tr><th>Student</th><th>School ID</th><th>Age</th><th>Current Class</th><th>Enrollment Status</th><th>Assessment</th></tr></thead><tbody>
<?php if(empty($new_students)): ?><tr><td colspan="6" class="text-center text-muted">No new students found for this school year.</td></tr><?php endif; ?>
<?php foreach($new_students as $student): $sid=(int)$student['id']; $a=($assessmentByStudent??[])[$sid]??null; ?><tr>
<td><?= esc($student['firstname'].' '.$student['lastname']) ?></td><td><?= esc($student['school_id']??'—') ?></td><td><?= esc($student['age']??'—') ?></td><td><?= esc($student['class_section']??'Class not Assigned') ?></td><td><?= esc(ucwords((string)($student['enrollment_status']??'unknown'))) ?></td><td>
<?php if(!$a): ?><form method="post" action="<?= site_url('administrator/assessments/students/'.$sid.'/start') ?>"><?= csrf_field() ?><button class="btn btn-sm btn-primary">Assign Assessment</button></form>
<?php elseif(in_array($a['status'],['not_started','in_progress'],true)): ?><a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/students/'.$sid) ?>">View Status</a><div class="small text-muted"><?= esc(ucwords(str_replace('_',' ',$a['status']))) ?></div>
<?php elseif($a['status']==='completed'): ?><a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/'.$a['id']) ?>">Review</a>
<?php else: ?><a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/'.$a['id']) ?>">View Results</a><?php endif; ?>
</td></tr><?php endforeach; ?>
</tbody></table>
</div></div></div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>$(function(){if($.fn.DataTable)$('#assessmentStudentTable').DataTable({pageLength:100,order:[[0,'asc']],fixedHeader:false})});</script>
<?= $this->endSection() ?>
+35
View File
@@ -0,0 +1,35 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $typeLabels = ['multiple_choice' => 'Multiple choice', 'short_answer' => 'Short answer', 'true_false' => 'True / False', 'essay' => 'Essay']; ?>
<div class="container-fluid py-3">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2><?= esc($pool['name']) ?> Question Pool</h2><p class="text-muted mb-0">Add, edit, delete, or reorder questions in the shared assessment pool.</p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Assessment Forms</a></div>
<?= $this->include('assessments/_alerts') ?>
<div class="card shadow-sm mb-4"><div class="card-header"><strong>Add a question</strong></div><div class="card-body">
<form method="post" action="<?= site_url('administrator/assessments/pools/' . $pool['id'] . '/questions') ?>"><?= csrf_field() ?>
<div class="row g-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select question-type" name="type" required><?php foreach ($typeLabels as $value => $label): ?><option value="<?= $value ?>"><?= esc($label) ?></option><?php endforeach; ?></select></div>
<div class="col-md-8"><label class="form-label">Question</label><textarea class="form-control" name="text" rows="2" required><?= set_value('text') ?></textarea></div>
<div class="col-md-6 options-wrap"><label class="form-label">Choices <span class="text-muted">(one per line)</span></label><textarea class="form-control" name="options_text" rows="3"></textarea></div>
<div class="col-md-6"><label class="form-label">Correct answer <span class="text-muted">(reference only)</span></label><textarea class="form-control" name="correct_answer" rows="3"></textarea></div></div>
<button class="btn btn-primary mt-3">Add Question</button>
</form>
</div></div>
<?php if (empty($questions)): ?><div class="alert alert-info">This pool has no questions yet.</div><?php endif; ?>
<?php foreach ($questions as $index => $question): ?>
<?php $opts = implode("\n", (array) json_decode((string) ($question['options'] ?? '[]'), true)); ?>
<div class="card shadow-sm mb-3"><div class="card-header d-flex justify-content-between"><span><span class="badge bg-secondary me-2">#<?= $index + 1 ?></span><?= esc($typeLabels[$question['type']] ?? $question['type']) ?></span><span class="d-flex gap-1">
<form method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/move') ?>"><?= csrf_field() ?><input type="hidden" name="direction" value="up"><button class="btn btn-sm btn-outline-secondary" aria-label="Move up" <?= $index === 0 ? 'disabled' : '' ?>>↑</button></form>
<form method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/move') ?>"><?= csrf_field() ?><input type="hidden" name="direction" value="down"><button class="btn btn-sm btn-outline-secondary" aria-label="Move down" <?= $index === count($questions)-1 ? 'disabled' : '' ?>>↓</button></form>
</span></div><div class="card-body">
<form method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/update') ?>"><?= csrf_field() ?>
<div class="row g-3"><div class="col-md-4"><label class="form-label">Type</label><select class="form-select question-type" name="type"><?php foreach ($typeLabels as $value => $label): ?><option value="<?= $value ?>" <?= $question['type'] === $value ? 'selected' : '' ?>><?= esc($label) ?></option><?php endforeach; ?></select></div>
<div class="col-md-8"><label class="form-label">Question</label><textarea class="form-control" name="text" rows="2" required><?= esc($question['text']) ?></textarea></div>
<div class="col-md-6 options-wrap"><label class="form-label">Choices (one per line)</label><textarea class="form-control" name="options_text" rows="3"><?= esc($opts) ?></textarea></div>
<div class="col-md-6"><label class="form-label">Correct answer (reference only)</label><textarea class="form-control" name="correct_answer" rows="3"><?= esc($question['correct_answer'] ?? '') ?></textarea></div></div>
<button class="btn btn-sm btn-primary mt-3">Save Changes</button>
</form>
<form class="d-inline" method="post" action="<?= site_url('administrator/assessments/questions/' . $question['id'] . '/delete') ?>" onsubmit="return confirm('Delete this question?')"><?= csrf_field() ?><button class="btn btn-sm btn-outline-danger mt-2">Delete</button></form>
</div></div>
<?php endforeach; ?>
</div>
<script>document.querySelectorAll('.question-type').forEach(function(s){function sync(){var w=s.closest('form').querySelector('.options-wrap');if(w)w.style.display=s.value==='multiple_choice'?'block':'none'}s.addEventListener('change',sync);sync()});</script>
<?= $this->endSection() ?>
+9
View File
@@ -0,0 +1,9 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<div class="container py-4" style="max-width:950px">
<div class="d-flex justify-content-end mb-3"><a class="btn btn-outline-secondary" href="<?= !empty($adminView) ? site_url('administrator/assessments/students') : site_url('student/assessments') ?>"><?= !empty($adminView) ? 'Back to Students' : 'Back to My Assessments' ?></a></div>
<div class="card shadow-sm mb-4"><div class="card-body text-center"><h2><?= esc($assessment['form_name']) ?></h2><p class="mb-1"><?= esc($assessment['firstname'].' '.$assessment['lastname']) ?></p><p class="text-muted mb-2"><?= esc($assessment['school_year'] ?? '—') ?></p><span class="badge bg-success">Reviewed</span></div></div><?= $this->include('assessments/_alerts') ?>
<?php foreach($questions as $index=>$q): ?><div class="card mb-3"><div class="card-body"><h5><?= $index+1 ?>. <?= esc($q['text']) ?></h5><p class="mb-1"><strong>Response:</strong></p><div style="white-space:pre-wrap"><?= esc($q['answer_value'] ?? 'No answer') ?></div></div></div><?php endforeach; ?>
<?php if(!empty($adminView)): ?><div class="card mb-3"><div class="card-body"><form id="resultsCommitteeNoteForm"><?= csrf_field() ?><label class="form-label fw-semibold" for="resultsCommitteeNote">Education Committee Note</label><textarea id="resultsCommitteeNote" class="form-control" name="education_committee_note" rows="5" maxlength="10000"><?= esc($assessment['education_committee_note'] ?? '') ?></textarea><div id="resultsNoteSaveNotice" class="form-text">This note remains editable and saves automatically.</div></form></div></div><?php endif; ?></div>
<?php if(!empty($adminView)): ?><script>(function(){var form=document.getElementById('resultsCommitteeNoteForm'),field=document.getElementById('resultsCommitteeNote'),notice=document.getElementById('resultsNoteSaveNotice'),timer;async function save(){notice.textContent='Saving note…';try{var response=await fetch('<?= site_url('administrator/assessments/attempts/'.$assessment['id'].'/note') ?>',{method:'POST',body:new FormData(form),headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}}),json=await response.json();if(!response.ok)throw new Error(json.message||'Save failed');if(json.csrfName&&json.csrfHash){var csrf=form.querySelector('input[name="'+json.csrfName+'"]');if(csrf)csrf.value=json.csrfHash}notice.textContent='Note saved.'}catch(e){notice.textContent='Note could not be saved. Keep this page open and try typing again.'}}field.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,700)});field.addEventListener('change',save)})();</script><?php endif; ?>
<?= $this->endSection() ?>
@@ -0,0 +1,17 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $byForm=[]; foreach($assignments as $a)$byForm[(int)$a['form_id']]=$a; ?>
<div class="container py-3" style="max-width:1000px">
<div class="d-flex justify-content-between align-items-center mb-3"><div><h2>Assessments: <?= esc($student['firstname'].' '.$student['lastname']) ?></h2><p class="text-muted mb-0"><?= esc($student['school_id'] ?? '') ?></p></div><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/students') ?>">Back to Students</a></div>
<?= $this->include('assessments/_alerts') ?>
<div class="card shadow-sm mb-4"><div class="card-header"><strong>Assign a published form</strong></div><div class="card-body">
<?php if(empty($forms)): ?><p class="text-muted mb-0">No published assessment forms are available.</p><?php else: ?><div class="table-responsive"><table class="table align-middle no-mgmt-sticky" data-no-mgmt-sticky><thead><tr><th>Form</th><th>Pool</th><th>Questions</th><th>Status / Action</th></tr></thead><tbody>
<?php foreach($forms as $form): $existing=$byForm[(int)$form['id']]??null; ?><tr><td><?= esc($form['name']) ?></td><td><?= esc($form['pool_name']) ?></td><td><?= (int)$form['question_count'] ?></td><td><?php if($existing): ?><span class="badge bg-secondary"><?= esc(ucwords(str_replace('_',' ',$existing['status']))) ?></span><?php else: ?><form method="post" action="<?= site_url('administrator/assessments/students/'.$student['id'].'/assign') ?>"><?= csrf_field() ?><input type="hidden" name="form_id" value="<?= (int)$form['id'] ?>"><button class="btn btn-sm btn-primary">Assign</button></form><?php endif; ?></td></tr><?php endforeach; ?>
</tbody></table></div><?php endif; ?>
</div></div>
<div class="card shadow-sm"><div class="card-header"><strong>Assignment history</strong></div><div class="card-body"><div class="table-responsive"><table class="table align-middle no-mgmt-sticky" data-no-mgmt-sticky><thead><tr><th>Form</th><th>School Year</th><th>Assigned</th><th>Status</th><th>Action</th></tr></thead><tbody>
<?php if(empty($assignments)): ?><tr><td colspan="5" class="text-center text-muted">Nothing assigned yet.</td></tr><?php endif; ?>
<?php foreach($assignments as $a): ?><tr><td><?= esc($a['form_name']) ?></td><td><?= esc($a['school_year'] ?? '—') ?></td><td><?= esc(local_datetime($a['assigned_at'],'m-d-Y H:i')) ?></td><td><?= $a['status']==='graded' ? 'Reviewed' : esc(ucwords(str_replace('_',' ',$a['status']))) ?></td><td><?php if($a['status']==='completed'): ?><a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/'.$a['id']) ?>">Review</a><?php elseif($a['status']==='graded'): ?><a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/'.$a['id']) ?>">View Results</a><?php else: ?><span class="text-muted">View Status</span><?php endif; ?></td></tr><?php endforeach; ?>
</tbody></table></div></div></div>
</div>
<?= $this->endSection() ?>
+22
View File
@@ -0,0 +1,22 @@
<?= $this->extend('layout/management_layout') ?>
<?= $this->section('content') ?>
<?php $preview=$preview??false; $assessment=$assessment??null; $answers=$answers??[]; ?>
<div class="container py-4" style="max-width:900px"><div class="d-flex justify-content-between align-items-center mb-3"><div><h2><?= esc($form['name']) ?></h2><p class="text-muted mb-0"><?= esc($form['school_year'] ?? '—') ?> · <?= count($questions) ?> questions</p></div><?php if($preview): ?><a class="btn btn-outline-secondary" href="<?= site_url('administrator/assessments/forms') ?>">Exit Preview</a><?php endif; ?></div>
<?php if($preview): ?><div class="alert alert-info">Preview mode — answers cannot be saved or submitted.</div><?php else: ?><div id="saveNotice" class="alert alert-light border py-2">Changes are saved as you work.</div><?php endif; ?>
<form id="assessmentForm" method="post" action="<?= !$preview ? site_url('student/assessments/'.$assessment['id'].'/submit') : '#' ?>"><?= csrf_field() ?>
<?php foreach($questions as $index=>$q): $options=(array)json_decode((string)($q['options']??'[]'),true); $value=(string)($answers[$q['id']]??''); ?>
<fieldset class="card shadow-sm mb-3" <?= $preview?'disabled':'' ?>><div class="card-body"><legend class="fs-6 mb-3"><span class="badge bg-secondary me-2"><?= $index+1 ?></span><?= esc($q['text']) ?></legend>
<?php if(in_array($q['type'],['multiple_choice','true_false'],true)): foreach($options as $option): ?><div class="form-check mb-2"><input class="form-check-input assessment-answer" type="radio" name="answers[<?= (int)$q['id'] ?>]" value="<?= esc($option) ?>" <?= $value===(string)$option?'checked':'' ?>><label class="form-check-label"><?= esc($option) ?></label></div><?php endforeach; ?>
<?php elseif($q['type']==='short_answer'): ?><input class="form-control assessment-answer" name="answers[<?= (int)$q['id'] ?>]" value="<?= esc($value) ?>">
<?php else: ?><textarea class="form-control assessment-answer" rows="6" name="answers[<?= (int)$q['id'] ?>]"><?= esc($value) ?></textarea><?php endif; ?>
</div></fieldset>
<?php endforeach; ?>
<?php if(!$preview): ?><div class="d-flex gap-2"><button type="button" id="saveButton" class="btn btn-outline-primary">Save Progress</button><button class="btn btn-success" onclick="return confirm('Submit this assessment? You cannot change answers after submission.')">Submit Assessment</button></div><?php endif; ?>
</form>
</div>
<?php if(!$preview): ?><script>
(function(){var form=document.getElementById('assessmentForm'),notice=document.getElementById('saveNotice'),timer;
async function save(){var fd=new FormData(form);notice.textContent='Saving…';try{var r=await fetch('<?= site_url('student/assessments/'.$assessment['id'].'/progress') ?>',{method:'POST',body:fd,headers:{'X-Requested-With':'XMLHttpRequest','Accept':'application/json'}});var j=await r.json();if(!r.ok)throw new Error(j.message||'Save failed');if(j.csrfName&&j.csrfHash){var token=form.querySelector('input[name="'+j.csrfName+'"]');if(token)token.value=j.csrfHash}notice.textContent='Progress saved.'}catch(e){notice.textContent='Could not save automatically. Use Save Progress to retry.'}}
document.querySelectorAll('.assessment-answer').forEach(function(el){el.addEventListener('change',function(){clearTimeout(timer);timer=setTimeout(save,500)});if(el.tagName==='TEXTAREA'||el.type==='text')el.addEventListener('input',function(){clearTimeout(timer);timer=setTimeout(save,1200)})});document.getElementById('saveButton').addEventListener('click',save);
})();</script><?php endif; ?>
<?= $this->endSection() ?>
+20 -60
View File
@@ -19,7 +19,7 @@
<?php endif; ?>
<div class="table-responsive">
<table id="enrollmentTable" class="table table-bordered table-striped mt-4 align-middle w-100">
<table id="enrollmentTable" class="table table-bordered table-striped mt-4 align-middle w-100 no-mgmt-sticky" data-no-mgmt-sticky>
<thead>
<tr>
@@ -30,6 +30,7 @@
<th>Actual Status</th>
<th>Age</th> <!-- NEW -->
<th>Registration Date</th> <!-- NEW -->
<th>Assessment</th>
<th>Action</th>
</tr>
@@ -107,6 +108,20 @@
<td><?= esc($student['age'] ?? '-') ?></td>
<td><?= esc(!empty($student['registration_date']) ? local_date($student['registration_date'], 'm-d-Y') : '-') ?></td>
<td>
<?php $studentAssessment = ($assessmentByStudent ?? [])[$sid] ?? null; ?>
<?php if (!$studentAssessment): ?>
<form method="post" action="<?= site_url('administrator/assessments/students/' . $sid . '/start') ?>"><?= csrf_field() ?><button class="btn btn-sm btn-outline-primary">Assign Assessment</button></form>
<?php elseif (in_array($studentAssessment['status'], ['not_started', 'in_progress'], true)): ?>
<a class="btn btn-sm btn-outline-secondary" href="<?= site_url('administrator/assessments/students/' . $sid) ?>">View Status</a>
<div class="small text-muted mt-1"><?= esc(ucwords(str_replace('_', ' ', $studentAssessment['status']))) ?></div>
<?php elseif ($studentAssessment['status'] === 'completed'): ?>
<a class="btn btn-sm btn-warning" href="<?= site_url('administrator/assessments/review/' . $studentAssessment['id']) ?>">Review</a>
<?php else: ?>
<a class="btn btn-sm btn-success" href="<?= site_url('administrator/assessments/results/' . $studentAssessment['id']) ?>">View Results</a>
<?php endif; ?>
</td>
<td class="d-flex gap-2">
<!-- Contact info modal trigger ONLY -->
<button class="btn btn-warning btn-sm" data-bs-toggle="modal" data-bs-target="#<?= esc($modalIdContact) ?>">
@@ -156,7 +171,7 @@
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="8" class="text-center">No students found.</td>
<td colspan="9" class="text-center">No students found.</td>
</tr>
<?php endif; ?>
</tbody>
@@ -169,52 +184,7 @@
<?= $this->section('scripts') ?>
<script>
$(function() {
function getFixedHeaderOffset() {
let total = 0;
const stack = [];
const header = document.querySelector('header.navbar.sticky-top, header.navbar.fixed-top');
if (header) stack.push(header);
const mgmt = document.getElementById('navbarManagement');
if (mgmt && (mgmt.classList.contains('sticky-top') || mgmt.classList.contains('fixed-top'))) stack.push(mgmt);
document.querySelectorAll('.navbar.sticky-top, .navbar.fixed-top').forEach(el => { if (!stack.includes(el)) stack.push(el); });
stack.forEach(el => { const h = el.offsetHeight || el.getBoundingClientRect().height || 0; total += Math.max(0, Math.round(h)); });
return total;
}
function loadScript(src, id) {
return new Promise((resolve, reject) => {
if (id && document.getElementById(id)) return resolve();
const s = document.createElement('script');
if (id) s.id = id;
s.src = src;
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
}
function loadCss(href, id) {
return new Promise((resolve) => {
if (id && document.getElementById(id)) return resolve();
const l = document.createElement('link');
if (id) l.id = id;
l.rel = 'stylesheet';
l.href = href;
l.onload = resolve;
document.head.appendChild(l);
});
}
function ensureFixedHeaderAssets() {
const hasFH = !!($.fn.dataTable && $.fn.dataTable.FixedHeader);
if (hasFH) return Promise.resolve();
return Promise.all([
loadScript('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader@3.4.0/js/dataTables.fixedHeader.min.js', 'dt-fixedheader'),
loadCss('https://cdn.jsdelivr.net/npm/datatables.net-fixedheader-bs5@3.4.0/css/fixedHeader.bootstrap5.min.css', 'dt-fixedheader-css')
]).catch(() => {});
}
function initTable() {
$('#enrollmentTable').DataTable({
$('#enrollmentTable').DataTable({
pageLength: 100,
lengthMenu: [10, 25, 50, 100],
stateSave: true,
@@ -222,18 +192,8 @@
columnDefs: [
{ targets: [1, 2, 3], searchable: false }
],
fixedHeader: {
header: true,
headerOffset: getFixedHeaderOffset()
}
});
}
if ($.fn.dataTable && $.fn.dataTable.FixedHeader) {
initTable();
} else {
ensureFixedHeaderAssets().then(() => initTable()).catch(() => initTable());
}
fixedHeader: false
});
});
</script>
<?= $this->endSection() ?>
@@ -0,0 +1,144 @@
<?php
namespace Tests\App\Config;
use CodeIgniter\Test\CIUnitTestCase;
final class AssessmentFeatureIntegrityTest extends CIUnitTestCase
{
public function testAssessmentRoutesAreExplicitAndRoleProtected(): void
{
$routes = file_get_contents(ROOTPATH . 'app/Config/Routes.php') ?: '';
$this->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);
}
}