Files
saqel/backend/app/Controllers/ExamController.php
T

361 lines
15 KiB
PHP

<?php
namespace App\Controllers;
use App\Core\Request;
use App\Core\Response;
use App\Core\Database;
use App\Core\Validator;
class ExamController
{
/**
* List exams for a specific course/lesson/scope
* GET /api/exams?course_id=1&lesson_id=2&scope=in_video_checkpoint
*/
public function getExams(Request $request, Response $response): void
{
$queryParams = $request->getQueryParams();
$courseId = !empty($queryParams['course_id']) ? (int)$queryParams['course_id'] : null;
$lessonId = !empty($queryParams['lesson_id']) ? (int)$queryParams['lesson_id'] : null;
$scope = $queryParams['scope'] ?? null;
$sql = "SELECT e.*, COUNT(q.id) as questions_count
FROM exams e
LEFT JOIN questions q ON q.exam_id = e.id
WHERE e.is_published = 1";
$params = [];
if ($courseId) {
$sql .= " AND e.course_id = ?";
$params[] = $courseId;
}
if ($lessonId) {
$sql .= " AND e.lesson_id = ?";
$params[] = $lessonId;
}
if ($scope) {
$sql .= " AND e.scope = ?";
$params[] = $scope;
}
$sql .= " GROUP BY e.id ORDER BY e.timestamp_seconds ASC, e.id ASC";
$exams = Database::select($sql, $params);
$response->json([
'status' => 'success',
'data' => $exams
]);
}
/**
* Get single exam with questions and sanitized options
* GET /api/exams/{id}
*/
public function getExamDetails(Request $request, Response $response): void
{
$examId = (int)$request->getParam('id');
$isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin');
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
if (!$exam) {
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']);
return;
}
$questions = Database::select("SELECT * FROM questions WHERE exam_id = ? ORDER BY id ASC", [$examId]);
foreach ($questions as &$q) {
$options = Database::select("SELECT id, option_text, feedback_text" . ($isTeacher ? ", is_correct" : "") . " FROM question_options WHERE question_id = ? ORDER BY id ASC", [$q['id']]);
$q['options'] = $options;
}
$exam['questions'] = $questions;
$response->json([
'status' => 'success',
'data' => $exam
]);
}
/**
* Submit Exam Attempt & Calculate Instant AI Diagnostic Evaluation
* POST /api/exams/{id}/submit
*/
public function submitExam(Request $request, Response $response): void
{
self::ensureAttemptSchema();
$studentId = $request->user_id;
$examId = (int)$request->getParam('id');
$body = $request->getBody();
$answers = $body['answers'] ?? []; // Array of ['question_id' => X, 'selected_option_id' => Y]
$timeSpent = (int)($body['time_spent_seconds'] ?? 0);
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
if (!$exam) {
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']);
return;
}
// Fetch all questions and correct options
$questions = Database::select("SELECT id, points, topic_tag, explanation_text, ai_hint FROM questions WHERE exam_id = ?", [$examId]);
$questionMap = [];
$totalPoints = 0;
foreach ($questions as $q) {
$questionMap[$q['id']] = $q;
$totalPoints += (int)$q['points'];
}
$options = Database::select("SELECT id, question_id, is_correct, feedback_text FROM question_options WHERE question_id IN (" . implode(',', array_keys($questionMap)) . ")");
$correctOptions = [];
foreach ($options as $opt) {
if ($opt['is_correct']) {
$correctOptions[$opt['question_id']] = (int)$opt['id'];
}
}
// Score evaluation
$earnedScore = 0;
$weakTopics = [];
$detailedAnswers = [];
foreach ($answers as $ans) {
$qId = (int)($ans['question_id'] ?? 0);
$selectedOptId = (int)($ans['selected_option_id'] ?? 0);
if (!isset($questionMap[$qId])) continue;
$isCorrect = (isset($correctOptions[$qId]) && $correctOptions[$qId] === $selectedOptId);
$qPoints = (int)$questionMap[$qId]['points'];
$awarded = $isCorrect ? $qPoints : 0;
$earnedScore += $awarded;
if (!$isCorrect) {
$topic = $questionMap[$qId]['topic_tag'] ?: 'المفاهيم العامة';
$weakTopics[$topic] = ($weakTopics[$topic] ?? 0) + 1;
}
$detailedAnswers[] = [
'question_id' => $qId,
'selected_option_id' => $selectedOptId,
'is_correct' => $isCorrect ? 1 : 0,
'points_awarded' => $awarded,
'explanation' => $questionMap[$qId]['explanation_text'] ?? null,
'ai_hint' => $questionMap[$qId]['ai_hint'] ?? null
];
}
$percentage = $totalPoints > 0 ? round(($earnedScore / $totalPoints) * 100, 2) : 0;
$passed = ($percentage >= (float)$exam['passing_percentage']);
$status = $passed ? 'passed' : 'needs_remediation';
// AI Diagnostic Report
$aiReport = $passed
? "أداء ممتاز! استيعاب قوي للمفاهيم بنسبة {$percentage}%. أنت جاهز تماماً للانتقال للدرس التالي."
: "تحتاج لمراجعة المفاهيم المتعلقة بـ: " . implode('، ', array_keys($weakTopics)) . ". يُنصح بمشاهدة مقطع الشرح الموصى به.";
// Insert Attempt Record
self::ensureSchema();
$uuid = sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
$attemptId = Database::insert(
"INSERT INTO exam_attempts (uuid, student_id, exam_id, score, total_score, percentage, status, time_spent_seconds, weak_topics_json, ai_diagnostic_report, completed_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())",
[$uuid, $studentId, $examId, $earnedScore, $totalPoints, $percentage, $status, $timeSpent, json_encode(array_keys($weakTopics), JSON_UNESCAPED_UNICODE), $aiReport]
);
// Save detailed question answers safely
foreach ($detailedAnswers as $dAns) {
try {
Database::insert(
"INSERT INTO student_question_answers (attempt_id, student_id, question_id, selected_option_id, is_correct, points_awarded, time_spent_seconds)
VALUES (?, ?, ?, ?, ?, ?, ?)",
[$attemptId, $studentId, $dAns['question_id'], $dAns['selected_option_id'], $dAns['is_correct'], $dAns['points_awarded'], 0]
);
} catch (\Throwable $e) {
error_log("student_question_answers insert notice: " . $e->getMessage());
}
}
// Update Student Cumulative Mastery & Tawjihi Readiness Score
$courseId = (int)$exam['course_id'];
$subjectId = (int)Database::selectOne("SELECT subject_id FROM courses WHERE id = ? LIMIT 1", [$courseId])['subject_id'];
$allAttempts = Database::select(
"SELECT ea.percentage, ea.status FROM exam_attempts ea
JOIN exams e ON e.id = ea.exam_id
WHERE ea.student_id = ? AND e.course_id = ?",
[$studentId, $courseId]
);
$totalExams = count($allAttempts);
$passedExams = 0;
$sumPct = 0;
foreach ($allAttempts as $att) {
$sumPct += (float)$att['percentage'];
if ($att['status'] === 'passed') $passedExams++;
}
$avgMastery = $totalExams > 0 ? round($sumPct / $totalExams, 2) : 0;
$tawjihiIndex = round(($avgMastery * 0.7) + (($passedExams / max(1, $totalExams)) * 30), 1);
Database::query("
INSERT INTO student_mastery_analytics
(student_id, course_id, subject_id, mastery_percentage, tawjihi_readiness_score, exams_passed_count, exams_total_count, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
mastery_percentage = VALUES(mastery_percentage),
tawjihi_readiness_score = VALUES(tawjihi_readiness_score),
exams_passed_count = VALUES(exams_passed_count),
exams_total_count = VALUES(exams_total_count),
updated_at = NOW()
", [$studentId, $courseId, $subjectId, $avgMastery, $tawjihiIndex, $passedExams, $totalExams]);
// Trigger Guardian Alert via Nabeh WhatsApp if remediation needed
if (!$passed && !empty($weakTopics)) {
try {
$studentInfo = Database::selectOne(
"SELECT s.full_name, sub.name AS subject_name
FROM students s
LEFT JOIN courses c ON c.id = ?
LEFT JOIN subjects sub ON sub.id = c.subject_id
WHERE s.id = ? LIMIT 1",
[$courseId, $studentId]
);
$guardian = Database::selectOne(
"SELECT ai.phone_number
FROM guardian_students gs
JOIN guardians g ON g.id = gs.guardian_id
JOIN auth_identities ai ON ai.id = g.auth_identity_id
WHERE gs.student_id = ? LIMIT 1",
[$studentId]
);
if ($guardian && !empty($guardian['phone_number'])) {
$nabeh = new \App\Services\NabehService();
$weakTopicStr = implode('، ', array_keys($weakTopics));
$nabeh->sendGuardianRemedialAlert(
(string)$guardian['phone_number'],
$studentInfo['full_name'] ?? 'الطالب',
$studentInfo['subject_name'] ?? 'المبحث المقرر',
$weakTopicStr
);
}
} catch (\Throwable $e) {
error_log("Guardian remedial alert notice: " . $e->getMessage());
}
}
$response->json([
'status' => 'success',
'data' => [
'attempt_id' => $attemptId,
'score' => $earnedScore,
'total_score' => $totalPoints,
'percentage' => $percentage,
'passed' => $passed,
'rewind_seconds' => $passed ? 0 : (int)$exam['rewind_on_fail_seconds'],
'ai_diagnostic_report' => $aiReport,
'weak_topics' => array_keys($weakTopics),
'tawjihi_readiness_score'=> $tawjihiIndex,
'detailed_answers' => $detailedAnswers
]
]);
}
/** Additive production migration for installations created before completed_at. */
private static function ensureAttemptSchema(): void
{
try {
$column = Database::selectOne(
"SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts'
AND COLUMN_NAME = 'completed_at' LIMIT 1"
);
if (empty($column['cnt'])) {
Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT NULL AFTER ai_diagnostic_report");
}
} catch (\Throwable $e) {
error_log('Exam schema migration note: ' . $e->getMessage());
}
}
/**
* Get Student Mastery & Tawjihi Readiness Analytics
* GET /api/student/progress/mastery?course_id=1
*/
public function getMastery(Request $request, Response $response): void
{
$studentId = $request->user_id;
$courseId = (int)$request->getParam('course_id');
$analytics = Database::selectOne(
"SELECT * FROM student_mastery_analytics WHERE student_id = ? AND course_id = ? LIMIT 1",
[$studentId, $courseId]
);
$recentAttempts = Database::select(
"SELECT ea.id, ea.score, ea.total_score, ea.percentage, ea.status, ea.ai_diagnostic_report, ea.completed_at, e.title as exam_title, e.scope
FROM exam_attempts ea
JOIN exams e ON e.id = ea.exam_id
WHERE ea.student_id = ? AND e.course_id = ?
ORDER BY ea.id DESC LIMIT 10",
[$studentId, $courseId]
);
$response->json([
'status' => 'success',
'data' => [
'analytics' => $analytics ?: [
'mastery_percentage' => 0,
'tawjihi_readiness_score'=> 0,
'exams_passed_count' => 0,
'exams_total_count' => 0
],
'recent_attempts' => $recentAttempts
]
]);
}
public static function ensureSchema(): void
{
try {
Database::query(
"CREATE TABLE IF NOT EXISTS `student_question_answers` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`attempt_id` BIGINT UNSIGNED NOT NULL,
`student_id` BIGINT UNSIGNED NOT NULL,
`question_id` BIGINT UNSIGNED NOT NULL,
`selected_option_id` BIGINT UNSIGNED NULL,
`is_correct` TINYINT(1) NOT NULL DEFAULT 0,
`points_awarded` DECIMAL(5, 2) NOT NULL DEFAULT 0.00,
`time_spent_seconds` INT UNSIGNED NOT NULL DEFAULT 0,
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
KEY `idx_sqa_attempt` (`attempt_id`),
KEY `idx_sqa_student` (`student_id`),
KEY `idx_sqa_question` (`question_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
);
// Ensure completed_at in exam_attempts
$colCheck = Database::selectOne(
"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'exam_attempts' AND COLUMN_NAME = 'completed_at' LIMIT 1"
);
if (!$colCheck) {
Database::query("ALTER TABLE exam_attempts ADD COLUMN completed_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP AFTER ai_diagnostic_report");
}
} catch (\Throwable $e) {
error_log("ExamController ensureSchema notice: " . $e->getMessage());
}
}
}