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

This commit is contained in:
root
2026-09-10 06:44:24 -04:00
parent ac19226bf0
commit b5e719382d
31 changed files with 1712 additions and 65 deletions
@@ -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)));
}
}