Update Saqel Platform: 2026-09-08 17:30:46
This commit is contained in:
@@ -75,6 +75,28 @@ class SuperAdminRepository {
|
||||
return _decode(response)['data'];
|
||||
}
|
||||
|
||||
Future<dynamic> _post(String path, Map<String, dynamic> body) async {
|
||||
final response = await http.post(Uri.parse('${AppConfig.apiBaseUrl}$path'), headers: {
|
||||
...await _headers(), 'Content-Type': 'application/json',
|
||||
}, body: json.encode(body)).timeout(const Duration(seconds: 20));
|
||||
return _decode(response)['data'];
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getSchools() async =>
|
||||
(await _get('/api/super-admin/schools') as List).map((e) => Map<String, dynamic>.from(e as Map)).toList();
|
||||
|
||||
Future<List<Map<String, dynamic>>> getStaff() async =>
|
||||
(await _get('/api/super-admin/staff') as List).map((e) => Map<String, dynamic>.from(e as Map)).toList();
|
||||
|
||||
Future<void> saveSchool(Map<String, dynamic> body) async { await _post('/api/super-admin/schools/save', body); }
|
||||
Future<void> toggleSchool(int id) async { await _post('/api/super-admin/schools/toggle', {'id': id}); }
|
||||
Future<void> saveStaff(Map<String, dynamic> body) async { await _post('/api/super-admin/staff/save', body); }
|
||||
Future<void> toggleStaff(int id) async { await _post('/api/super-admin/staff/toggle', {'id': id}); }
|
||||
Future<List<Map<String, dynamic>>> getDirectorates() async =>
|
||||
(await _get('/api/super-admin/directorates') as List).map((e) => Map<String, dynamic>.from(e as Map)).toList();
|
||||
Future<void> saveDirectorate(Map<String, dynamic> body) async { await _post('/api/super-admin/directorates/save', body); }
|
||||
Future<void> toggleDirectorate(int id) async { await _post('/api/super-admin/directorates/toggle', {'id': id}); }
|
||||
|
||||
Future<MacroTelemetryModel> getMacroTelemetry() async =>
|
||||
MacroTelemetryModel.fromJson(Map<String, dynamic>.from(await _get('/api/super-admin/overview') as Map));
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'tabs/macro_radar_tab.dart';
|
||||
import 'tabs/ai_cluster_tab.dart';
|
||||
import 'tabs/treasury_cliq_tab.dart';
|
||||
import 'tabs/security_integrity_tab.dart';
|
||||
import 'tabs/organization_tab.dart';
|
||||
|
||||
/**
|
||||
* ==============================================================================
|
||||
@@ -158,6 +159,7 @@ class _SuperAdminShellState extends State<SuperAdminShell> {
|
||||
alerts: state.alerts,
|
||||
isKillSwitchActive: state.isEmergencyKillSwitchActive,
|
||||
),
|
||||
const OrganizationTab(),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -201,6 +203,11 @@ class _SuperAdminShellState extends State<SuperAdminShell> {
|
||||
activeIcon: Icon(CupertinoIcons.shield_lefthalf_fill, color: SuperAdminTheme.royalGold),
|
||||
label: 'النزاهة والأمان',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(CupertinoIcons.building_2_fill),
|
||||
activeIcon: Icon(CupertinoIcons.building_2_fill, color: SuperAdminTheme.royalGold),
|
||||
label: 'الهيكل الإداري',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import '../../../data/repositories/super_admin_repository.dart';
|
||||
import '../../../core/theme/super_admin_theme.dart';
|
||||
|
||||
class OrganizationTab extends StatefulWidget { const OrganizationTab({super.key}); @override State<OrganizationTab> createState()=>_OrganizationTabState(); }
|
||||
class _OrganizationTabState extends State<OrganizationTab> {
|
||||
final repo=SuperAdminRepository(); final search=TextEditingController(); String role='all';
|
||||
List<Map<String,dynamic>> schools=[], staff=[]; bool loading=true;
|
||||
@override void initState(){super.initState(); _load();}
|
||||
Future<void> _load() async { setState(()=>loading=true); try { final r=await Future.wait([repo.getSchools(),repo.getStaff()]); if(mounted)setState((){schools=r[0];staff=r[1];loading=false;}); } catch(_){if(mounted)setState(()=>loading=false);}}
|
||||
void _showMap(Map<String,dynamic> s) { final lat=(s['latitude'] as num?)?.toDouble()??31.9539, lng=(s['longitude'] as num?)?.toDouble()??35.9106; showDialog(context:context,builder:(_)=>AlertDialog(backgroundColor:SuperAdminTheme.surfaceDark,title:Text('${s['name']}',style:const TextStyle(color:Colors.white)),content:SizedBox(width:500,height:380,child:FlutterMap(options:MapOptions(initialCenter:LatLng(lat,lng),initialZoom:13),children:[TileLayer(urlTemplate:'https://tile.openstreetmap.org/{z}/{x}/{y}.png',userAgentPackageName:'com.saqel.super_admin'),MarkerLayer(markers:[Marker(point:LatLng(lat,lng),width:42,height:42,child:const Icon(Icons.location_pin,color:Colors.red,size:42))])])),actions:[TextButton(onPressed:()=>Navigator.pop(context),child:const Text('إغلاق'))])); }
|
||||
@override Widget build(BuildContext c){ final q=search.text.trim().toLowerCase(); final filtered=staff.where((x)=> (role=='all'||x['role']==role) && (q.isEmpty||'${x['full_name']} ${x['role']}'.toLowerCase().contains(q))).toList(); return RefreshIndicator(onRefresh:_load,child:ListView(padding:const EdgeInsets.all(16),children:[Row(children:[const Expanded(child:Text('إدارة الهيكل الإداري',style:TextStyle(color:Colors.white,fontSize:20,fontWeight:FontWeight.bold))),IconButton(onPressed:_load,icon:const Icon(Icons.refresh,color:SuperAdminTheme.cyberCyan))]),const SizedBox(height:12),TextField(controller:search,onChanged:(_)=>setState((){}),style:const TextStyle(color:Colors.white),decoration:const InputDecoration(labelText:'بحث بالاسم أو الدور',labelStyle:TextStyle(color:Colors.white60),prefixIcon:Icon(Icons.search,color:SuperAdminTheme.cyberCyan))),const SizedBox(height:8),DropdownButton<String>(value:role,isExpanded:true,dropdownColor:SuperAdminTheme.surfaceDark,style:const TextStyle(color:Colors.white),items:const [DropdownMenuItem(value:'all',child:Text('كل الأدوار')),DropdownMenuItem(value:'super_admin',child:Text('Super Admin')),DropdownMenuItem(value:'school_admin',child:Text('مدير مدرسة')),DropdownMenuItem(value:'directorate_admin',child:Text('مدير مديرية')),DropdownMenuItem(value:'supervisor',child:Text('مشرف'))],onChanged:(v)=>setState(()=>role=v??'all')),Text('المدارس (${schools.length})',style:const TextStyle(color:SuperAdminTheme.royalGold,fontWeight:FontWeight.bold)),...schools.map((s)=>ListTile(onTap:()=>_showMap(s),leading:const Icon(Icons.location_on,color:SuperAdminTheme.cyberCyan),title:Text('${s['name']}',style:const TextStyle(color:Colors.white)),subtitle:Text('${s['code']} • ${s['is_active']==1?'نشطة':'موقوفة'} • اضغط لعرض الخريطة',style:const TextStyle(color:Colors.white60)))),const SizedBox(height:12),Text('الحسابات (${filtered.length})',style:const TextStyle(color:SuperAdminTheme.royalGold,fontWeight:FontWeight.bold)),if(loading)const Center(child:CircularProgressIndicator()) else ...filtered.map((s)=>ListTile(leading:const Icon(Icons.badge,color:SuperAdminTheme.cyberCyan),title:Text('${s['full_name']}',style:const TextStyle(color:Colors.white)),subtitle:Text('${s['role']} • ${s['status']}',style:const TextStyle(color:Colors.white60))))])); }
|
||||
}
|
||||
@@ -81,6 +81,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dart_earcut:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dart_earcut
|
||||
sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -142,6 +150,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
flutter_map:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map
|
||||
sha256: "2ecb34619a4be19df6f40c2f8dce1591675b4eff7a6857bd8f533706977385da"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -272,6 +288,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.7"
|
||||
latlong2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: latlong2
|
||||
sha256: "98227922caf49e6056f91b6c56945ea1c7b166f28ffcd5fb8e72fc0b453cc8fe"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.1"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -304,6 +328,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
lists:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lists
|
||||
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
logger:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: logger
|
||||
sha256: "2a0dc097e7b01d942475bdd552356db2d0f768b05540bd4b2b53f1840f2239a7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.8.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -336,6 +376,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
mgrs_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mgrs_dart
|
||||
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
nested:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -432,6 +480,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
polylabel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: polylabel
|
||||
sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
proj4dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: proj4dart
|
||||
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -573,6 +637,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
unicode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unicode
|
||||
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -613,6 +685,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.5"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wkt_parser
|
||||
sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -18,6 +18,8 @@ dependencies:
|
||||
device_info_plus: ^10.1.0
|
||||
google_fonts: ^6.2.1
|
||||
intl: ^0.19.0
|
||||
flutter_map: ^7.0.2
|
||||
latlong2: ^0.9.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -6,9 +6,59 @@ use App\Core\Database;
|
||||
use App\Core\Request;
|
||||
use App\Core\Response;
|
||||
use App\Services\CliqPaymentService;
|
||||
use App\Core\Security;
|
||||
|
||||
class SuperAdminController
|
||||
{
|
||||
public function directorates(Request $request, Response $response): void
|
||||
{ $response->json(['status'=>'success','data'=>Database::select('SELECT * FROM directorates ORDER BY id DESC')]); }
|
||||
|
||||
public function saveDirectorate(Request $request, Response $response): void
|
||||
{ $b=$request->getBody(); $id=(int)($b['id']??0); $name=trim((string)($b['name']??'')); $code=trim((string)($b['code']??'')); if($name===''||$code===''){ $response->status(422)->json(['status'=>'error','message'=>'اسم المديرية والرمز مطلوبان']); return; } if($id>0) Database::query('UPDATE directorates SET name=?,code=?,type=?,commander_name=?,phone=?,is_active=? WHERE id=?',[$name,$code,$b['type']??'ministry',$b['commander_name']??null,$b['phone']??null,(int)($b['is_active']??1),$id]); else Database::query('INSERT INTO directorates(uuid,code,name,type,commander_name,phone) VALUES(UUID(),?,?,?,?,?)',[$code,$name,$b['type']??'ministry',$b['commander_name']??null,$b['phone']??null]); $response->json(['status'=>'success']); }
|
||||
|
||||
public function toggleDirectorate(Request $request, Response $response): void
|
||||
{ $b=$request->getBody(); Database::query("UPDATE directorates SET is_active=IF(is_active=1,0,1) WHERE id=?",[(int)($b['id']??0)]); $response->json(['status'=>'success']); }
|
||||
public function schools(Request $request, Response $response): void
|
||||
{
|
||||
$response->json(['status' => 'success', 'data' => Database::select(
|
||||
"SELECT s.*, d.name AS directorate_name FROM schools s LEFT JOIN directorates d ON d.id=s.directorate_id ORDER BY s.id DESC"
|
||||
)]);
|
||||
}
|
||||
|
||||
public function saveSchool(Request $request, Response $response): void
|
||||
{
|
||||
$b = $request->getBody(); $id = (int)($b['id'] ?? 0);
|
||||
$name = trim((string)($b['name'] ?? '')); $code = trim((string)($b['code'] ?? ''));
|
||||
if ($name === '' || $code === '') { $response->status(422)->json(['status'=>'error','message'=>'اسم المدرسة والرمز مطلوبان']); return; }
|
||||
if ($id > 0) {
|
||||
Database::query("UPDATE schools SET name=?, code=?, type=?, directorate_id=?, director_name=?, phone=?, city=?, governorate=?, latitude=?, longitude=? WHERE id=?", [$name,$code,$b['type']??'center',$b['directorate_id']?:null,$b['director_name']??null,$b['phone']??null,$b['city']??'Amman',$b['governorate']??'العاصمة',$b['latitude']??null,$b['longitude']??null,$id]);
|
||||
} else {
|
||||
Database::query("INSERT INTO schools (uuid,code,name,type,directorate_id,director_name,phone,city,governorate,latitude,longitude) VALUES (UUID(),?,?,?,?,?,?,?,?,?,?)", [$code,$name,$b['type']??'center',$b['directorate_id']?:null,$b['director_name']??null,$b['phone']??null,$b['city']??'Amman',$b['governorate']??'العاصمة',$b['latitude']??null,$b['longitude']??null]);
|
||||
$id=(int)Database::getInstance()->lastInsertId();
|
||||
}
|
||||
$response->json(['status'=>'success','data'=>['id'=>$id]]);
|
||||
}
|
||||
|
||||
public function toggleSchool(Request $request, Response $response): void
|
||||
{ $b=$request->getBody(); Database::query("UPDATE schools SET is_active=IF(is_active=1,0,1) WHERE id=?",[(int)($b['id']??0)]); $response->json(['status'=>'success']); }
|
||||
|
||||
public function staff(Request $request, Response $response): void
|
||||
{ $response->json(['status'=>'success','data'=>Database::select("SELECT sa.id,sa.uuid,sa.identity_id,sa.full_name,sa.role,sa.school_id,sa.directorate_id,sa.status,ai.phone_hash FROM staff_accounts sa JOIN auth_identities ai ON ai.id=sa.identity_id ORDER BY sa.id DESC")]); }
|
||||
|
||||
public function toggleStaff(Request $request, Response $response): void
|
||||
{ $b=$request->getBody(); Database::query("UPDATE staff_accounts SET status=IF(status='active','suspended','active') WHERE id=?",[(int)($b['id']??0)]); $response->json(['status'=>'success']); }
|
||||
|
||||
public function saveStaff(Request $request, Response $response): void
|
||||
{
|
||||
$b=$request->getBody(); $id=(int)($b['id']??0); $role=(string)($b['role']??''); $name=trim((string)($b['full_name']??'')); $phone=preg_replace('/\D+/','',(string)($b['phone']??''));
|
||||
if($name===''||!in_array($role,['super_admin','school_admin','directorate_admin','supervisor'],true)){ $response->status(422)->json(['status'=>'error','message'=>'الاسم والدور مطلوبان']); return; }
|
||||
if(str_starts_with($phone,'07'))$phone='962'.substr($phone,1); $hash=Security::blindIndex($phone);
|
||||
$identity=Database::selectOne('SELECT id FROM auth_identities WHERE phone_hash=? LIMIT 1',[$hash]);
|
||||
if(!$identity){ Database::query("INSERT INTO auth_identities(uuid,phone_number,phone_hash,status) VALUES(UUID(),?,?, 'active')",[Security::encrypt($phone),$hash]); $identity=['id'=>(int)Database::getInstance()->lastInsertId()]; }
|
||||
if($id>0) Database::query('UPDATE staff_accounts SET full_name=?,role=?,school_id=?,directorate_id=?,status=\'active\' WHERE id=?',[$name,$role,$b['school_id']?:null,$b['directorate_id']?:null,$id]);
|
||||
else Database::query("INSERT INTO staff_accounts(uuid,identity_id,full_name,role,school_id,directorate_id) VALUES(UUID(),?,?,?,?,?)",[$identity['id'],$name,$role,$b['school_id']?:null,$b['directorate_id']?:null]);
|
||||
$response->json(['status'=>'success']);
|
||||
}
|
||||
public function overview(Request $request, Response $response): void
|
||||
{
|
||||
CliqPaymentService::ensureSchema();
|
||||
|
||||
@@ -98,6 +98,24 @@ class VideoController
|
||||
return;
|
||||
}
|
||||
|
||||
// No Cloudflare R2 write occurs before this fail-closed media gate.
|
||||
// The report is generated from the real uploaded file while it remains
|
||||
// in PHP's temporary storage.
|
||||
$preflight = AiVideoAnalyzerService::auditUploadBeforeStorage(
|
||||
$_FILES['video'],
|
||||
$title,
|
||||
trim((string)($request->getBody()['subject'] ?? $_POST['subject'] ?? ''))
|
||||
);
|
||||
$auditId = $this->recordUploadAudit($request, $courseId, $_FILES['video'], $preflight);
|
||||
if (($preflight['decision'] ?? '') !== 'approved') {
|
||||
$response->status(422)->json([
|
||||
'status' => 'needs_review',
|
||||
'message' => 'لم يتم حفظ الفيديو في Cloudflare R2 قبل اجتياز تدقيق الجودة.',
|
||||
'data' => ['audit_id' => $auditId, 'preflight_report' => $preflight],
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$uploadResult = VideoService::handleDirectUpload($_FILES['video'], $courseId, $title);
|
||||
|
||||
@@ -116,6 +134,9 @@ class VideoController
|
||||
$uploadResult['duration'] ?? 0
|
||||
]
|
||||
);
|
||||
if ($auditId) {
|
||||
Database::query('UPDATE video_upload_audits SET lesson_id = ? WHERE id = ?', [$lessonId, $auditId]);
|
||||
}
|
||||
|
||||
// Autonomous Zero-Touch AI Analysis & Socratic Checkpoint Generation (Silent Background Execution)
|
||||
$aiReport = AiVideoAnalyzerService::processLessonAutonomously($lessonId);
|
||||
@@ -128,6 +149,8 @@ class VideoController
|
||||
'title' => $title,
|
||||
'course_id' => $courseId,
|
||||
'ai_analysis' => $aiReport
|
||||
,'preflight_report' => $preflight,
|
||||
'audit_id' => $auditId,
|
||||
])
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
@@ -138,6 +161,21 @@ class VideoController
|
||||
}
|
||||
}
|
||||
|
||||
private function recordUploadAudit(Request $request, int $courseId, array $file, array $report): ?int
|
||||
{
|
||||
try {
|
||||
$hex = bin2hex(random_bytes(16));
|
||||
$uuid = substr($hex, 0, 8) . '-' . substr($hex, 8, 4) . '-4' . substr($hex, 13, 3) . '-a' . substr($hex, 17, 3) . '-' . substr($hex, 20);
|
||||
return (int)Database::insert(
|
||||
'INSERT INTO video_upload_audits (uuid, teacher_id, course_id, file_sha256, original_filename, decision, report_json) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[$uuid, (int)$request->user_id, $courseId ?: null, hash_file('sha256', (string)($file['tmp_name'] ?? '')), (string)($file['name'] ?? 'video'), (string)($report['decision'] ?? 'needs_manual_review'), json_encode($report, JSON_UNESCAPED_UNICODE)]
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Video upload audit persistence failed: ' . $e->getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create video entity on Bunny Stream
|
||||
* POST /api/teacher/videos/bunny-create
|
||||
|
||||
@@ -21,6 +21,69 @@ use App\Core\Security;
|
||||
|
||||
class AiVideoAnalyzerService
|
||||
{
|
||||
/**
|
||||
* Fail-closed admission gate for teacher uploads. The media stays in PHP's
|
||||
* temporary upload area until Gemini and technical checks approve it.
|
||||
*/
|
||||
public static function auditUploadBeforeStorage(array $file, string $title, string $subject = ''): array
|
||||
{
|
||||
$path = (string)($file['tmp_name'] ?? '');
|
||||
if ($path === '' || !is_file($path)) {
|
||||
return ['decision' => 'rejected', 'reason' => 'ملف الفيديو غير متاح للتدقيق'];
|
||||
}
|
||||
$mime = (new \finfo(FILEINFO_MIME_TYPE))->file($path) ?: '';
|
||||
if (!str_starts_with($mime, 'video/')) {
|
||||
return ['decision' => 'rejected', 'reason' => 'نوع الملف ليس فيديو صالحاً'];
|
||||
}
|
||||
$ffprobe = trim((string)@shell_exec('command -v ffprobe 2>/dev/null'));
|
||||
if ($ffprobe === '') {
|
||||
return ['decision' => 'needs_manual_review', 'reason' => 'خدمة فحص الوسائط غير متاحة؛ لم يتم رفع الفيديو'];
|
||||
}
|
||||
$raw = @shell_exec(escapeshellarg($ffprobe) . ' -v error -show_entries format=duration -show_streams -of json ' . escapeshellarg($path));
|
||||
$probe = json_decode((string)$raw, true);
|
||||
$duration = (float)($probe['format']['duration'] ?? 0);
|
||||
$hasVideo = false; $hasAudio = false;
|
||||
foreach (($probe['streams'] ?? []) as $stream) {
|
||||
$hasVideo = $hasVideo || (($stream['codec_type'] ?? '') === 'video');
|
||||
$hasAudio = $hasAudio || (($stream['codec_type'] ?? '') === 'audio');
|
||||
}
|
||||
if (!$hasVideo || !$hasAudio || $duration <= 0 || $duration > 1500) {
|
||||
return ['decision' => 'rejected', 'reason' => 'الفيديو يجب أن يحتوي صورة وصوتاً وأن لا يتجاوز 25 دقيقة', 'duration_seconds' => $duration];
|
||||
}
|
||||
$key = trim((string)getenv('GEMINI_API_KEY'));
|
||||
if ($key === '') return ['decision' => 'needs_manual_review', 'reason' => 'Gemini غير مهيأ؛ لم يتم رفع الفيديو', 'duration_seconds' => $duration];
|
||||
|
||||
// Extract a representative frame; Gemini receives real media evidence,
|
||||
// not only the title or metadata supplied by the teacher.
|
||||
$frame = tempnam(sys_get_temp_dir(), 'saqel_audit_') . '.jpg';
|
||||
$ffmpeg = trim((string)@shell_exec('command -v ffmpeg 2>/dev/null'));
|
||||
if ($ffmpeg === '' || @shell_exec(escapeshellarg($ffmpeg) . ' -y -ss ' . escapeshellarg((string)max(1, (int)($duration / 3))) . ' -i ' . escapeshellarg($path) . ' -frames:v 1 -q:v 3 ' . escapeshellarg($frame) . ' 2>/dev/null') === null || !is_file($frame)) {
|
||||
return ['decision' => 'needs_manual_review', 'reason' => 'تعذر استخراج لقطة للتقييم؛ لم يتم رفع الفيديو', 'duration_seconds' => $duration];
|
||||
}
|
||||
try {
|
||||
$prompt = "أنت مدقق جودة تربوية لمنصة صقل. قيّم اللقطة الفعلية من فيديو تعليمي بعنوان: {$title}. المادة: {$subject}. مدة الفيديو: {$duration} ثانية. أخرج JSON فقط: {\"decision\":\"approved|needs_manual_review|rejected\",\"visual_clarity_score\":0-100,\"pedagogical_readiness_score\":0-100,\"report\":\"سبب عربي مختصر\"}. ارفض المحتوى غير التعليمي أو غير الواضح. لا تمنح الموافقة إن لم تظهر أدلة كافية.";
|
||||
$payload = [
|
||||
'contents' => [[
|
||||
'parts' => [
|
||||
['text' => $prompt],
|
||||
['inline_data' => ['mime_type' => 'image/jpeg', 'data' => base64_encode((string)file_get_contents($frame))],],
|
||||
],
|
||||
]],
|
||||
'generationConfig' => ['responseMimeType' => 'application/json', 'temperature' => 0.1],
|
||||
];
|
||||
$ch = curl_init('https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-lite-latest:generateContent?key=' . rawurlencode($key));
|
||||
curl_setopt_array($ch, [CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>json_encode($payload),CURLOPT_HTTPHEADER=>['Content-Type: application/json'],CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>30]);
|
||||
$body = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch);
|
||||
$text = json_decode((string)$body, true)['candidates'][0]['content']['parts'][0]['text'] ?? '';
|
||||
$report = json_decode($text, true);
|
||||
if ($code !== 200 || !is_array($report) || !in_array($report['decision'] ?? '', ['approved','needs_manual_review','rejected'], true)) throw new \RuntimeException('استجابة Gemini غير صالحة');
|
||||
$report['duration_seconds'] = $duration;
|
||||
if (($report['visual_clarity_score'] ?? 0) < 70 || ($report['pedagogical_readiness_score'] ?? 0) < 70) $report['decision'] = 'needs_manual_review';
|
||||
return $report;
|
||||
} catch (\Throwable $e) {
|
||||
return ['decision' => 'needs_manual_review', 'reason' => 'تعذر إكمال تحليل Gemini؛ لم يتم رفع الفيديو'];
|
||||
} finally { @unlink($frame); }
|
||||
}
|
||||
/**
|
||||
* التحليل الآلي المستقل للفيديو وإنشاء الفصول ونقاط الفحص السقراطي في قاعدة البيانات
|
||||
*
|
||||
@@ -138,8 +201,8 @@ class AiVideoAnalyzerService
|
||||
{
|
||||
try {
|
||||
$geminiKey = getenv('GEMINI_API_KEY');
|
||||
$alignmentScore = 96.50;
|
||||
$clarityScore = 94.00;
|
||||
$alignmentScore = 0.0;
|
||||
$clarityScore = 0.0;
|
||||
$outcomes = [
|
||||
"استيعاب المفهوم الرياضي/العلمي لدرس {$lessonTitle}",
|
||||
"تطبيق القواعد والقوانين المعتمدة في كتاب الوزارة",
|
||||
@@ -151,8 +214,8 @@ class AiVideoAnalyzerService
|
||||
'application' => 30,
|
||||
'analysis' => 10
|
||||
];
|
||||
$critique = "الحصة التعليمية مطابقة لمعايير المنهاج الوزاري المعتمد وتغطي نتاجات التعلم الأساسية بكفاءة عالية.";
|
||||
$status = 'approved_official';
|
||||
$critique = "لم يكتمل تدقيق Gemini؛ تتطلب الحصة مراجعة بشرية.";
|
||||
$status = 'needs_manual_review';
|
||||
|
||||
if (!empty($geminiKey)) {
|
||||
$prompt = "أنت كبير المشرفين التربويين في وزارة التربية والتعليم الأردنية لمنصة صَقِل.
|
||||
@@ -241,11 +304,7 @@ class AiVideoAnalyzerService
|
||||
error_log("Pedagogical quality assessment error: " . $e->getMessage());
|
||||
}
|
||||
|
||||
return [
|
||||
'alignment_score' => 96.0,
|
||||
'clarity_score' => 94.0,
|
||||
'status' => 'approved_official'
|
||||
];
|
||||
return ['alignment_score' => 0.0, 'clarity_score' => 0.0, 'status' => 'needs_manual_review'];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -478,6 +478,25 @@ CREATE TABLE IF NOT EXISTS `video_quality_assessments` (
|
||||
CONSTRAINT `fk_vqa_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `video_upload_audits` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL UNIQUE,
|
||||
`teacher_id` BIGINT UNSIGNED NOT NULL,
|
||||
`course_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`lesson_id` BIGINT UNSIGNED DEFAULT NULL,
|
||||
`file_sha256` CHAR(64) NOT NULL,
|
||||
`original_filename` VARCHAR(500) NOT NULL,
|
||||
`decision` ENUM('approved', 'needs_manual_review', 'rejected') NOT NULL,
|
||||
`report_json` JSON NOT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_video_upload_audits_teacher` (`teacher_id`),
|
||||
KEY `idx_video_upload_audits_decision` (`decision`),
|
||||
CONSTRAINT `fk_video_upload_audits_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_video_upload_audits_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_video_upload_audits_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ------------------------------------------------------------------------------
|
||||
-- 12. Table: exams (الامتحانات وبنوك الأسئلة التكيفية: فحص سقراطي، امتحان درس، وحدة، وامتحان وزاري)
|
||||
-- ------------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE IF NOT EXISTS `video_upload_audits` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`uuid` CHAR(36) NOT NULL UNIQUE,
|
||||
`teacher_id` BIGINT UNSIGNED NOT NULL,
|
||||
`course_id` BIGINT UNSIGNED NULL,
|
||||
`lesson_id` BIGINT UNSIGNED NULL,
|
||||
`file_sha256` CHAR(64) NOT NULL,
|
||||
`original_filename` VARCHAR(500) NOT NULL,
|
||||
`decision` ENUM('approved', 'needs_manual_review', 'rejected') NOT NULL,
|
||||
`report_json` JSON NOT NULL,
|
||||
`created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_video_upload_audits_teacher` (`teacher_id`),
|
||||
KEY `idx_video_upload_audits_decision` (`decision`),
|
||||
CONSTRAINT `fk_video_upload_audits_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teachers` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_video_upload_audits_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_video_upload_audits_lesson` FOREIGN KEY (`lesson_id`) REFERENCES `lessons` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -108,6 +108,15 @@ $router->get('/api/super-admin/overview', [\App\Controllers\SuperAdminController
|
||||
$router->get('/api/super-admin/payouts', [\App\Controllers\SuperAdminController::class, 'payouts'], $superAdminMiddleware);
|
||||
$router->get('/api/super-admin/ai-nodes', [\App\Controllers\SuperAdminController::class, 'aiNodes'], $superAdminMiddleware);
|
||||
$router->get('/api/super-admin/security-alerts', [\App\Controllers\SuperAdminController::class, 'securityAlerts'], $superAdminMiddleware);
|
||||
$router->get('/api/super-admin/schools', [\App\Controllers\SuperAdminController::class, 'schools'], $superAdminMiddleware);
|
||||
$router->post('/api/super-admin/schools/save', [\App\Controllers\SuperAdminController::class, 'saveSchool'], $superAdminMiddleware);
|
||||
$router->post('/api/super-admin/schools/toggle', [\App\Controllers\SuperAdminController::class, 'toggleSchool'], $superAdminMiddleware);
|
||||
$router->get('/api/super-admin/staff', [\App\Controllers\SuperAdminController::class, 'staff'], $superAdminMiddleware);
|
||||
$router->post('/api/super-admin/staff/save', [\App\Controllers\SuperAdminController::class, 'saveStaff'], $superAdminMiddleware);
|
||||
$router->post('/api/super-admin/staff/toggle', [\App\Controllers\SuperAdminController::class, 'toggleStaff'], $superAdminMiddleware);
|
||||
$router->get('/api/super-admin/directorates', [\App\Controllers\SuperAdminController::class, 'directorates'], $superAdminMiddleware);
|
||||
$router->post('/api/super-admin/directorates/save', [\App\Controllers\SuperAdminController::class, 'saveDirectorate'], $superAdminMiddleware);
|
||||
$router->post('/api/super-admin/directorates/toggle', [\App\Controllers\SuperAdminController::class, 'toggleDirectorate'], $superAdminMiddleware);
|
||||
|
||||
// OTP Authentication Routes (WhatsApp via Nabeh Gateway + Device Fingerprinting)
|
||||
$router->post('/api/auth/otp/request', [\App\Controllers\AuthController::class, 'requestOtp'], [\App\Middlewares\RateLimitMiddleware::class]);
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
- تشغيل الطالب يرفض الدرس الذي لا يملك فيديو حقيقياً؛ أزيل فيديو الاختبار العام.
|
||||
- تقديم الامتحان وتصحيحه ودفتر الأخطاء والتقارير الشهرية تتم من الخادم فقط.
|
||||
- المحادثة تستخدم `auth_identity_id` وتسمح فقط بعلاقة طالب/معلم لها تصريح دورة فعلي.
|
||||
- مبدأ تخزين المحتوى التعليمي المعتمد: قاعدة البيانات تحفظ الفهرس والبيانات الوصفية والحالة والصلاحيات فقط، ولا تحفظ نصوص الكتب أو ملفات HTML أو الفيديو داخلها.
|
||||
- ملفات الكتب بعد استخراجها ومراجعتها (Markdown)، وأوراق العمل، وملفات HTML للتجارب تُحفظ كـ versioned objects في تخزين الملفات الأصلي (R2 أو مساحة تخزين خاصة مكافئة)، وتُستدعى بروابط موقعة ومحددة الصلاحية.
|
||||
- الفيديوهات تُرفع إلى R2 وتُحوّل إلى HLS؛ قاعدة البيانات تحفظ `object_key` و`hls_url` وحالة المعالجة والنسخة فقط.
|
||||
- Redis ليس مخزناً دائماً للمحتوى: يستخدم للجلسات وOTP وrate limits، ولـ cache قصير العمر لشجرة المنهج أو Markdown المتكرر، مع TTL وإمكانية إعادة البناء من R2.
|
||||
- كل درس/وحدة/ورقة عمل/تجربة لها سجل إصدار ومصدر ومراجع وحالة (`draft`, `review`, `published`, `archived`) لمنع عرض ملف غير معتمد.
|
||||
- اختبارات Smoke للتطبيقات الأربعة ناجحة، وتحليل Flutter لا يحتوي أخطاء compile.
|
||||
- فحص syntax لجميع ملفات PHP ناجح و`git diff --check` نظيف.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user