Complete Documentation & Implementation
Table of Contents
-
System Overview
-
Installation & Setup
-
Database Schema
-
Laravel Backend Implementation
-
Frontend Implementation
-
Moodle Plugin
-
API Documentation
-
Deployment Guide
-
Security & Privacy
-
Performance Optimization
System Overview
Architecture Diagram
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Moodle LMS │ │ Laravel API │ │ Vue.js Client │
│ │ │ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Plugin │◄┼────┤ │ Auth Service│ │ │ │ Proctoring │ │
│ │ │ │ │ │ │ │ │ │ Interface │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
│ │ │ │ │ │
│ ┌─────────────┐ │ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ Quiz │ │ │ │ Recording │ │ │ │ Monitoring │ │
│ │ Access │ │ │ │ Service │ │ │ │ Dashboard │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ └─────────────┘ │ │ └─────────────┘ │ │ └─────────────┘ │
└─────────────────┘ └─────────────────┘ └─────────────────┘
Technology Stack
-
Backend: Laravel 10.x with PHP 8.1+
-
Frontend: Vue.js 3 with Composition API
-
Database: MySQL 8.0+
-
Real-time: Laravel Echo + Pusher/Redis
-
Storage: AWS S3 or local storage
-
Queue: Redis with Laravel Horizon
-
Caching: Redis
Installation & Setup
Prerequisites
# Required software
- PHP 8.1+
- Node.js 18+
- MySQL 8.0+
- Redis 6.0+
- Composer
- npm/yarn
Laravel Backend Setup
# Create new Laravel project
composer create-project laravel/laravel proctoring-system
cd proctoring-system
# Install required packages
composer require laravel/sanctum
composer require pusher/pusher-php-server
composer require league/flysystem-aws-s3-v3
composer require intervention/image
composer require laravel/horizon
# Install frontend dependencies
npm install vue@next @vitejs/plugin-vue
npm install @tensorflow/tfjs
npm install face-api.js
npm install recordrtc
npm install pusher-js laravel-echo
Environment Configuration
# .env file
APP_NAME="Proctoring System"
APP_ENV=production
APP_KEY=base64:your-app-key
APP_DEBUG=false
APP_URL=https://your-domain.com
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=proctoring_system
DB_USERNAME=your_username
DB_PASSWORD=your_password
BROADCAST_DRIVER=pusher
CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
PUSHER_APP_ID=your_app_id
PUSHER_APP_KEY=your_app_key
PUSHER_APP_SECRET=your_app_secret
PUSHER_HOST=
PUSHER_PORT=443
PUSHER_SCHEME=https
PUSHER_APP_CLUSTER=mt1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=your-bucket-name
MOODLE_URL=https://your-moodle-site.com
MOODLE_TOKEN=your_moodle_webservice_token
Database Schema
Migration Files
<?php
// database/migrations/create_proctoring_sessions_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('proctoring_sessions', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('student_id');
$table->unsignedBigInteger('quiz_id');
$table->unsignedBigInteger('moodle_user_id');
$table->string('session_token', 255)->unique();
$table->timestamp('start_time');
$table->timestamp('end_time')->nullable();
$table->enum('status', ['active', 'completed', 'terminated', 'paused'])->default('active');
$table->json('device_info');
$table->json('recordings')->nullable();
$table->string('ip_address', 45);
$table->text('user_agent');
$table->integer('violation_count')->default(0);
$table->decimal('overall_score', 5, 2)->default(100.00);
$table->timestamps();
$table->index(['student_id', 'quiz_id']);
$table->index('session_token');
$table->index('status');
});
}
public function down()
{
Schema::dropIfExists('proctoring_sessions');
}
};
<?php
// database/migrations/create_violations_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('violations', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('session_id');
$table->enum('type', [
'no_face', 'multiple_faces', 'face_not_clear', 'tab_switch',
'window_blur', 'phone_detected', 'book_detected', 'person_detected',
'screen_share_ended', 'fullscreen_exit', 'right_click', 'copy_paste',
'browser_extension', 'virtual_machine'
]);
$table->timestamp('timestamp');
$table->decimal('confidence_score', 5, 2)->default(0.00);
$table->string('screenshot_path')->nullable();
$table->integer('video_bookmark_time'); // seconds from start
$table->json('detection_data')->nullable();
$table->enum('severity', ['low', 'medium', 'high', 'critical'])->default('medium');
$table->boolean('reviewed')->default(false);
$table->text('proctor_notes')->nullable();
$table->timestamps();
$table->foreign('session_id')->references('id')->on('proctoring_sessions')->onDelete('cascade');
$table->index(['session_id', 'type']);
$table->index('timestamp');
$table->index('severity');
});
}
public function down()
{
Schema::dropIfExists('violations');
}
};
<?php
// database/migrations/create_recording_chunks_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up()
{
Schema::create('recording_chunks', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('session_id');
$table->enum('type', ['screen', 'webcam', 'audio']);
$table->string('file_path');
$table->integer('chunk_number');
$table->integer('start_time'); // seconds from session start
$table->integer('duration'); // seconds
$table->unsignedBigInteger('file_size');
$table->string('mime_type');
$table->enum('status', ['uploading', 'completed', 'failed'])->default('uploading');
$table->timestamps();
$table->foreign('session_id')->references('id')->on('proctoring_sessions')->onDelete('cascade');
$table->index(['session_id', 'type', 'chunk_number']);
});
}
public function down()
{
Schema::dropIfExists('recording_chunks');
}
};
Laravel Backend Implementation
Models
<?php
// app/Models/ProctoringSession.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class ProctoringSession extends Model
{
use HasFactory;
protected $fillable = [
'student_id',
'quiz_id',
'moodle_user_id',
'session_token',
'start_time',
'end_time',
'status',
'device_info',
'recordings',
'ip_address',
'user_agent',
'violation_count',
'overall_score'
];
protected $casts = [
'device_info' => 'array',
'recordings' => 'array',
'start_time' => 'datetime',
'end_time' => 'datetime',
'overall_score' => 'decimal:2'
];
public function violations(): HasMany
{
return $this->hasMany(Violation::class, 'session_id');
}
public function recordingChunks(): HasMany
{
return $this->hasMany(RecordingChunk::class, 'session_id');
}
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeCompleted($query)
{
return $query->where('status', 'completed');
}
public function addViolation(string $type, float $confidence, array $data = [])
{
$this->violations()->create([
'type' => $type,
'timestamp' => now(),
'confidence_score' => $confidence,
'video_bookmark_time' => now()->diffInSeconds($this->start_time),
'detection_data' => $data,
'severity' => $this->calculateSeverity($type, $confidence)
]);
$this->increment('violation_count');
$this->updateOverallScore();
}
private function calculateSeverity(string $type, float $confidence): string
{
$criticalViolations = ['screen_share_ended', 'tab_switch', 'virtual_machine'];
$highViolations = ['no_face', 'multiple_faces', 'phone_detected'];
if (in_array($type, $criticalViolations)) {
return 'critical';
} elseif (in_array($type, $highViolations) && $confidence > 0.8) {
return 'high';
} elseif ($confidence > 0.6) {
return 'medium';
}
return 'low';
}
private function updateOverallScore()
{
$penalties = [
'critical' => 20,
'high' => 10,
'medium' => 5,
'low' => 2
];
$totalPenalty = $this->violations()
->get()
->sum(function ($violation) use ($penalties) {
return $penalties[$violation->severity] ?? 0;
});
$this->overall_score = max(0, 100 - $totalPenalty);
$this->save();
}
}
<?php
// app/Models/Violation.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Violation extends Model
{
use HasFactory;
protected $fillable = [
'session_id',
'type',
'timestamp',
'confidence_score',
'screenshot_path',
'video_bookmark_time',
'detection_data',
'severity',
'reviewed',
'proctor_notes'
];
protected $casts = [
'timestamp' => 'datetime',
'detection_data' => 'array',
'confidence_score' => 'decimal:2',
'reviewed' => 'boolean'
];
public function session(): BelongsTo
{
return $this->belongsTo(ProctoringSession::class, 'session_id');
}
public function scopeUnreviewed($query)
{
return $query->where('reviewed', false);
}
public function scopeBySeverity($query, string $severity)
{
return $query->where('severity', $severity);
}
}
Controllers
<?php
// app/Http/Controllers/ProctoringController.php
namespace App\Http\Controllers;
use App\Models\ProctoringSession;
use App\Models\Violation;
use App\Services\MoodleService;
use App\Services\ProctoringService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class ProctoringController extends Controller
{
protected ProctoringService $proctoringService;
protected MoodleService $moodleService;
public function __construct(ProctoringService $proctoringService, MoodleService $moodleService)
{
$this->proctoringService = $proctoringService;
$this->moodleService = $moodleService;
}
public function initializeSession(Request $request): JsonResponse
{
$validated = $request->validate([
'student_id' => 'required|integer',
'quiz_id' => 'required|integer',
'moodle_user_id' => 'required|integer'
]);
try {
// Verify student access to quiz via Moodle
$quizAccess = $this->moodleService->verifyQuizAccess(
$validated['moodle_user_id'],
$validated['quiz_id']
);
if (!$quizAccess) {
return response()->json(['error' => 'Quiz access denied'], 403);
}
// Create proctoring session
$session = ProctoringSession::create([
'student_id' => $validated['student_id'],
'quiz_id' => $validated['quiz_id'],
'moodle_user_id' => $validated['moodle_user_id'],
'session_token' => Str::random(64),
'start_time' => now(),
'device_info' => $this->extractDeviceInfo($request),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'status' => 'active'
]);
// Generate Moodle quiz URL with token
$quizUrl = $this->moodleService->generateQuizUrl(
$validated['quiz_id'],
$session->session_token
);
return response()->json([
'session_id' => $session->id,
'session_token' => $session->session_token,
'quiz_url' => $quizUrl,
'websocket_channel' => "proctoring.{$session->id}",
'requirements' => $this->getSystemRequirements()
]);
} catch (\Exception $e) {
Log::error('Failed to initialize proctoring session', [
'error' => $e->getMessage(),
'request' => $validated
]);
return response()->json(['error' => 'Failed to initialize session'], 500);
}
}
public function validateToken(string $token): JsonResponse
{
$session = ProctoringSession::where('session_token', $token)
->where('status', 'active')
->first();
if (!$session) {
return response()->json(['valid' => false], 404);
}
// Check if session is still within time limits
if ($session->start_time->diffInHours(now()) > 4) {
$session->update(['status' => 'terminated']);
return response()->json(['valid' => false], 403);
}
return response()->json([
'valid' => true,
'session_id' => $session->id,
'student_id' => $session->student_id,
'quiz_id' => $session->quiz_id
]);
}
public function recordViolation(Request $request, int $sessionId): JsonResponse
{
$validated = $request->validate([
'type' => 'required|string',
'confidence' => 'required|numeric|between:0,1',
'screenshot' => 'nullable|string', // base64 encoded
'detection_data' => 'nullable|array'
]);
try {
$session = ProctoringSession::findOrFail($sessionId);
// Save screenshot if provided
$screenshotPath = null;
if ($validated['screenshot']) {
$screenshotPath = $this->saveScreenshot(
$validated['screenshot'],
$sessionId
);
}
// Create violation record
$violation = $session->violations()->create([
'type' => $validated['type'],
'timestamp' => now(),
'confidence_score' => $validated['confidence'],
'screenshot_path' => $screenshotPath,
'video_bookmark_time' => now()->diffInSeconds($session->start_time),
'detection_data' => $validated['detection_data'] ?? [],
'severity' => $this->calculateSeverity($validated['type'], $validated['confidence'])
]);
// Update session violation count and score
$session->increment('violation_count');
$this->updateSessionScore($session);
// Broadcast violation to proctors
broadcast(new \App\Events\ViolationDetected($violation))->toOthers();
// Check if session should be terminated
if ($this->shouldTerminateSession($session)) {
$session->update(['status' => 'terminated']);
broadcast(new \App\Events\SessionTerminated($session))->toOthers();
}
return response()->json([
'violation_id' => $violation->id,
'session_status' => $session->status,
'violation_count' => $session->violation_count,
'overall_score' => $session->overall_score
]);
} catch (\Exception $e) {
Log::error('Failed to record violation', [
'error' => $e->getMessage(),
'session_id' => $sessionId,
'violation_data' => $validated
]);
return response()->json(['error' => 'Failed to record violation'], 500);
}
}
public function uploadRecordingChunk(Request $request, int $sessionId): JsonResponse
{
$validated = $request->validate([
'type' => 'required|in:screen,webcam,audio',
'chunk_number' => 'required|integer',
'file' => 'required|file|max:10240', // 10MB max
'start_time' => 'required|integer',
'duration' => 'required|integer'
]);
try {
$session = ProctoringSession::findOrFail($sessionId);
// Generate file path
$fileName = sprintf(
'%s_%s_chunk_%d.webm',
$sessionId,
$validated['type'],
$validated['chunk_number']
);
$filePath = "recordings/{$sessionId}/{$fileName}";
// Store file
$path = Storage::disk('s3')->put($filePath, $request->file('file'));
// Create chunk record
$chunk = $session->recordingChunks()->create([
'type' => $validated['type'],
'file_path' => $path,
'chunk_number' => $validated['chunk_number'],
'start_time' => $validated['start_time'],
'duration' => $validated['duration'],
'file_size' => $request->file('file')->getSize(),
'mime_type' => $request->file('file')->getMimeType(),
'status' => 'completed'
]);
return response()->json([
'chunk_id' => $chunk->id,
'status' => 'uploaded'
]);
} catch (\Exception $e) {
Log::error('Failed to upload recording chunk', [
'error' => $e->getMessage(),
'session_id' => $sessionId,
'chunk_data' => $validated
]);
return response()->json(['error' => 'Failed to upload chunk'], 500);
}
}
public function endSession(Request $request, int $sessionId): JsonResponse
{
try {
$session = ProctoringSession::findOrFail($sessionId);
$session->update([
'status' => 'completed',
'end_time' => now()
]);
// Queue video processing job
\App\Jobs\ProcessSessionRecording::dispatch($session);
// Notify Moodle of session completion
$this->moodleService->notifySessionCompletion($session);
return response()->json([
'status' => 'completed',
'session_duration' => $session->start_time->diffInMinutes($session->end_time),
'total_violations' => $session->violation_count,
'overall_score' => $session->overall_score
]);
} catch (\Exception $e) {
Log::error('Failed to end session', [
'error' => $e->getMessage(),
'session_id' => $sessionId
]);
return response()->json(['error' => 'Failed to end session'], 500);
}
}
private function extractDeviceInfo(Request $request): array
{
$userAgent = $request->userAgent();
return [
'user_agent' => $userAgent,
'ip_address' => $request->ip(),
'screen_resolution' => $request->input('screen_resolution'),
'timezone' => $request->input('timezone'),
'language' => $request->input('language'),
'platform' => $this->detectPlatform($userAgent),
'browser' => $this->detectBrowser($userAgent)
];
}
private function detectPlatform(string $userAgent): string
{
if (preg_match('/Windows/i', $userAgent)) return 'Windows';
if (preg_match('/Mac/i', $userAgent)) return 'macOS';
if (preg_match('/Linux/i', $userAgent)) return 'Linux';
if (preg_match('/Android/i', $userAgent)) return 'Android';
if (preg_match('/iPhone|iPad/i', $userAgent)) return 'iOS';
return 'Unknown';
}
private function detectBrowser(string $userAgent): string
{
if (preg_match('/Chrome/i', $userAgent)) return 'Chrome';
if (preg_match('/Firefox/i', $userAgent)) return 'Firefox';
if (preg_match('/Safari/i', $userAgent)) return 'Safari';
if (preg_match('/Edge/i', $userAgent)) return 'Edge';
return 'Unknown';
}
private function saveScreenshot(string $base64Data, int $sessionId): string
{
$imageData = base64_decode(preg_replace('/^data:image\/\w+;base64,/', '', $base64Data));
$fileName = sprintf('screenshot_%s_%s.jpg', $sessionId, now()->timestamp);
$filePath = "screenshots/{$sessionId}/{$fileName}";
Storage::disk('s3')->put($filePath, $imageData);
return $filePath;
}
private function calculateSeverity(string $type, float $confidence): string
{
$criticalViolations = ['screen_share_ended', 'tab_switch', 'virtual_machine'];
$highViolations = ['no_face', 'multiple_faces', 'phone_detected'];
if (in_array($type, $criticalViolations)) {
return 'critical';
} elseif (in_array($type, $highViolations) && $confidence > 0.8) {
return 'high';
} elseif ($confidence > 0.6) {
return 'medium';
}
return 'low';
}
private function updateSessionScore(ProctoringSession $session): void
{
$penalties = [
'critical' => 20,
'high' => 10,
'medium' => 5,
'low' => 2
];
$totalPenalty = $session->violations()
->get()
->sum(function ($violation) use ($penalties) {
return $penalties[$violation->severity] ?? 0;
});
$session->update([
'overall_score' => max(0, 100 - $totalPenalty)
]);
}
private function shouldTerminateSession(ProctoringSession $session): bool
{
$criticalViolations = $session->violations()
->where('severity', 'critical')
->count();
$highViolations = $session->violations()
->where('severity', 'high')
->count();
return $criticalViolations >= 3 || $highViolations >= 5 || $session->overall_score < 30;
}
private function getSystemRequirements(): array
{
return [
'webcam' => true,
'microphone' => true,
'screen_sharing' => true,
'browsers' => ['chrome', 'firefox', 'edge'],
'min_bandwidth' => '1mbps',
'fullscreen_required' => true
];
}
}
Services
<?php
// app/Services/MoodleService.php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class MoodleService
{
protected string $baseUrl;
protected string $token;
public function __construct()
{
$this->baseUrl = config('services.moodle.url');
$this->token = config('services.moodle.token');
}
public function verifyQuizAccess(int $userId, int $quizId): bool
{
try {
$response = Http::get("{$this->baseUrl}/webservice/rest/server.php", [
'wstoken' => $this->token,
'wsfunction' => 'mod_quiz_get_quiz_access_information',
'moodlewsrestformat' => 'json',
'quizid' => $quizId,
'userid' => $userId
]);
if ($response->successful()) {
$data = $response->json();
return $data['canattempt'] ?? false;
}
return false;
} catch (\Exception $e) {
Log::error('Moodle quiz access verification failed', [
'error' => $e->getMessage(),
'user_id' => $userId,
'quiz_id' => $quizId
]);
return false;
}
}
public function generateQuizUrl(int $quizId, string $proctoringToken): string
{
return "{$this->baseUrl}/mod/quiz/view.php?id={$quizId}&proctoring_token={$proctoringToken}";
}
public function notifySessionCompletion(object $session): void
{
try {
Http::post("{$this->baseUrl}/webservice/rest/server.php", [
'wstoken' => $this->token,
'wsfunction' => 'local_proctoring_session_completed',
'moodlewsrestformat' => 'json',
'sessiondata' => json_encode([
'session_id' => $session->id,
'user_id' => $session->moodle_user_id,
'quiz_id' => $session->quiz_id,
'violation_count' => $session->violation_count,
'overall_score' => $session->overall_score,
'duration' => $session->start_time->diffInMinutes($session->end_time)
])
]);
} catch (\Exception $e) {
Log::error('Failed to notify Moodle of session completion', [
'error' => $e->getMessage(),
'session_id' => $session->id
]);
}
}
public function getUserInfo(int $userId): array
{
try {
$response = Http::get("{$this->baseUrl}/webservice/rest/server.php", [
'wstoken' => $this->token,
'wsfunction' => 'core_user_get_users_by_field',
'moodlewsrestformat' => 'json',
'field' => 'id',
'values[0]' => $userId
]);
if ($response->successful()) {
$data = $response->json();
return $data[0] ?? [];
}
return [];
} catch (\Exception $e) {
Log::error('Failed to get Moodle user info', [
'error' => $e->getMessage(),
'user_id' => $userId
]);
return [];
}
}
}
Events
<?php
// app/Events/ViolationDetected.php
namespace App\Events;
use App\Models\Violation;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ViolationDetected implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public Violation $violation;
public function __construct(Violation $violation)
{
$this->violation = $violation;
}
public function broadcastOn(): array
{
return [
new PrivateChannel('proctoring.' . $this->violation->session_id),
new PrivateChannel('proctor.dashboard')
];
}
public function broadcastWith(): array
{
return [
'violation' => [
'id' => $this->violation->id,
'session_id' => $this->violation->session_id,
'type' => $this->violation->type,
'timestamp' => $this->violation->timestamp,
'confidence_score' => $this->violation->confidence_score,
'severity' => $this->violation->severity,
'video_bookmark_time' => $this->violation->video_bookmark_time,
'detection_data' => $this->violation->detection_data
],
'session' => [
'id' => $this->violation->session->id,
'student_id' => $this->violation->session->student_id,
'quiz_id' => $this->violation->session->quiz_id,
'violation_count' => $this->violation->session->violation_count,
'overall_score' => $this->violation->session->overall_score
]
];
}
}
<?php
// app/Events/SessionTerminated.php
namespace App\Events;
use App\Models\ProctoringSession;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PresenceChannel;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class SessionTerminated implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public ProctoringSession $session;
public function __construct(ProctoringSession $session)
{
$this->session = $session;
}
public function broadcastOn(): array
{
return [
new PrivateChannel('proctoring.' . $this->session->id),
new PrivateChannel('proctor.dashboard')
];
}
public function broadcastWith(): array
{
return [
'session' => [
'id' => $this->session->id,
'student_id' => $this->session->student_id,
'quiz_id' => $this->session->quiz_id,
'status' => $this->session->status,
'violation_count' => $this->session->violation_count,
'overall_score' => $this->session->overall_score,
'termination_reason' => 'Excessive violations detected'
]
];
}
}
Jobs
<?php
// app/Jobs/ProcessSessionRecording.php
namespace App\Jobs;
use App\Models\ProctoringSession;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class ProcessSessionRecording implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected ProctoringSession $session;
public function __construct(ProctoringSession $session)
{
$this->session = $session;
}
public function handle(): void
{
try {
Log::info('Starting recording processing for session', ['session_id' => $this->session->id]);
// Get all recording chunks
$screenChunks = $this->session->recordingChunks()
->where('type', 'screen')
->orderBy('chunk_number')
->get();
$webcamChunks = $this->session->recordingChunks()
->where('type', 'webcam')
->orderBy('chunk_number')
->get();
// Process screen recording
if ($screenChunks->isNotEmpty()) {
$screenVideoPath = $this->mergeVideoChunks($screenChunks, 'screen');
$this->addViolationBookmarks($screenVideoPath, 'screen');
}
// Process webcam recording
if ($webcamChunks->isNotEmpty()) {
$webcamVideoPath = $this->mergeVideoChunks($webcamChunks, 'webcam');
$this->addViolationBookmarks($webcamVideoPath, 'webcam');
}
// Update session with final recording paths
$this->session->update([
'recordings' => [
'screen' => $screenVideoPath ?? null,
'webcam' => $webcamVideoPath ?? null,
'processed_at' => now()
]
]);
Log::info('Recording processing completed', ['session_id' => $this->session->id]);
} catch (\Exception $e) {
Log::error('Recording processing failed', [
'session_id' => $this->session->id,
'error' => $e->getMessage()
]);
$this->fail($e);
}
}
private function mergeVideoChunks($chunks, string $type): string
{
$outputPath = "recordings/{$this->session->id}/merged_{$type}.mp4";
// Create file list for FFmpeg
$fileList = $chunks->map(function ($chunk) {
return "file '" . Storage::disk('s3')->url($chunk->file_path) . "'";
})->implode("\n");
$listPath = storage_path("app/temp/filelist_{$this->session->id}_{$type}.txt");
file_put_contents($listPath, $fileList);
// Run FFmpeg to merge chunks
$command = "ffmpeg -f concat -safe 0 -i {$listPath} -c copy " . storage_path("app/temp/merged_{$type}.mp4");
exec($command);
// Upload merged video to S3
$mergedContent = file_get_contents(storage_path("app/temp/merged_{$type}.mp4"));
Storage::disk('s3')->put($outputPath, $mergedContent);
// Clean up temp files
unlink($listPath);
unlink(storage_path("app/temp/merged_{$type}.mp4"));
return $outputPath;
}
private function addViolationBookmarks(string $videoPath, string $type): void
{
$violations = $this->session->violations()
->orderBy('timestamp')
->get();
if ($violations->isEmpty()) {
return;
}
// Create chapter file for video bookmarks
$chapters = $violations->map(function ($violation) {
return sprintf(
"[CHAPTER]\nTIMEBASE=1/1\nSTART=%d\nEND=%d\ntitle=%s - %s (%.2f%%)",
$violation->video_bookmark_time,
$violation->video_bookmark_time + 5,
strtoupper($violation->type),
$violation->severity,
$violation->confidence_score * 100
);
})->implode("\n\n");
$chaptersPath = storage_path("app/temp/chapters_{$this->session->id}_{$type}.txt");
file_put_contents($chaptersPath, $chapters);
// Add chapters to video
$inputPath = Storage::disk('s3')->url($videoPath);
$outputPath = "recordings/{$this->session->id}/final_{$type}.mp4";
$command = "ffmpeg -i {$inputPath} -i {$chaptersPath} -map 0 -map_metadata 1 -c copy " .
storage_path("app/temp/final_{$type}.mp4");
exec($command);
// Upload final video
$finalContent = file_get_contents(storage_path("app/temp/final_{$type}.mp4"));
Storage::disk('s3')->put($outputPath, $finalContent);
// Clean up
unlink($chaptersPath);
unlink(storage_path("app/temp/final_{$type}.mp4"));
// Update session recordings
$recordings = $this->session->recordings ?? [];
$recordings[$type] = $outputPath;
$this->session->update(['recordings' => $recordings]);
}
}
Proctor Dashboard Controller
<?php
// app/Http/Controllers/ProctorDashboardController.php
namespace App\Http\Controllers;
use App\Models\ProctoringSession;
use App\Models\Violation;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Storage;
class ProctorDashboardController extends Controller
{
public function getActiveSessions(): JsonResponse
{
$sessions = ProctoringSession::with(['violations' => function ($query) {
$query->orderBy('timestamp', 'desc')->limit(5);
}])
->where('status', 'active')
->select(['id', 'student_id', 'quiz_id', 'start_time', 'violation_count', 'overall_score'])
->get();
return response()->json($sessions);
}
public function getSessionDetails(int $sessionId): JsonResponse
{
$session = ProctoringSession::with(['violations' => function ($query) {
$query->orderBy('timestamp', 'desc');
}])
->findOrFail($sessionId);
return response()->json([
'session' => $session,
'violations' => $session->violations,
'recording_urls' => $this->getRecordingUrls($session),
'statistics' => $this->getSessionStatistics($session)
]);
}
public function getViolationDetails(int $violationId): JsonResponse
{
$violation = Violation::with('session')->findOrFail($violationId);
return response()->json([
'violation' => $violation,
'screenshot_url' => $violation->screenshot_path ?
Storage::disk('s3')->temporaryUrl($violation->screenshot_path, now()->addHour()) : null,
'video_url' => $this->getVideoUrlWithTimestamp($violation),
'context' => $this->getViolationContext($violation)
]);
}
public function updateViolationStatus(Request $request, int $violationId): JsonResponse
{
$validated = $request->validate([
'reviewed' => 'required|boolean',
'proctor_notes' => 'nullable|string'
]);
$violation = Violation::findOrFail($violationId);
$violation->update($validated);
return response()->json(['status' => 'updated']);
}
public function terminateSession(Request $request, int $sessionId): JsonResponse
{
$validated = $request->validate([
'reason' => 'required|string'
]);
$session = ProctoringSession::findOrFail($sessionId);
$session->update([
'status' => 'terminated',
'end_time' => now()
]);
// Log termination reason
$session->violations()->create([
'type' => 'manual_termination',
'timestamp' => now(),
'confidence_score' => 1.0,
'video_bookmark_time' => now()->diffInSeconds($session->start_time),
'detection_data' => ['reason' => $validated['reason']],
'severity' => 'critical'
]);
// Broadcast termination
broadcast(new \App\Events\SessionTerminated($session));
return response()->json(['status' => 'terminated']);
}
public function getSessionRecording(int $sessionId, string $type): JsonResponse
{
$session = ProctoringSession::findOrFail($sessionId);
$recordings = $session->recordings ?? [];
if (!isset($recordings[$type])) {
return response()->json(['error' => 'Recording not found'], 404);
}
$videoUrl = Storage::disk('s3')->temporaryUrl($recordings[$type], now()->addHours(2));
return response()->json([
'video_url' => $videoUrl,
'violations' => $this->getViolationsForVideo($session),
'duration' => $session->end_time ?
$session->start_time->diffInSeconds($session->end_time) :
$session->start_time->diffInSeconds(now())
]);
}
private function getRecordingUrls(ProctoringSession $session): array
{
$recordings = $session->recordings ?? [];
$urls = [];
foreach ($recordings as $type => $path) {
if ($path && Storage::disk('s3')->exists($path)) {
$urls[$type] = Storage::disk('s3')->temporaryUrl($path, now()->addHours(2));
}
}
return $urls;
}
private function getSessionStatistics(ProctoringSession $session): array
{
$violations = $session->violations;
return [
'total_violations' => $violations->count(),
'violations_by_type' => $violations->groupBy('type')->map->count(),
'violations_by_severity' => $violations->groupBy('severity')->map->count(),
'duration_minutes' => $session->end_time ?
$session->start_time->diffInMinutes($session->end_time) :
$session->start_time->diffInMinutes(now()),
'avg_confidence' => $violations->avg('confidence_score'),
'peak_violation_time' => $this->getPeakViolationTime($violations)
];
}
private function getVideoUrlWithTimestamp(Violation $violation): ?string
{
$recordings = $violation->session->recordings ?? [];
if (!isset($recordings['screen'])) {
return null;
}
$baseUrl = Storage::disk('s3')->temporaryUrl($recordings['screen'], now()->addHours(2));
return $baseUrl . '#t=' . $violation->video_bookmark_time;
}
private function getViolationContext(Violation $violation): array
{
// Get violations within 30 seconds before and after
$contextViolations = $violation->session->violations()
->whereBetween('video_bookmark_time', [
max(0, $violation->video_bookmark_time - 30),
$violation->video_bookmark_time + 30
])
->where('id', '!=', $violation->id)
->orderBy('video_bookmark_time')
->get();
return [
'before' => $contextViolations->where('video_bookmark_time', '<', $violation->video_bookmark_time)->values(),
'after' => $contextViolations->where('video_bookmark_time', '>', $violation->video_bookmark_time)->values()
];
}
private function getViolationsForVideo(ProctoringSession $session): array
{
return $session->violations()
->orderBy('video_bookmark_time')
->get()
->map(function ($violation) {
return [
'id' => $violation->id,
'type' => $violation->type,
'time' => $violation->video_bookmark_time,
'severity' => $violation->severity,
'confidence' => $violation->confidence_score,
'reviewed' => $violation->reviewed
];
})
->toArray();
}
private function getPeakViolationTime($violations): ?int
{
if ($violations->isEmpty()) {
return null;
}
// Group violations by 5-minute intervals
$intervals = $violations->groupBy(function ($violation) {
return intval($violation->video_bookmark_time / 300) * 300;
});
// Find interval with most violations
$peakInterval = $intervals->sortByDesc(function ($group) {
return $group->count();
})->first();
return $peakInterval ? $peakInterval->first()->video_bookmark_time : null;
}
}
Frontend Implementation
Vue.js Components
<!-- resources/js/components/ProctoringInterface.vue -->
<template>
<div class="proctoring-interface">
<!-- Header -->
<div class="header">
<div class="session-info">
<h3>Quiz Session Active</h3>
<div class="session-stats">
<span class="timer">{{ formatTime(sessionDuration) }}</span>
<span class="violations">Violations: {{ violationCount }}</span>
<span class="score" :class="scoreClass">Score: {{ overallScore }}%</span>
</div>
</div>
<div class="controls">
<button @click="endSession" class="btn btn-danger">End Session</button>
</div>
</div>
<!-- Main Content -->
<div class="content">
<!-- Quiz Frame -->
<div class="quiz-container">
<iframe
ref="quizFrame"
:src="quizUrl"
class="quiz-frame"
@load="onQuizLoaded"
></iframe>
</div>
<!-- Monitoring Panel -->
<div class="monitoring-panel">
<!-- Webcam Feed -->
<div class="webcam-section">
<h4>Webcam Monitor</h4>
<video
ref="webcamVideo"
class="webcam-feed"
autoplay
muted
></video>
<div class="webcam-status" :class="webcamStatusClass">
{{ webcamStatus }}
</div>
</div>
<!-- System Status -->
<div class="system-status">
<h4>System Status</h4>
<div class="status-items">
<div class="status-item" :class="screenShareStatus">
<span class="icon">📺</span>
<span>Screen Share: {{ screenShareActive ? 'Active' : 'Inactive' }}</span>
</div>
<div class="status-item" :class="connectionStatus">
<span class="icon">🌐</span>
<span>Connection: {{ connectionStrength }}</span>
</div>
<div class="status-item" :class="browserStatus">
<span class="icon">🌏</span>
<span>Browser: {{ browserCompatible ? 'Compatible' : 'Incompatible' }}</span>
</div>
</div>
</div>
<!-- Recent Violations -->
<div class="violations-section">
<h4>Recent Violations</h4>
<div class="violations-list">
<div
v-for="violation in recentViolations"
:key="violation.id"
class="violation-item"
:class="'severity-' + violation.severity"
>
<div class="violation-type">{{ formatViolationType(violation.type) }}</div>
<div class="violation-time">{{ formatTime(violation.video_bookmark_time) }}</div>
<div class="violation-confidence">{{ Math.round(violation.confidence_score * 100) }}%</div>
</div>
</div>
</div>
</div>
</div>
<!-- Violation Alert Modal -->
<div v-if="showViolationAlert" class="violation-alert-modal">
<div class="modal-content">
<div class="alert-header">
<h3>⚠️ Violation Detected</h3>
</div>
<div class="alert-body">
<p>{{ currentViolation.message }}</p>
<p class="alert-instruction">{{ currentViolation.instruction }}</p>
</div>
<div class="alert-footer">
<button @click="acknowledgeViolation" class="btn btn-primary">
I Understand
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useProctoring } from '@/composables/useProctoring'
import { useViolationDetection } from '@/composables/useViolationDetection'
import { useRecording } from '@/composables/useRecording'
export default {
name: 'ProctoringInterface',
props: {
sessionId: {
type: Number,
required: true
},
sessionToken: {
type: String,
required: true
},
quizUrl: {
type: String,
required: true
}
},
setup(props) {
const quizFrame = ref(null)
const webcamVideo = ref(null)
const {
sessionDuration,
violationCount,
overallScore,
sessionStatus,
endSession
} = useProctoring(props.sessionId, props.sessionToken)
const {
violations,
currentViolation,
showViolationAlert,
acknowledgeViolation,
startDetection,
stopDetection
} = useViolationDetection(props.sessionId, webcamVideo)
const {
screenShareActive,
webcamStatus,
connectionStrength,
browserCompatible,
startRecording,
stopRecording
} = useRecording(props.sessionId)
const recentViolations = computed(() =>
violations.value.slice(-5).reverse()
)
const scoreClass = computed(() => {
if (overallScore.value >= 80) return 'score-good'
if (overallScore.value >= 60) return 'score-warning'
return 'score-danger'
})
const webcamStatusClass = computed(() => {
switch (webcamStatus.value) {
case 'Face detected': return 'status-good'
case 'No face detected': return 'status-danger'
case 'Multiple faces': return 'status-warning'
default: return 'status-unknown'
}
})
const screenShareStatus = computed(() =>
screenShareActive.value ? 'status-good' : 'status-danger'
)
const connectionStatus = computed(() => {
if (connectionStrength.value === 'Strong') return 'status-good'
if (connectionStrength.value === 'Weak') return 'status-warning'
return 'status-danger'
})
const browserStatus = computed(() =>
browserCompatible.value ? 'status-good' : 'status-danger'
)
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
const formatViolationType = (type) => {
return type.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
const onQuizLoaded = () => {
// Quiz iframe loaded, start monitoring
startDetection()
startRecording()
}
onMounted(() => {
// Initialize webcam
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
webcamVideo.value.srcObject = stream
})
.catch(err => {
console.error('Failed to access webcam:', err)
})
})
onUnmounted(() => {
stopDetection()
stopRecording()
})
return {
quizFrame,
webcamVideo,
sessionDuration,
violationCount,
overallScore,
recentViolations,
currentViolation,
showViolationAlert,
screenShareActive,
webcamStatus,
connectionStrength,
browserCompatible,
scoreClass,
webcamStatusClass,
screenShareStatus,
connectionStatus,
browserStatus,
formatTime,
formatViolationType,
acknowledgeViolation,
endSession,
onQuizLoaded
}
}
}
</script>
<style scoped>
.proctoring-interface {
display: flex;
flex-direction: column;
height: 100vh;
background: #f5f5f5;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: white;
border-bottom: 1px solid #ddd;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.session-info h3 {
margin: 0 0 0.5rem 0;
color: #333;
}
.session-stats {
display: flex;
gap: 1rem;
}
.session-stats span {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 500;
}
.timer {
background: #e3f2fd;
color: #1976d2;
}
.violations {
background: #fff3e0;
color: #f57c00;
}
.score {
font-weight: 600;
}
.score-good {
background: #e8f5e8;
color: #2e7d32;
}
.score-warning {
background: #fff3e0;
color: #f57c00;
}
.score-danger {
background: #ffebee;
color: #c62828;
}
.content {
display: flex;
flex: 1;
gap: 1rem;
padding: 1rem;
}
.quiz-container {
flex: 1;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.quiz-frame {
width: 100%;
height: 100%;
border: none;
}
.monitoring-panel {
width: 350px;
display: flex;
flex-direction: column;
gap: 1rem;
}
.webcam-section,
.system-status,
.violations-section {
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.webcam-section h4,
.system-status h4,
.violations-section h4 {
margin: 0 0 1rem 0;
color: #333;
font-size: 1rem;
}
.webcam-feed {
width: 100%;
height: 200px;
border-radius: 4px;
object-fit: cover;
background: #000;
}
.webcam-status {
text-align: center;
padding: 0.5rem;
margin-top: 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
font-weight: 500;
}
.status-good {
background: #e8f5e8;
color: #2e7d32;
}
.status-warning {
background: #fff3e0;
color: #f57c00;
}
.status-danger {
background: #ffebee;
color: #c62828;
}
.status-unknown {
background: #f5f5f5;
color: #666;
}
.status-items {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.status-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-radius: 4px;
font-size: 0.9rem;
}
.violations-list {
max-height: 200px;
overflow-y: auto;
}
.violation-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
}
.severity-low {
background: #f3e5f5;
color: #7b1fa2;
}
.severity-medium {
background: #fff3e0;
color: #f57c00;
}
.severity-high {
background: #ffebee;
color: #c62828;
}
.severity-critical {
background: #ffcdd2;
color: #b71c1c;
font-weight: 600;
}
.violation-alert-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.alert-header h3 {
margin: 0 0 1rem 0;
color: #d32f2f;
}
.alert-body p {
margin: 0 0