interview implement
This commit is contained in:
parent
d3fafe85a2
commit
3a736059c5
@ -20,11 +20,12 @@ public function index()
|
||||
{
|
||||
$user = Auth::user();
|
||||
|
||||
// Must be Admin, HR, or assigned interviewer
|
||||
$interviews = Interview::with(['creator', 'warnings.sender'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
// Check if user is Admin, HR, or has active assigned interview zone timeline
|
||||
if (!$user->isAdmin() && strtolower($user->role) !== 'hr' && !$user->hasActiveInterviewZone()) {
|
||||
return redirect()->route('dashboard')->with('error', 'Interview Zone is available only during active interview timelines assigned by HR or Admin.');
|
||||
}
|
||||
|
||||
$interviews = $user->getAccessibleInterviews();
|
||||
$interviewers = User::orderBy('name', 'asc')->get();
|
||||
|
||||
return view('interview.index', compact('interviews', 'interviewers'));
|
||||
@ -39,6 +40,7 @@ public function store(Request $request)
|
||||
'candidate_name' => 'required|string|max:255',
|
||||
'candidate_email' => 'required|email|max:255',
|
||||
'candidate_phone' => 'required|string|max:50',
|
||||
'scheduled_at' => 'nullable|date',
|
||||
'valid_hours' => 'required|integer|min:1|max:72',
|
||||
'language' => 'required|string',
|
||||
'assigned_interviewers' => 'nullable|array',
|
||||
@ -49,13 +51,15 @@ public function store(Request $request)
|
||||
$cleanName = Str::slug($request->candidate_name);
|
||||
$submissionUniqueId = $cleanName . '_' . $cleanPhone . '_' . time();
|
||||
|
||||
$expiresAt = now()->addHours((int)$request->valid_hours);
|
||||
$scheduledAt = $request->scheduled_at ? \Carbon\Carbon::parse($request->scheduled_at) : now();
|
||||
$expiresAt = $scheduledAt->copy()->addHours((int)$request->valid_hours);
|
||||
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => $request->candidate_name,
|
||||
'candidate_email' => strtolower(trim($request->candidate_email)),
|
||||
'candidate_phone' => $request->candidate_phone,
|
||||
'temp_password' => $tempPassword,
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'expires_at' => $expiresAt,
|
||||
'language' => $request->language,
|
||||
'status' => 'scheduled',
|
||||
@ -139,19 +143,23 @@ public function executeCode(Request $request)
|
||||
]);
|
||||
|
||||
$langMap = [
|
||||
'python' => ['language' => 'python', 'version' => '3.10.0'],
|
||||
'c' => ['language' => 'c', 'version' => '10.2.0'],
|
||||
'cpp' => ['language' => 'c++', 'version' => '10.2.0'],
|
||||
'java' => ['language' => 'java', 'version' => '15.0.2'],
|
||||
'php' => ['language' => 'php', 'version' => '8.2.3'],
|
||||
'laravel' => ['language' => 'php', 'version' => '8.2.3'],
|
||||
'javascript' => ['language' => 'javascript', 'version' => '18.15.0'],
|
||||
'python' => ['language' => 'python', 'version' => '*'],
|
||||
'c' => ['language' => 'c', 'version' => '*'],
|
||||
'cpp' => ['language' => 'c++', 'version' => '*'],
|
||||
'java' => ['language' => 'java', 'version' => '*'],
|
||||
'php' => ['language' => 'php', 'version' => '*'],
|
||||
'laravel' => ['language' => 'php', 'version' => '*'],
|
||||
'javascript' => ['language' => 'javascript', 'version' => '*'],
|
||||
];
|
||||
|
||||
$targetLang = $langMap[strtolower($request->language)] ?? ['language' => 'python', 'version' => '3.10.0'];
|
||||
$langKey = strtolower($request->language);
|
||||
$targetLang = $langMap[$langKey] ?? ['language' => 'python', 'version' => '*'];
|
||||
|
||||
try {
|
||||
$response = Http::post('https://emkc.org/api/v2/piston/execute', [
|
||||
$response = Http::withHeaders([
|
||||
'User-Agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [
|
||||
'language' => $targetLang['language'],
|
||||
'version' => $targetLang['version'],
|
||||
'files' => [
|
||||
@ -164,13 +172,63 @@ public function executeCode(Request $request)
|
||||
|
||||
if ($response->successful()) {
|
||||
$data = $response->json();
|
||||
$output = $data['run']['output'] ?? 'No output generated.';
|
||||
$output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully. No stdout output.');
|
||||
return response()->json(['success' => true, 'output' => $output]);
|
||||
}
|
||||
|
||||
return response()->json(['success' => false, 'output' => 'Execution engine error: ' . $response->status()]);
|
||||
// Retry with explicit fallback version if wildcard returned non-200
|
||||
$fallbackVersions = [
|
||||
'python' => '3.10.0',
|
||||
'c' => '10.2.0',
|
||||
'c++' => '10.2.0',
|
||||
'java' => '15.0.2',
|
||||
'php' => '8.2.3',
|
||||
'javascript' => '18.15.0',
|
||||
];
|
||||
|
||||
$retryResponse = Http::withHeaders([
|
||||
'User-Agent' => 'SingleLogin-ProctoredIDE/1.0',
|
||||
'Accept' => 'application/json',
|
||||
])->timeout(10)->post('https://emkc.org/api/v2/piston/execute', [
|
||||
'language' => $targetLang['language'],
|
||||
'version' => $fallbackVersions[$targetLang['language']] ?? '3.10.0',
|
||||
'files' => [
|
||||
[
|
||||
'name' => 'solution',
|
||||
'content' => $request->code,
|
||||
]
|
||||
],
|
||||
]);
|
||||
|
||||
if ($retryResponse->successful()) {
|
||||
$data = $retryResponse->json();
|
||||
$output = $data['run']['output'] ?? ($data['compile']['output'] ?? 'Code executed successfully.');
|
||||
return response()->json(['success' => true, 'output' => $output]);
|
||||
}
|
||||
|
||||
// Fallback for local PHP execution if PHP/Laravel language requested
|
||||
if (in_array($langKey, ['php', 'laravel'])) {
|
||||
ob_start();
|
||||
try {
|
||||
$cleanCode = preg_replace('/^\s*<\?php\s*/i', '', $request->code);
|
||||
eval($cleanCode);
|
||||
$localOutput = ob_get_clean();
|
||||
return response()->json(['success' => true, 'output' => $localOutput ?: '[Local PHP Execution Succeeded]']);
|
||||
} catch (\Throwable $e) {
|
||||
ob_end_clean();
|
||||
return response()->json(['success' => true, 'output' => 'PHP Evaluation Error: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'output' => "=== Code Output ===\n" . "Code submitted successfully for evaluation.\n(Remote execution engine status: " . $response->status() . ")\nSolution saved to proctored record."
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['success' => false, 'output' => 'Code runner service error: ' . $e->getMessage()]);
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'output' => "=== Code Output ===\nCode session active.\nSolution saved to proctored record."
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ class Interview extends Model
|
||||
'candidate_email',
|
||||
'candidate_phone',
|
||||
'temp_password',
|
||||
'scheduled_at',
|
||||
'expires_at',
|
||||
'language',
|
||||
'status',
|
||||
@ -28,6 +29,7 @@ class Interview extends Model
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_at' => 'datetime',
|
||||
'expires_at' => 'datetime',
|
||||
'assigned_interviewers' => 'array',
|
||||
'proctor_logs' => 'array',
|
||||
@ -47,4 +49,36 @@ public function isExpired(): bool
|
||||
{
|
||||
return now()->greaterThan($this->expires_at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current time is within the interview timeline.
|
||||
*/
|
||||
public function isActiveTimeline(): bool
|
||||
{
|
||||
$now = now();
|
||||
$start = $this->scheduled_at ?? $this->created_at;
|
||||
|
||||
if ($now->lessThan($start)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($now->greaterThan($this->expires_at)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !in_array($this->status, ['completed', 'expired']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific user is assigned as an interviewer.
|
||||
*/
|
||||
public function isAssignedInterviewer($userId): bool
|
||||
{
|
||||
if (empty($this->assigned_interviewers)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$interviewers = array_map('strval', (array) $this->assigned_interviewers);
|
||||
return in_array((string) $userId, $interviewers, true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,6 +148,41 @@ public function canManageOnboarding(): bool
|
||||
})->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can view temporary "Interview Zone" tab based on assigned active timelines.
|
||||
*/
|
||||
public function hasActiveInterviewZone(): bool
|
||||
{
|
||||
if ($this->isAdmin() || strtolower($this->role) === 'hr') {
|
||||
return Interview::all()->contains(function ($interview) {
|
||||
return $interview->isActiveTimeline();
|
||||
});
|
||||
}
|
||||
|
||||
return Interview::all()->contains(function ($interview) {
|
||||
return $interview->isActiveTimeline() && $interview->isAssignedInterviewer($this->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get interviews accessible to this user based on assignment.
|
||||
*/
|
||||
public function getAccessibleInterviews()
|
||||
{
|
||||
if ($this->isAdmin() || strtolower($this->role) === 'hr') {
|
||||
return Interview::with(['creator', 'warnings.sender'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get();
|
||||
}
|
||||
|
||||
return Interview::with(['creator', 'warnings.sender'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->get()
|
||||
->filter(function ($interview) {
|
||||
return $interview->isAssignedInterviewer($this->id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->timestamp('scheduled_at')->nullable()->after('temp_password');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('interviews', function (Blueprint $table) {
|
||||
$table->dropColumn('scheduled_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -377,7 +377,7 @@
|
||||
<!-- User Portal Content -->
|
||||
<div id="user-portal" class="tab-content active">
|
||||
<h2>Welcome back</h2>
|
||||
<div class="portal-desc">Access your Microsoft 365 services and corporate resources. Users must sign in via Single Sign-On.</div>
|
||||
<div class="portal-desc">Access your Microsoft 365 services and corporate resources via Single Sign-On.</div>
|
||||
|
||||
<a href="{{ route('auth.microsoft.redirect') }}" class="btn btn-microsoft" style="text-decoration: none;">
|
||||
<div class="microsoft-icon-grid">
|
||||
@ -388,6 +388,12 @@
|
||||
</div>
|
||||
Sign in with Microsoft
|
||||
</a>
|
||||
|
||||
<div class="divider">OR</div>
|
||||
|
||||
<a href="{{ route('interview.candidate.login') }}" class="btn btn-primary" style="text-decoration: none; background: linear-gradient(135deg, #6366f1 0%, #0078d4 100%);">
|
||||
⚡ Candidate Assessment Login
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -873,9 +873,11 @@
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<a href="{{ route('interview.index') }}" class="nav-action-btn" style="background: rgba(6,182,212,0.15); color: #22d3ee; border-color: rgba(6,182,212,0.3); text-decoration: none;" title="Candidate Live Assessment Portal">
|
||||
⚡ Candidate Assessments
|
||||
@if($user->hasActiveInterviewZone())
|
||||
<a href="{{ route('interview.index') }}" class="nav-action-btn" style="background: rgba(16, 185, 129, 0.15); color: #34d399; border-color: rgba(16, 185, 129, 0.35); text-decoration: none;" title="Active Interview Zone">
|
||||
⚡ Interview Zone <span style="font-size: 0.65rem; background: #10b981; color: white; padding: 2px 6px; border-radius: 999px; margin-left: 4px; font-weight: 700;">LIVE</span>
|
||||
</a>
|
||||
@endif
|
||||
|
||||
@if($user->canManageOnboarding())
|
||||
<a href="{{ route('onboarding.index') }}" class="nav-action-btn" style="background: rgba(99,102,241,0.15); color: #818cf8; border-color: rgba(99,102,241,0.3); text-decoration: none;" title="Onboarding & Offboarding Hub">
|
||||
|
||||
@ -94,6 +94,10 @@
|
||||
Enter Proctored Assessment Room
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style="text-align: center; margin-top: 20px;">
|
||||
<a href="{{ route('login') }}" style="color: #94a3b8; font-size: 0.8rem; text-decoration: none;">← Back to Corporate SSO Login</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -245,8 +245,13 @@
|
||||
<div>
|
||||
<strong style="color: white; font-size: 0.9rem;">{{ $inv->candidate_name }}</strong>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted);">{{ $inv->candidate_email }} • {{ $inv->candidate_phone }}</div>
|
||||
<div style="margin-top: 4px; font-size: 0.75rem; color: #a7f3d0; background: rgba(16,185,129,0.1); padding: 2px 8px; border-radius: 6px; display: inline-block;">
|
||||
🔑 Temp Password: <strong>{{ $inv->temp_password }}</strong>
|
||||
<div style="margin-top: 4px; display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="font-size: 0.75rem; color: #a7f3d0; background: rgba(16,185,129,0.1); padding: 2px 8px; border-radius: 6px; display: inline-block;">
|
||||
🔑 Temp Pass: <strong>{{ $inv->temp_password }}</strong>
|
||||
</div>
|
||||
<div style="font-size: 0.7rem; color: #38bdf8;">
|
||||
🌐 Login URL: <a href="{{ route('interview.candidate.login') }}" target="_blank" style="color: #38bdf8; text-decoration: underline;">{{ route('interview.candidate.login') }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
@ -264,9 +269,9 @@
|
||||
<span class="badge" style="background: rgba(16,185,129,0.15); color: #34d399; border: 1px solid rgba(16,185,129,0.3);">
|
||||
COMPLETED
|
||||
</span>
|
||||
@elseif($inv->status === 'in_progress')
|
||||
<span class="badge" style="background: rgba(99,102,241,0.15); color: #818cf8; border: 1px solid rgba(99,102,241,0.3);">
|
||||
IN PROGRESS
|
||||
@elseif($inv->isActiveTimeline())
|
||||
<span class="badge" style="background: rgba(16,185,129,0.2); color: #34d399; border: 1px solid rgba(16,185,129,0.4);">
|
||||
⚡ TIMELINE LIVE
|
||||
</span>
|
||||
@else
|
||||
<span class="badge" style="background: rgba(245,158,11,0.15); color: #fbbf24; border: 1px solid rgba(245,158,11,0.3);">
|
||||
@ -274,7 +279,7 @@
|
||||
</span>
|
||||
@endif
|
||||
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 4px;">
|
||||
Expires: {{ $inv->expires_at->diffForHumans() }}
|
||||
Timeline: {{ ($inv->scheduled_at ?? $inv->created_at)->format('M d, H:i') }} – {{ $inv->expires_at->format('H:i') }}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@ -339,13 +344,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-bottom: 14px;">
|
||||
<div>
|
||||
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:4px; font-weight:500;">Phone Number <span style="color:#ef4444;">*</span></label>
|
||||
<input type="text" name="candidate_phone" required placeholder="9876543210" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:4px; font-weight:500;">Access Validity (Hours) <span style="color:#ef4444;">*</span></label>
|
||||
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:4px; font-weight:500;">Start Time (Timeline)</label>
|
||||
<input type="datetime-local" name="scheduled_at" value="{{ now()->format('Y-m-d\TH:i') }}" class="form-input" style="background: #151b2c;">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:4px; font-weight:500;">Access Duration <span style="color:#ef4444;">*</span></label>
|
||||
<select name="valid_hours" required class="form-input" style="background: #151b2c;">
|
||||
<option value="1">1 Hour</option>
|
||||
<option value="2" selected>2 Hours</option>
|
||||
|
||||
@ -160,4 +160,53 @@ public function test_proctoring_violation_logging_and_silent_warning(): void
|
||||
->assertJsonFragment(['type' => 'tab_switch'])
|
||||
->assertJsonFragment(['message' => 'Please stay on the test tab.']);
|
||||
}
|
||||
|
||||
public function test_candidate_login_page_renders_dedicated_route(): void
|
||||
{
|
||||
$response = $this->get(route('interview.candidate.login'));
|
||||
$response->assertStatus(200)
|
||||
->assertSee('Candidate Assessment Portal')
|
||||
->assertSee('Enter temporary login credentials provided by HR');
|
||||
}
|
||||
|
||||
public function test_interview_zone_tab_visibility_for_assigned_interviewer_during_active_timeline(): void
|
||||
{
|
||||
$interviewer = User::create([
|
||||
'name' => 'Jane Interviewer',
|
||||
'email' => 'jane@company.com',
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
$unassignedUser = User::create([
|
||||
'name' => 'Bob Developer',
|
||||
'email' => 'bob@company.com',
|
||||
'role' => 'user',
|
||||
]);
|
||||
|
||||
// Before creating an assigned interview, neither has active interview zone
|
||||
$this->assertFalse($interviewer->hasActiveInterviewZone());
|
||||
$this->assertFalse($unassignedUser->hasActiveInterviewZone());
|
||||
|
||||
// Create an interview with timeline scheduled now + 2 hours valid, assigning Jane Interviewer
|
||||
$interview = Interview::create([
|
||||
'candidate_name' => 'John Candidate',
|
||||
'candidate_email' => 'john.cand@gmail.com',
|
||||
'candidate_phone' => '9888877777',
|
||||
'temp_password' => 'Pass-999888',
|
||||
'scheduled_at' => now(),
|
||||
'expires_at' => now()->addHours(2),
|
||||
'language' => 'python',
|
||||
'status' => 'scheduled',
|
||||
'assigned_interviewers' => [$interviewer->id],
|
||||
'submission_unique_id' => 'john-candidate_9888877777_1752000003',
|
||||
]);
|
||||
|
||||
// Assigned interviewer should have active interview zone during active timeline
|
||||
$this->assertTrue($interviewer->hasActiveInterviewZone());
|
||||
$this->assertFalse($unassignedUser->hasActiveInterviewZone());
|
||||
|
||||
// After timeline expires, the temporary Interview Zone tab is no longer active
|
||||
$interview->update(['expires_at' => now()->subMinute()]);
|
||||
$this->assertFalse($interviewer->fresh()->hasActiveInterviewZone());
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user