add assessment feature
This commit is contained in:
@@ -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 student’s Islamic education background?',
|
||||
],
|
||||
[
|
||||
'type' => 'short_answer',
|
||||
'text' => 'What is the student’s Arabic competency level?',
|
||||
],
|
||||
[
|
||||
'type' => 'short_answer',
|
||||
'text' => 'What is the student’s English competency level?',
|
||||
],
|
||||
[
|
||||
'type' => 'essay',
|
||||
'text' => 'Has the student attended any Sunday/Islamic school in the past? If YES, which one?',
|
||||
],
|
||||
[
|
||||
'type' => 'essay',
|
||||
'text' => 'Has the student memorized any Surahs? If yes, which one(s)?',
|
||||
],
|
||||
[
|
||||
'type' => 'essay',
|
||||
'text' => 'Why have the parents chosen AlRahma Sunday School for their child(ren)?',
|
||||
],
|
||||
[
|
||||
'type' => 'essay',
|
||||
'text' => 'Any serious/special medical condition about the student which we have to be aware of?',
|
||||
],
|
||||
[
|
||||
'type' => 'essay',
|
||||
'text' => 'If surnames of enrolled students are different, please confirm if they are all siblings; if NOT, then please specify their relationship to each other:',
|
||||
],
|
||||
[
|
||||
'type' => 'essay',
|
||||
'text' => 'Any other questions / concerns that the parents may have?',
|
||||
],
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (! $this->db->tableExists('question_pools') || ! $this->db->tableExists('assessment_questions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pool = $this->db->table('question_pools')
|
||||
->select('id')
|
||||
->where('name', self::POOL_NAME)
|
||||
->get()
|
||||
->getRowArray();
|
||||
|
||||
if ($pool === null) {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$this->db->table('question_pools')->insert([
|
||||
'name' => self::POOL_NAME,
|
||||
'subject_tag' => 'New Student Intake',
|
||||
'created_by' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
$poolId = (int) $this->db->insertID();
|
||||
} else {
|
||||
$poolId = (int) $pool['id'];
|
||||
}
|
||||
|
||||
if ($poolId <= 0) return;
|
||||
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$maxOrder = $this->db->table('assessment_questions')->selectMax('order_index')->where('pool_id', $poolId)->get()->getRowArray();
|
||||
$nextOrder = ((int) ($maxOrder['order_index'] ?? 0)) + 1;
|
||||
foreach (self::QUESTIONS as $question) {
|
||||
$exists = $this->db->table('assessment_questions')
|
||||
->where('pool_id', $poolId)
|
||||
->where('text', $question['text'])
|
||||
->countAllResults() > 0;
|
||||
if ($exists) continue;
|
||||
|
||||
$this->db->table('assessment_questions')->insert([
|
||||
'pool_id' => $poolId,
|
||||
'type' => $question['type'],
|
||||
'text' => $question['text'],
|
||||
'options' => null,
|
||||
'correct_answer' => null,
|
||||
'points' => 0,
|
||||
'order_index' => $nextOrder++,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! $this->db->tableExists('question_pools') || ! $this->db->tableExists('assessment_questions')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$pool = $this->db->table('question_pools')->select('id')->where('name', self::POOL_NAME)->get()->getRowArray();
|
||||
if ($pool === null) return;
|
||||
|
||||
$poolId = (int) $pool['id'];
|
||||
foreach (array_column(self::QUESTIONS, 'text') as $text) {
|
||||
$question = $this->db->table('assessment_questions')->select('id')->where(['pool_id' => $poolId, 'text' => $text])->get()->getRowArray();
|
||||
if ($question === null) continue;
|
||||
if ($this->db->tableExists('assessment_form_questions')
|
||||
&& $this->db->table('assessment_form_questions')->where('question_id', (int) $question['id'])->countAllResults() > 0) {
|
||||
continue;
|
||||
}
|
||||
$this->db->table('assessment_questions')->where('id', (int) $question['id'])->delete();
|
||||
}
|
||||
|
||||
$hasQuestions = $this->db->table('assessment_questions')->where('pool_id', $poolId)->countAllResults() > 0;
|
||||
$hasForms = $this->db->tableExists('assessment_forms')
|
||||
&& $this->db->table('assessment_forms')->where('pool_id', $poolId)->countAllResults() > 0;
|
||||
if (! $hasQuestions && ! $hasForms) {
|
||||
$this->db->table('question_pools')->where('id', $poolId)->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -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;
|
||||
}
|
||||
}
|
||||
+32
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 ?>">
|
||||
|
||||
@@ -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; ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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() ?>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user