361 lines
15 KiB
PHP
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());
|
|
}
|
|
}
|
|
}
|