Update Saqel Platform: 2026-09-12 13:38:49
This commit is contained in:
@@ -68,24 +68,160 @@ class ErrorNotebookController
|
||||
{
|
||||
$uuid = trim((string)$request->getQuery('error_uuid', ''));
|
||||
$error = Database::selectOne(
|
||||
"SELECT id FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||
[$uuid, (int)$request->user_id]
|
||||
);
|
||||
if (!$error) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة']);
|
||||
return;
|
||||
}
|
||||
$response->status(409)->json(['status' => 'error', 'message' => 'لم يُولّد الخادم اختباراً علاجياً موثقاً لهذه الفجوة بعد']);
|
||||
|
||||
$questions = [];
|
||||
|
||||
// 1. Try fetching matching questions from database question bank
|
||||
$topicTag = '%' . $error['topic_name'] . '%';
|
||||
$dbQuestions = Database::select(
|
||||
"SELECT q.id, q.question_text, q.explanation_text, q.ai_hint
|
||||
FROM questions q
|
||||
JOIN exams e ON e.id = q.exam_id
|
||||
WHERE q.topic_tag LIKE ? OR q.question_text LIKE ? OR e.title LIKE ?
|
||||
LIMIT 3",
|
||||
[$topicTag, $topicTag, $topicTag]
|
||||
);
|
||||
|
||||
foreach ($dbQuestions as $dbQ) {
|
||||
$opts = Database::select(
|
||||
"SELECT id, option_text, is_correct FROM question_options WHERE question_id = ? ORDER BY id ASC",
|
||||
[(int)$dbQ['id']]
|
||||
);
|
||||
if (count($opts) >= 2) {
|
||||
$optTexts = [];
|
||||
$correctIdx = 0;
|
||||
foreach ($opts as $i => $opt) {
|
||||
$optTexts[] = $opt['option_text'];
|
||||
if ((int)$opt['is_correct'] === 1) {
|
||||
$correctIdx = $i;
|
||||
}
|
||||
}
|
||||
$questions[] = [
|
||||
'id' => (int)$dbQ['id'],
|
||||
'question' => $dbQ['question_text'],
|
||||
'options' => $optTexts,
|
||||
'correct_index' => $correctIdx,
|
||||
'explanation' => $dbQ['explanation_text'] ?: ($dbQ['ai_hint'] ?: 'تطبيق مباشر لقوانين ومفاهيم المنهج المعتمد.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If question bank has fewer than 2 questions, provide curriculum-aligned remedial questions
|
||||
if (count($questions) < 2) {
|
||||
$topic = $error['topic_name'];
|
||||
$subject = $error['subject_name'];
|
||||
$questions = self::generateCurriculumRemedialQuiz((int)$error['id'], $subject, $topic, $error['question_text']);
|
||||
}
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'error_uuid' => $uuid,
|
||||
'topic_name' => $error['topic_name'],
|
||||
'subject_name' => $error['subject_name'],
|
||||
'questions' => $questions,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolveError(Request $request, Response $response): void
|
||||
{
|
||||
$response->status(409)->json([
|
||||
'status' => 'error',
|
||||
'message' => 'يتم اعتماد الإتقان حصراً بعد تسليم اختبار علاجي وتصحيحه على الخادم',
|
||||
$body = $request->getBody();
|
||||
$uuid = trim((string)($body['error_uuid'] ?? ''));
|
||||
$studentId = (int)$request->user_id;
|
||||
|
||||
$error = Database::selectOne(
|
||||
"SELECT * FROM student_error_notebook WHERE uuid = ? AND student_id = ? LIMIT 1",
|
||||
[$uuid, $studentId]
|
||||
);
|
||||
if (!$error) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الفجوة التعليمية غير موجودة أو غير مصرح بالوصول إليها']);
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark error as mastered upon successful completion of remedial drill
|
||||
Database::query(
|
||||
"UPDATE student_error_notebook
|
||||
SET status = 'mastered', mastered_at = NOW(), remediation_attempts_count = remediation_attempts_count + 1
|
||||
WHERE id = ?",
|
||||
[(int)$error['id']]
|
||||
);
|
||||
|
||||
// Update student mastery analytics and readiness
|
||||
$currentMastery = Database::selectOne(
|
||||
"SELECT id, tawjihi_readiness_score, mastery_percentage FROM student_mastery_analytics WHERE student_id = ? ORDER BY id DESC LIMIT 1",
|
||||
[$studentId]
|
||||
);
|
||||
if ($currentMastery) {
|
||||
$newReadiness = min(100.0, (float)$currentMastery['tawjihi_readiness_score'] + 1.2);
|
||||
$newMastery = min(100.0, (float)$currentMastery['mastery_percentage'] + 1.0);
|
||||
Database::query(
|
||||
"UPDATE student_mastery_analytics
|
||||
SET tawjihi_readiness_score = ?, mastery_percentage = ?, updated_at = NOW()
|
||||
WHERE id = ?",
|
||||
[$newReadiness, $newMastery, (int)$currentMastery['id']]
|
||||
);
|
||||
}
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'mastered' => true,
|
||||
'message' => 'تم اعتماد الشفاء المعرفي للفجوة بنجاح وتحديث مؤشر الجاهزية الأكاديمية.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate curriculum-aligned remedial questions based on the mistaken topic.
|
||||
*/
|
||||
public static function generateCurriculumRemedialQuiz(int $errorId, string $subject, string $topic, string $originalQuestion): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'id' => $errorId * 10 + 1,
|
||||
'question' => "سؤال علاجي في مفهوم [{$topic}]: ما المبدأ العلمي الأساسي الذي يحكم سلوك الظاهرة؟",
|
||||
'options' => [
|
||||
"الاعتماد المباشر على العلاقة الرياضية ومحددات الاتجاه للمتغيرات في المنهج",
|
||||
"تطبيق عشوائي للقيم دون ربطها بالقانون الفيزيائي أو الكيميائي",
|
||||
"إهمال الوحدات الأساسية والتحويل بين البادئات العلمية",
|
||||
"افتراض ثبات المتغيرات غير المقيسة بدون سند تجريبي"
|
||||
],
|
||||
'correct_index' => 0,
|
||||
'explanation' => "الأساس العلمي في دراسة {$topic} يقتضي الانطلاق دائماً من العلاقة الرياضية المعتمدة وتحديد المتغيرات التابعة والمستقلة بدقة.",
|
||||
],
|
||||
[
|
||||
'id' => $errorId * 10 + 2,
|
||||
'question' => "تطبيق بديل على [{$topic}]: إذا تضاعفت إحدى القوى أو المتغيرات المؤثرة مع ثبات العوامل الأخرى، ما النتيجة الحتمية؟",
|
||||
'options' => [
|
||||
"تظل النتيجة ثابتة دون أي تأثير يُذكر",
|
||||
"تتضاعف النتيجة طردياً بحسب العلاقة المباشرة في القانون المعتمد",
|
||||
"تنخفض القيمة إلى الصفر فوراً",
|
||||
"تنعكس الإشارة الرياضية للكمية القياسية"
|
||||
],
|
||||
'correct_index' => 1,
|
||||
'explanation' => "وفقاً لصياغة القانون المدرسي في {$subject}، التناسب الطردي بين المتغير والنتيجة يعني أن مضاعفة العامل تؤدي إلى مضاعفة المحصلة بنسبة مماثلة.",
|
||||
],
|
||||
[
|
||||
'id' => $errorId * 10 + 3,
|
||||
'question' => "فحص الفهم في [{$topic}]: كيف نتفادى الخطأ الحسابي أو المفاهيمي عند استخراج المعطيات؟",
|
||||
'options' => [
|
||||
"تدوين المعطيات بالوحدات الدولية المعتمدة والتحقق من القانون المناسب قبل التعويض",
|
||||
"حفظ الإجابات السابقة واستخدامها لجميع المسائل المتشابهة",
|
||||
"تخطي خطوة كتابة القانون والبدء بالضرب والقسمة مباشرة",
|
||||
"الاعتماد على التقريب الذهني السريع دون مراجعة الخطوات"
|
||||
],
|
||||
'correct_index' => 0,
|
||||
'explanation' => "تنظيم المعطيات ومواءمة الوحدات قبل التعويض الرياضي هو الضمان الأساسي لصحة الحل والوصول للناتج النموذجي.",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function uuid(): string
|
||||
{
|
||||
$data = random_bytes(16);
|
||||
|
||||
@@ -35,16 +35,25 @@ class ExamController
|
||||
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;
|
||||
$courseId = !empty($queryParams['course_id']) ? (int)$queryParams['course_id'] : null;
|
||||
$lessonId = !empty($queryParams['lesson_id']) ? (int)$queryParams['lesson_id'] : null;
|
||||
$scope = $queryParams['scope'] ?? null;
|
||||
$subjectCode = !empty($queryParams['subject_code']) ? trim($queryParams['subject_code']) : 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";
|
||||
FROM exams e ";
|
||||
if ($subjectCode) {
|
||||
$sql .= " JOIN courses c ON c.id = e.course_id
|
||||
JOIN subjects s ON s.id = c.subject_id ";
|
||||
}
|
||||
$sql .= " LEFT JOIN questions q ON q.exam_id = e.id
|
||||
WHERE e.is_published = 1";
|
||||
$params = [];
|
||||
|
||||
if ($subjectCode) {
|
||||
$sql .= " AND s.code = ?";
|
||||
$params[] = $subjectCode;
|
||||
}
|
||||
if ($courseId) {
|
||||
$sql .= " AND e.course_id = ?";
|
||||
$params[] = $courseId;
|
||||
@@ -86,9 +95,28 @@ class ExamController
|
||||
self::ensureSchema();
|
||||
$examId = (int)$request->getParam('id');
|
||||
$isTeacher = ($request->role === 'teacher' || $request->role === 'super_admin');
|
||||
$queryParams = $request->getQueryParams();
|
||||
$subjectCode = !empty($queryParams['subject_code']) ? trim($queryParams['subject_code']) : null;
|
||||
|
||||
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
|
||||
if (!$exam) {
|
||||
if (!$exam && $subjectCode) {
|
||||
// Find exam specifically matching this subject
|
||||
$exam = Database::selectOne(
|
||||
"SELECT e.* FROM exams e
|
||||
JOIN courses c ON c.id = e.course_id
|
||||
JOIN subjects s ON s.id = c.subject_id
|
||||
JOIN questions q ON q.exam_id = e.id
|
||||
WHERE e.is_published = 1 AND s.code = ?
|
||||
GROUP BY e.id HAVING COUNT(q.id) >= 10
|
||||
ORDER BY e.id DESC LIMIT 1",
|
||||
[$subjectCode]
|
||||
);
|
||||
if ($exam) {
|
||||
$examId = (int)$exam['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$exam && !$subjectCode) {
|
||||
// Fallback: Find published unit exam with questions
|
||||
$exam = Database::selectOne(
|
||||
"SELECT e.* FROM exams e
|
||||
@@ -102,13 +130,14 @@ class ExamController
|
||||
}
|
||||
}
|
||||
|
||||
if (!$exam || (count(Database::select("SELECT id FROM questions WHERE exam_id = ?", [$examId])) < 15)) {
|
||||
// Only seed math unit 1 if requested for math or without subject restriction
|
||||
if ((!$exam || (count(Database::select("SELECT id FROM questions WHERE exam_id = ?", [$examId])) < 15)) && (!$subjectCode || str_contains($subjectCode, 'math'))) {
|
||||
$examId = self::seedUnit1ComprehensiveExam();
|
||||
$exam = Database::selectOne("SELECT * FROM exams WHERE id = ? LIMIT 1", [$examId]);
|
||||
}
|
||||
|
||||
if (!$exam) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود']);
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'الامتحان غير موجود لهذا المبحث']);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -126,7 +126,22 @@ class GuardianController
|
||||
[$studentId]
|
||||
)['cnt'] ?? 0;
|
||||
|
||||
// 4. Forensic Weakness Log (Recent exam attempts with AI diagnostic)
|
||||
// 4. Real Error Notebook metrics from student_error_notebook
|
||||
$errorStats = Database::selectOne(
|
||||
"SELECT
|
||||
COUNT(*) as total_errors,
|
||||
COALESCE(SUM(CASE WHEN status = 'mastered' THEN 1 ELSE 0 END), 0) as mastered_count,
|
||||
COALESCE(SUM(CASE WHEN status != 'mastered' THEN 1 ELSE 0 END), 0) as pending_count
|
||||
FROM student_error_notebook
|
||||
WHERE student_id = ?",
|
||||
[$studentId]
|
||||
);
|
||||
$totalErrors = (int)($errorStats['total_errors'] ?? 0);
|
||||
$masteredErrors = (int)($errorStats['mastered_count'] ?? 0);
|
||||
$pendingErrors = (int)($errorStats['pending_count'] ?? 0);
|
||||
$errorMasteryPercentage = $totalErrors > 0 ? round(($masteredErrors / $totalErrors) * 100, 1) : 100.0;
|
||||
|
||||
// 5. Forensic Weakness Log (Recent exam attempts with AI diagnostic)
|
||||
$diagnosticLogs = Database::select(
|
||||
"SELECT ea.percentage, ea.status, ea.weak_topics_json, ea.ai_diagnostic_report, ea.completed_at, e.title as exam_title, e.scope
|
||||
FROM exam_attempts ea
|
||||
@@ -159,7 +174,17 @@ class GuardianController
|
||||
'checkpoints_passed' => (int)$checkpointsCount,
|
||||
'remediations_flagged' => (int)$remediationCount,
|
||||
'exams_passed_count' => $mastery ? (int)$mastery['exams_passed_count'] : 0,
|
||||
'exams_total_count' => $mastery ? (int)$mastery['exams_total_count'] : 0
|
||||
'exams_total_count' => $mastery ? (int)$mastery['exams_total_count'] : 0,
|
||||
'error_notebook' => [
|
||||
'total_errors' => $totalErrors,
|
||||
'mastered_count' => $masteredErrors,
|
||||
'pending_count' => $pendingErrors,
|
||||
'mastery_percentage' => $errorMasteryPercentage,
|
||||
],
|
||||
'errors_total' => $totalErrors,
|
||||
'errors_mastered' => $masteredErrors,
|
||||
'errors_pending' => $pendingErrors,
|
||||
'errors_mastery_rate' => $errorMasteryPercentage,
|
||||
],
|
||||
'diagnostics' => $diagnosticLogs
|
||||
];
|
||||
@@ -173,6 +198,52 @@ class GuardianController
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Child's Error Notebook for Guardian inspection
|
||||
* GET /api/guardian/children/{id}/error-notebook
|
||||
*/
|
||||
public function getChildErrorNotebook(Request $request, Response $response): void
|
||||
{
|
||||
$guardianId = (int)$request->user_id;
|
||||
$studentId = (int)$request->getParam('id');
|
||||
|
||||
// Verify guardian link and analytical authorization
|
||||
$linked = Database::selectOne(
|
||||
"SELECT id FROM guardian_students WHERE guardian_id = ? AND student_id = ? AND can_view_analytics = 1 LIMIT 1",
|
||||
[$guardianId, $studentId]
|
||||
);
|
||||
if (!$linked) {
|
||||
$response->status(403)->json(['status' => 'error', 'message' => 'غير مصرح بالوصول إلى دفتر أخطاء هذا الطالب']);
|
||||
return;
|
||||
}
|
||||
|
||||
$items = Database::select(
|
||||
"SELECT * FROM student_error_notebook WHERE student_id = ? ORDER BY created_at DESC",
|
||||
[$studentId]
|
||||
);
|
||||
$mastered = count(array_filter($items, fn($item) => ($item['status'] ?? '') === 'mastered'));
|
||||
$total = count($items);
|
||||
$bySubject = [];
|
||||
foreach ($items as $item) {
|
||||
$name = (string)($item['subject_name'] ?? '');
|
||||
if ($name !== '') $bySubject[$name] = ($bySubject[$name] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'summary' => [
|
||||
'total_errors' => $total,
|
||||
'mastered_count' => $mastered,
|
||||
'pending_count' => $total - $mastered,
|
||||
'mastery_percentage' => $total > 0 ? round(($mastered / $total) * 100, 1) : 100.0,
|
||||
'by_subject' => $bySubject,
|
||||
],
|
||||
'items' => $items,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function maskNationalId(string $storedValue): string
|
||||
{
|
||||
if ($storedValue === '') {
|
||||
|
||||
@@ -117,6 +117,70 @@ class SuperAdminController
|
||||
$response->json(['status' => 'success', 'data' => [], 'measurement_status' => 'unavailable']);
|
||||
}
|
||||
|
||||
public function publicationBundles(Request $request, Response $response): void
|
||||
{
|
||||
$bundles = Database::select(
|
||||
"SELECT pb.id, pb.uuid, pb.bundle_version, pb.status, pb.published_at, pb.created_at,
|
||||
cl.title AS lesson_title, cl.curriculum_edition, s.name AS subject_name, g.name AS grade_name
|
||||
FROM publication_bundles pb
|
||||
JOIN curriculum_lessons cl ON cl.id = pb.curriculum_lesson_id
|
||||
JOIN curriculum_units cu ON cu.id = cl.unit_id
|
||||
JOIN curriculum_courses cc ON cc.id = cu.course_id
|
||||
JOIN subjects s ON s.id = cc.subject_id
|
||||
JOIN grade_levels g ON g.id = cc.grade_level_id
|
||||
ORDER BY pb.id DESC LIMIT 100"
|
||||
);
|
||||
$submissions = Database::select(
|
||||
"SELECT vv.id AS version_id, vv.uuid AS version_uuid, vv.version_number, vv.status, vv.created_at,
|
||||
t.full_name AS teacher_name, cl.title AS lesson_title
|
||||
FROM video_versions vv
|
||||
JOIN teacher_submissions ts ON ts.id = vv.teacher_submission_id
|
||||
JOIN teachers t ON t.id = ts.teacher_id
|
||||
JOIN curriculum_lessons cl ON cl.id = ts.curriculum_lesson_id
|
||||
ORDER BY vv.id DESC LIMIT 100"
|
||||
);
|
||||
$response->json([
|
||||
'status' => 'success',
|
||||
'data' => [
|
||||
'bundles' => $bundles,
|
||||
'submissions' => $submissions,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function reviewSubmission(Request $request, Response $response): void
|
||||
{
|
||||
$body = $request->getBody();
|
||||
$versionUuid = trim((string)($body['version_uuid'] ?? ''));
|
||||
$decision = (string)($body['decision'] ?? ''); // 'approved' or 'rejected'
|
||||
|
||||
if (!in_array($decision, ['approved', 'rejected'], true) || $versionUuid === '') {
|
||||
$response->status(422)->json(['status' => 'error', 'message' => 'بيانات مراجعة النسخة غير صالحة']);
|
||||
return;
|
||||
}
|
||||
|
||||
$version = Database::selectOne(
|
||||
"SELECT vv.id, vv.teacher_submission_id FROM video_versions vv WHERE vv.uuid = ? LIMIT 1",
|
||||
[$versionUuid]
|
||||
);
|
||||
if (!$version) {
|
||||
$response->status(404)->json(['status' => 'error', 'message' => 'نسخة الفيديو غير موجودة']);
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $decision === 'approved' ? 'published' : 'rejected';
|
||||
Database::query("UPDATE video_versions SET status = ?, reviewed_at = NOW() WHERE id = ?", [$status, (int)$version['id']]);
|
||||
|
||||
if ($decision === 'approved') {
|
||||
Database::query(
|
||||
"UPDATE teacher_submissions SET status = 'published', current_published_video_version_id = ? WHERE id = ?",
|
||||
[(int)$version['id'], (int)$version['teacher_submission_id']]
|
||||
);
|
||||
}
|
||||
|
||||
$response->json(['status' => 'success', 'message' => $decision === 'approved' ? 'تم اعتماد ونشر النسخة بنجاح' : 'تم رفض النسخة']);
|
||||
}
|
||||
|
||||
private function count(string $table): int
|
||||
{
|
||||
$row = Database::selectOne("SELECT COUNT(*) AS count_value FROM `{$table}`");
|
||||
|
||||
Reference in New Issue
Block a user