ajoute la surveillance d'examen (chantier vrai cockpit, dernière étape)
Une séance peut être lancée en mode examen avec une durée dédiée. Réutilise le mécanisme d'expiration déjà en place (labs:expirer-sessions, expire_a) plutôt que d'en construire un nouveau. Le navigateur de l'élève signale les changements d'onglet et les sorties de plein écran (evenements_examen : un type et un horodatage, rien d'autre - pas de capture d'écran ni de webcam) ; le prof voit le compte par élève et le temps restant dans le cockpit de sa classe et la salle d'attente. Choix d'Adam après discussion des options (suivi chronométré simple / + détection d'onglet / + tentative de plein écran) : la troisième, honnêtement présentée - un navigateur ne permet pas de vraiment empêcher de sortir, seulement de le signaler. Avec cette étape, les quatre fonctions fictives du tableau de bord démo (quiz, devoirs, observations, examen) sont maintenant réelles. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
954bb6ca6c
commit
f2bca621cd
15 changed files with 392 additions and 11 deletions
|
|
@ -11,9 +11,10 @@ use Illuminate\Support\Facades\Log;
|
|||
* RGPD (CLAUDE.md, étape 14) : supprime les classes archivées depuis
|
||||
* 90 jours, avec en cascade leurs élèves, séances, sessions de lab, machines,
|
||||
* corrections, quiz et devoirs maison (chantier « vrai cockpit »), ainsi que
|
||||
* les annonces, observations et le chat élève ↔ prof (séparation vitrine/
|
||||
* application, 2026-10-13). Le compte du prof est conservé. Le journal ne
|
||||
* garde que des identifiants et des nombres, jamais de nom d'élève.
|
||||
* les annonces, observations, le chat élève ↔ prof (séparation vitrine/
|
||||
* application, 2026-10-13) et les événements d'examen (2026-10-14). Le
|
||||
* compte du prof est conservé. Le journal ne garde que des identifiants et
|
||||
* des nombres, jamais de nom d'élève.
|
||||
*
|
||||
* Les sauvegardes chiffrées contenant encore ces données expirent d'elles-
|
||||
* mêmes au plus 4 semaines plus tard (rotation, docs/runbooks/sauvegardes.md).
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ class ClasseController extends Controller
|
|||
// d'attente, la plus récemment démarrée d'abord.
|
||||
$seance = $classe->seances()
|
||||
->whereIn('statut', [Seance::SALLE_ATTENTE, Seance::EN_COURS])
|
||||
->with(['lab', 'labSessions' => fn ($q) => $q->with(['eleve', 'derniereCorrection', 'machines'])])
|
||||
->with(['lab', 'labSessions' => fn ($q) => $q->with(['eleve', 'derniereCorrection', 'machines', 'evenementsExamen'])])
|
||||
->latest('demarree_a')
|
||||
->get()
|
||||
->sortByDesc(fn ($s) => $s->statut === Seance::EN_COURS)
|
||||
|
|
|
|||
35
portail/app/Http/Controllers/EvenementExamenController.php
Normal file
35
portail/app/Http/Controllers/EvenementExamenController.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\EvenementExamen;
|
||||
use App\Models\LabSession;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
/**
|
||||
* Surveillance d'examen (chantier « vrai cockpit », 2026-10-14) : le
|
||||
* navigateur de l'élève signale un changement d'onglet ou une sortie du
|
||||
* plein écran. Jamais de capture d'écran ni de webcam — juste un type et
|
||||
* un horodatage (CLAUDE.md : minimisation).
|
||||
*/
|
||||
class EvenementExamenController extends Controller
|
||||
{
|
||||
public function store(Request $request, LabSession $labSession): JsonResponse
|
||||
{
|
||||
$eleve = Auth::guard('eleve')->user();
|
||||
abort_if($labSession->eleve_id !== $eleve->id, 403);
|
||||
|
||||
$seance = $labSession->seance;
|
||||
abort_unless($seance->mode_examen, 403);
|
||||
|
||||
$donnees = $request->validate([
|
||||
'type' => ['required', 'in:'.implode(',', [EvenementExamen::CHANGEMENT_ONGLET, EvenementExamen::SORTIE_PLEIN_ECRAN])],
|
||||
]);
|
||||
|
||||
$labSession->evenementsExamen()->create($donnees);
|
||||
|
||||
return response()->json([], 201);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,8 +31,12 @@ class SeanceController extends Controller
|
|||
$donnees = $request->validate([
|
||||
'lab_id' => ['required', 'exists:labs,id'],
|
||||
'prevue_a' => ['nullable', 'date_format:Y-m-d\TH:i'],
|
||||
'mode_examen' => ['nullable', 'boolean'],
|
||||
'duree_examen_min' => ['required_if:mode_examen,1', 'nullable', 'integer', 'min:5', 'max:300'],
|
||||
]);
|
||||
|
||||
$modeExamen = $request->boolean('mode_examen');
|
||||
|
||||
if (! empty($donnees['prevue_a'])) {
|
||||
$prevueA = Carbon::createFromFormat('Y-m-d\TH:i', $donnees['prevue_a'], config('app.fuseau_utilisateurs'))->utc();
|
||||
|
||||
|
|
@ -44,6 +48,8 @@ class SeanceController extends Controller
|
|||
'lab_id' => $donnees['lab_id'],
|
||||
'statut' => Seance::PLANIFIEE,
|
||||
'prevue_a' => $prevueA,
|
||||
'mode_examen' => $modeExamen,
|
||||
'duree_examen_min' => $modeExamen ? $donnees['duree_examen_min'] : null,
|
||||
]);
|
||||
|
||||
return redirect()->route('mes-classes.show', $classe)->with('seance_planifiee', true);
|
||||
|
|
@ -53,6 +59,8 @@ class SeanceController extends Controller
|
|||
'lab_id' => $donnees['lab_id'],
|
||||
'statut' => Seance::SALLE_ATTENTE,
|
||||
'demarree_a' => now(),
|
||||
'mode_examen' => $modeExamen,
|
||||
'duree_examen_min' => $modeExamen ? $donnees['duree_examen_min'] : null,
|
||||
]);
|
||||
|
||||
return redirect()->route('seances.attente', $seance);
|
||||
|
|
@ -74,7 +82,7 @@ class SeanceController extends Controller
|
|||
abort_if($seance->classe->user_id !== Auth::guard('web')->id(), 403);
|
||||
|
||||
return view('seances.attente', [
|
||||
'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve', 'labSessions.machines', 'labSessions.derniereCorrection']),
|
||||
'seance' => $seance->load(['classe', 'lab', 'labSessions.eleve', 'labSessions.machines', 'labSessions.derniereCorrection', 'labSessions.evenementsExamen']),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -107,7 +115,7 @@ class SeanceController extends Controller
|
|||
->update([
|
||||
'etat' => LabSession::ADMIS,
|
||||
'raison_echec' => null,
|
||||
'expire_a' => now()->addMinutes($labSession->lab->duree_min ?? 45),
|
||||
'expire_a' => now()->addMinutes($seance->mode_examen ? $seance->duree_examen_min : ($labSession->lab->duree_min ?? 45)),
|
||||
]);
|
||||
|
||||
if ($pris === 0) {
|
||||
|
|
|
|||
27
portail/app/Models/EvenementExamen.php
Normal file
27
portail/app/Models/EvenementExamen.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* Sortie détectée pendant un examen (changement d'onglet, plein écran
|
||||
* quitté) : jamais de capture d'écran ni de webcam.
|
||||
*/
|
||||
class EvenementExamen extends Model
|
||||
{
|
||||
public const CHANGEMENT_ONGLET = 'changement_onglet';
|
||||
|
||||
public const SORTIE_PLEIN_ECRAN = 'sortie_plein_ecran';
|
||||
|
||||
protected $table = 'evenements_examen';
|
||||
|
||||
protected $fillable = ['lab_session_id', 'type'];
|
||||
|
||||
/** @return BelongsTo<LabSession, $this> */
|
||||
public function labSession(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(LabSession::class);
|
||||
}
|
||||
}
|
||||
|
|
@ -91,4 +91,20 @@ class LabSession extends Model
|
|||
{
|
||||
return $this->hasOne(Correction::class)->latestOfMany();
|
||||
}
|
||||
|
||||
/** @return HasMany<EvenementExamen, $this> */
|
||||
public function evenementsExamen(): HasMany
|
||||
{
|
||||
return $this->hasMany(EvenementExamen::class)->latest();
|
||||
}
|
||||
|
||||
/** Nul si la session n'expire jamais ou si l'expiration est déjà passée. */
|
||||
public function minutesRestantes(): ?int
|
||||
{
|
||||
if ($this->expire_a === null || $this->expire_a->isPast()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (int) ceil(now()->diffInSeconds($this->expire_a) / 60);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class Seance extends Model
|
|||
|
||||
public const TERMINEE = 'terminee';
|
||||
|
||||
protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a', 'visio_ouverte_a', 'visio_salle', 'visio_chat'];
|
||||
protected $fillable = ['classe_id', 'lab_id', 'statut', 'prevue_a', 'demarree_a', 'terminee_a', 'visio_ouverte_a', 'visio_salle', 'visio_chat', 'mode_examen', 'duree_examen_min'];
|
||||
|
||||
protected $casts = [
|
||||
'prevue_a' => 'datetime',
|
||||
|
|
@ -28,6 +28,7 @@ class Seance extends Model
|
|||
'terminee_a' => 'datetime',
|
||||
'visio_ouverte_a' => 'datetime',
|
||||
'visio_chat' => 'boolean',
|
||||
'mode_examen' => 'boolean',
|
||||
];
|
||||
|
||||
public function visioOuverte(): bool
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Surveillance d'examen (chantier « vrai cockpit », dernière étape,
|
||||
* 2026-10-14) : une séance peut être lancée en mode examen, avec une
|
||||
* durée dédiée (pas celle du lab, choisie par le prof pour l'occasion).
|
||||
* Choix d'Adam, après discussion des options : suivi chronométré +
|
||||
* détection de changement d'onglet + tentative de plein écran — jamais
|
||||
* de capture d'écran ni de webcam.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('seances', function (Blueprint $table) {
|
||||
$table->boolean('mode_examen')->default(false)->after('statut');
|
||||
$table->unsignedSmallInteger('duree_examen_min')->nullable()->after('mode_examen');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('seances', function (Blueprint $table) {
|
||||
$table->dropColumn(['mode_examen', 'duree_examen_min']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Journal des sorties détectées pendant un examen (onglet changé, plein
|
||||
* écran quitté) : seulement un type et un horodatage, jamais de capture
|
||||
* d'écran ni de contenu. Minimisation (CLAUDE.md) : rien d'autre n'est
|
||||
* collecté sur l'élève.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('evenements_examen', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('lab_session_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('type')->comment('changement_onglet, sortie_plein_ecran');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('evenements_examen');
|
||||
}
|
||||
};
|
||||
|
|
@ -153,7 +153,11 @@
|
|||
{{-- Vraies données de la séance en cours : un élève, son état, sa dernière vérification. --}}
|
||||
<section class="mt-6 rounded-lg border border-[#dadce0] bg-white" aria-labelledby="t-seance-encours">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 border-b border-[#e3e5e8] p-4">
|
||||
<h2 id="t-seance-encours" class="text-lg font-extrabold">{{ $seance->lab->titre }} — {{ $seance->statut === \App\Models\Seance::EN_COURS ? 'en cours' : 'salle d\'attente' }}</h2>
|
||||
<h2 id="t-seance-encours" class="text-lg font-extrabold">{{ $seance->lab->titre }} — {{ $seance->statut === \App\Models\Seance::EN_COURS ? 'en cours' : 'salle d\'attente' }}
|
||||
@if ($seance->mode_examen)
|
||||
<span class="rounded-lg border border-[#A63D2A] bg-[#fce8e6] px-2 py-0.5 font-mono text-xs font-bold align-middle">Mode examen · {{ $seance->duree_examen_min }} min</span>
|
||||
@endif
|
||||
</h2>
|
||||
<span class="flex flex-wrap gap-2">
|
||||
<a href="{{ route('seances.attente', $seance) }}" class="inline-flex h-10 items-center rounded-lg border border-[#dadce0] px-4 text-sm font-extrabold hover:bg-[#f8f9fa]">Salle d'attente</a>
|
||||
<a href="{{ route('seances.visio.prof', $seance) }}" class="inline-flex h-10 items-center rounded-lg bg-[#0b57d0] px-4 text-sm font-extrabold text-white">{{ $seance->visioOuverte() ? 'Rejoindre la visio' : 'Lancer la visio' }}</a>
|
||||
|
|
@ -164,7 +168,9 @@
|
|||
@else
|
||||
<div class="overflow-x-auto"><table class="w-full min-w-[640px] border-collapse text-left text-sm">
|
||||
<thead class="border-b border-[#e3e5e8] bg-[#f8f9fa] font-mono text-[11px] uppercase tracking-wider">
|
||||
<tr><th class="px-4 py-3 font-semibold">Élève</th><th class="px-4 py-3 font-semibold">État</th><th class="px-4 py-3 font-semibold">Dernière vérification</th><th class="px-4 py-3 font-semibold"></th></tr>
|
||||
<tr><th class="px-4 py-3 font-semibold">Élève</th><th class="px-4 py-3 font-semibold">État</th><th class="px-4 py-3 font-semibold">Dernière vérification</th>
|
||||
@if ($seance->mode_examen)<th class="px-4 py-3 font-semibold">Sorties</th>@endif
|
||||
<th class="px-4 py-3 font-semibold"></th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-[#e3e5e8]">
|
||||
@foreach ($seance->labSessions as $labSession)
|
||||
|
|
@ -195,6 +201,15 @@
|
|||
—
|
||||
@endif
|
||||
</td>
|
||||
@if ($seance->mode_examen)
|
||||
<td class="px-4 py-3 font-mono font-semibold">
|
||||
@if ($labSession->evenementsExamen->isNotEmpty())
|
||||
<span class="text-[#A63D2A]">{{ $labSession->evenementsExamen->count() }}</span>
|
||||
@else
|
||||
0
|
||||
@endif
|
||||
</td>
|
||||
@endif
|
||||
<td class="px-4 py-3 text-right">
|
||||
@if ($labSession->etat === $etats::ACTIF)
|
||||
<a href="{{ route('seances.console.prof.page', [$seance, $labSession]) }}" class="font-bold underline underline-offset-4">Voir sa console</a>
|
||||
|
|
@ -414,7 +429,7 @@
|
|||
@if (session('seance_planifiee'))
|
||||
<p class="mt-4 rounded-lg border border-[#e3e5e8] bg-[#e6f4ea] p-4 font-semibold">Séance planifiée.</p>
|
||||
@endif
|
||||
<form action="{{ route('mes-classes.seances.store', $classe) }}" method="post" class="mt-4 flex flex-wrap items-end gap-3">
|
||||
<form action="{{ route('mes-classes.seances.store', $classe) }}" method="post" class="mt-4 flex flex-wrap items-end gap-3" id="form-seance">
|
||||
@csrf
|
||||
<label class="grid gap-1 font-bold">Lab<select name="lab_id" required class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
|
||||
@foreach (\App\Models\Lab::orderBy('titre')->get() as $lab)
|
||||
|
|
@ -422,11 +437,17 @@
|
|||
@endforeach
|
||||
</select></label>
|
||||
<label class="grid gap-1 font-bold">Date et heure (facultatif)<input type="datetime-local" name="prevue_a" value="{{ old('prevue_a') }}" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold"></label>
|
||||
<label class="flex items-center gap-2 font-bold"><input type="checkbox" name="mode_examen" value="1" id="case-mode-examen"> Mode examen</label>
|
||||
<label class="grid gap-1 font-bold" id="champ-duree-examen" hidden>Durée (minutes)<input type="number" name="duree_examen_min" min="5" max="300" value="{{ old('duree_examen_min', 60) }}" class="w-28 rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold"></label>
|
||||
<button type="submit" class="inline-flex h-11 items-center justify-center rounded-full bg-black px-5 font-bold text-white">Ouvrir ou planifier</button>
|
||||
</form>
|
||||
@error('prevue_a')
|
||||
<p class="mt-2 font-semibold text-[#A63D2A]">{{ $message }}</p>
|
||||
@enderror
|
||||
@error('duree_examen_min')
|
||||
<p class="mt-2 font-semibold text-[#A63D2A]">{{ $message }}</p>
|
||||
@enderror
|
||||
<p class="mt-2 text-sm font-semibold text-[#5f6368]">Mode examen : chronométré, détecte les changements d'onglet et propose le plein écran. Ça ne bloque pas vraiment l'élève — un navigateur ne le permet pas — mais tu vois les sorties.</p>
|
||||
|
||||
@php($planifiees = $classe->seances()->where('statut', \App\Models\Seance::PLANIFIEE)->with('lab')->orderBy('prevue_a')->get())
|
||||
@if ($planifiees->isNotEmpty())
|
||||
|
|
@ -489,6 +510,10 @@
|
|||
if (!occupe) location.reload();
|
||||
}, 15000);
|
||||
@endif
|
||||
var caseModeExamen = document.getElementById('case-mode-examen');
|
||||
if (caseModeExamen) caseModeExamen.addEventListener('change', function () {
|
||||
document.getElementById('champ-duree-examen').hidden = !caseModeExamen.checked;
|
||||
});
|
||||
document.querySelectorAll('[data-imprimer]').forEach(function (b) { b.addEventListener('click', function () { window.print(); }); });
|
||||
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
|
||||
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@
|
|||
<p class="mt-4 font-semibold">Ton prof le voit et peut relancer. Cette page se mettra à jour toute seule.</p>
|
||||
</div>
|
||||
@elseif ($labSession->etat === $etats::ACTIF && $labSession->machines->isNotEmpty())
|
||||
@if ($seance->mode_examen)
|
||||
<div id="bandeau-examen" class="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[#A63D2A] bg-[#fce8e6] p-4">
|
||||
<p class="font-extrabold">Mode examen · temps restant : <span id="temps-restant" class="font-mono">…</span></p>
|
||||
<button type="button" id="bouton-plein-ecran" class="inline-flex h-10 items-center rounded-full bg-black px-4 font-bold text-white">Passer en plein écran</button>
|
||||
</div>
|
||||
<p class="mb-4 text-sm font-semibold text-[#5f6368]">Reste sur cette page : les changements d'onglet et les sorties du plein écran sont signalés à ton prof.</p>
|
||||
@endif
|
||||
<h1 class="mt-2 text-3xl font-extrabold tracking-tight">Tes machines</h1>
|
||||
<div class="mt-4 flex gap-2" role="tablist">
|
||||
@foreach ($labSession->machines as $i => $machine)
|
||||
|
|
@ -96,6 +103,43 @@
|
|||
@endforeach
|
||||
});
|
||||
</script>
|
||||
@if ($seance->mode_examen)
|
||||
<script nonce="{{ Vite::cspNonce() }}">
|
||||
(function () {
|
||||
var finA = new Date(@json($labSession->expire_a?->toIso8601String()));
|
||||
var URL_EVENEMENT = @json(route('lab-sessions.evenements-examen', $labSession));
|
||||
var CSRF = @json(csrf_token());
|
||||
|
||||
function signaler(type) {
|
||||
fetch(URL_EVENEMENT, {
|
||||
method: 'POST', keepalive: true,
|
||||
headers: { 'X-CSRF-TOKEN': CSRF, 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ type: type }),
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function mettreAJourTemps() {
|
||||
var restantMs = finA - new Date();
|
||||
var affichage = document.getElementById('temps-restant');
|
||||
if (restantMs <= 0) { affichage.textContent = '0 min'; location.reload(); return; }
|
||||
affichage.textContent = Math.ceil(restantMs / 60000) + ' min';
|
||||
}
|
||||
mettreAJourTemps();
|
||||
setInterval(mettreAJourTemps, 10000);
|
||||
|
||||
document.getElementById('bouton-plein-ecran').addEventListener('click', function () {
|
||||
if (document.documentElement.requestFullscreen) document.documentElement.requestFullscreen().catch(function () {});
|
||||
});
|
||||
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) signaler('changement_onglet');
|
||||
});
|
||||
document.addEventListener('fullscreenchange', function () {
|
||||
if (!document.fullscreenElement) signaler('sortie_plein_ecran');
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@endif
|
||||
@else
|
||||
<div class="py-20 text-center">
|
||||
<h1 class="text-4xl font-extrabold tracking-tight">Cette séance est terminée pour toi</h1>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,11 @@
|
|||
<meta http-equiv="refresh" content="10">
|
||||
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
|
||||
<p class="font-mono text-sm font-semibold">{{ $seance->classe->nom }} · {{ $seance->lab->titre }}</p>
|
||||
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Salle d'attente</h1>
|
||||
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Salle d'attente
|
||||
@if ($seance->mode_examen)
|
||||
<span class="rounded-lg border border-[#A63D2A] bg-[#fce8e6] px-2 py-0.5 align-middle font-mono text-sm font-bold">Mode examen · {{ $seance->duree_examen_min }} min</span>
|
||||
@endif
|
||||
</h1>
|
||||
<p class="mt-2 font-semibold">La page se rafraîchit toutes les 10 secondes.</p>
|
||||
<p class="mt-4"><a href="{{ route('seances.visio.prof', $seance) }}" class="inline-flex h-11 items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">{{ $seance->visioOuverte() ? 'Retourner à la visio' : 'Visio de la classe' }}</a></p>
|
||||
|
||||
|
|
@ -38,6 +42,12 @@
|
|||
</span>
|
||||
@endif
|
||||
<a href="{{ route('seances.console.prof.page', [$seance, $labSession]) }}" class="font-bold underline underline-offset-4">Voir sa console</a>
|
||||
@if ($seance->mode_examen)
|
||||
<span class="font-mono text-sm font-bold {{ $labSession->evenementsExamen->isNotEmpty() ? 'text-[#A63D2A]' : '' }}">{{ $labSession->evenementsExamen->count() }} sortie(s)</span>
|
||||
@if ($labSession->minutesRestantes() !== null)
|
||||
<span class="font-mono text-sm font-bold">{{ $labSession->minutesRestantes() }} min restantes</span>
|
||||
@endif
|
||||
@endif
|
||||
@break
|
||||
@case($etats::ECHEC)
|
||||
<span class="rounded-lg border border-[#A63D2A] bg-[#fce8e6] px-3 py-1 font-mono text-sm font-bold" title="{{ $labSession->raison_echec }}">Échec du lancement (3 essais)</span>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ use App\Http\Controllers\DevoirController;
|
|||
use App\Http\Controllers\DevoirEleveController;
|
||||
use App\Http\Controllers\EleveTableauDeBordController;
|
||||
use App\Http\Controllers\EmploiDuTempsController;
|
||||
use App\Http\Controllers\EvenementExamenController;
|
||||
use App\Http\Controllers\InscriptionController;
|
||||
use App\Http\Controllers\MaClasseController;
|
||||
use App\Http\Controllers\MesCoursController;
|
||||
|
|
@ -140,6 +141,11 @@ Route::middleware(['auth:eleve', 'session.unique:eleve', 'maj.activite'])->group
|
|||
->name('lab-sessions.corriger');
|
||||
Route::post('/lab-sessions/{labSession}/machines/{labSessionMachine}/console', [ConsoleController::class, 'pourEleve'])->name('console.eleve');
|
||||
|
||||
// Chantier « vrai cockpit », dernière étape : Surveillance d'examen.
|
||||
Route::post('/lab-sessions/{labSession}/evenements-examen', [EvenementExamenController::class, 'store'])
|
||||
->middleware('throttle:20,1')
|
||||
->name('lab-sessions.evenements-examen');
|
||||
|
||||
// Chantier « vrai cockpit », étape 2 : Quiz flash.
|
||||
Route::get('/quiz-flash/etat', [QuizEleveController::class, 'etat'])->name('quiz.eleve.etat');
|
||||
Route::post('/sessions-quiz/{session}/repondre', [QuizEleveController::class, 'repondre'])
|
||||
|
|
|
|||
126
portail/tests/Feature/SurveillanceExamenTest.php
Normal file
126
portail/tests/Feature/SurveillanceExamenTest.php
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\Eleve;
|
||||
use App\Models\EvenementExamen;
|
||||
use App\Models\Lab;
|
||||
use App\Models\LabSession;
|
||||
use App\Models\Seance;
|
||||
use App\Services\Labs\StockChaud;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
function labSessionEnAttenteExamen(int $dureeExamenMin = 60): LabSession
|
||||
{
|
||||
$classe = Classe::factory()->create();
|
||||
$lab = Lab::factory()->create(['identifiant' => '08-deux-lan-routeur']);
|
||||
$seance = Seance::create([
|
||||
'classe_id' => $classe->id,
|
||||
'lab_id' => $lab->id,
|
||||
'statut' => Seance::SALLE_ATTENTE,
|
||||
'demarree_a' => now(),
|
||||
'mode_examen' => true,
|
||||
'duree_examen_min' => $dureeExamenMin,
|
||||
]);
|
||||
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
|
||||
|
||||
return LabSession::create(['seance_id' => $seance->id, 'eleve_id' => $eleve->id, 'lab_id' => $lab->id, 'etat' => LabSession::EN_ATTENTE]);
|
||||
}
|
||||
|
||||
test("admettre un élève en mode examen utilise la durée de l'examen, pas celle du lab", function () {
|
||||
Queue::fake();
|
||||
$this->mock(StockChaud::class, function ($stock) {
|
||||
$stock->shouldReceive('prendre')->once()->andReturn(null);
|
||||
});
|
||||
$session = labSessionEnAttenteExamen(dureeExamenMin: 90);
|
||||
|
||||
admettre($this, $session)->assertRedirect();
|
||||
|
||||
$session->refresh();
|
||||
expect(now()->diffInMinutes($session->expire_a))->toBeGreaterThan(85)
|
||||
->and(now()->diffInMinutes($session->expire_a))->toBeLessThanOrEqual(90);
|
||||
});
|
||||
|
||||
test("un élève signale un événement d'examen sur sa propre session", function () {
|
||||
$session = labSessionEnAttenteExamen();
|
||||
$session->update(['etat' => LabSession::ACTIF]);
|
||||
|
||||
eleve($this, $session->eleve)
|
||||
->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'changement_onglet'])
|
||||
->assertCreated();
|
||||
|
||||
expect($session->evenementsExamen()->count())->toBe(1)
|
||||
->and($session->evenementsExamen()->first()->type)->toBe(EvenementExamen::CHANGEMENT_ONGLET);
|
||||
});
|
||||
|
||||
test("un élève ne peut pas signaler un événement sur la session d'un autre élève", function () {
|
||||
$session = labSessionEnAttenteExamen();
|
||||
$intrus = Eleve::factory()->create();
|
||||
|
||||
eleve($this, $intrus)
|
||||
->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'changement_onglet'])
|
||||
->assertForbidden();
|
||||
|
||||
expect($session->evenementsExamen()->count())->toBe(0);
|
||||
});
|
||||
|
||||
test("pas d'événement d'examen possible sur une séance normale (mode examen désactivé)", function () {
|
||||
$session = sessionEnAttente();
|
||||
$session->update(['etat' => LabSession::ACTIF]);
|
||||
|
||||
eleve($this, $session->eleve)
|
||||
->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'changement_onglet'])
|
||||
->assertForbidden();
|
||||
});
|
||||
|
||||
test('type invalide refusé', function () {
|
||||
$session = labSessionEnAttenteExamen();
|
||||
$session->update(['etat' => LabSession::ACTIF]);
|
||||
|
||||
eleve($this, $session->eleve)
|
||||
->postJson(route('lab-sessions.evenements-examen', $session), ['type' => 'webcam_allumee'])
|
||||
->assertUnprocessable();
|
||||
});
|
||||
|
||||
test('purger une classe archivée supprime aussi les événements d’examen', function () {
|
||||
$session = labSessionEnAttenteExamen();
|
||||
$session->update(['etat' => LabSession::TERMINE]);
|
||||
$session->evenementsExamen()->create(['type' => EvenementExamen::SORTIE_PLEIN_ECRAN]);
|
||||
|
||||
$classe = $session->seance->classe;
|
||||
$classe->forceFill(['archivee_a' => now()->subDays(91)])->save();
|
||||
$this->artisan('classes:purger-archivees');
|
||||
|
||||
expect(EvenementExamen::count())->toBe(0);
|
||||
});
|
||||
|
||||
test('les scripts en ligne du mode examen (barre prof et page élève) sont du JavaScript valide', function () {
|
||||
if (Process::run('node --version')->failed()) {
|
||||
$this->markTestSkipped('Node absent');
|
||||
}
|
||||
|
||||
$session = labSessionEnAttenteExamen();
|
||||
$session->update(['etat' => LabSession::ACTIF]);
|
||||
$session->machines()->create(['nom' => 'pc-a', 'instance_id' => 'uuid-a']);
|
||||
|
||||
$pages = [
|
||||
fn () => prof($this, $session->seance->classe->prof)->get(route('mes-classes.show', $session->seance->classe)),
|
||||
fn () => eleve($this, $session->eleve)->get(route('seances.eleve.attente', $session->seance)),
|
||||
];
|
||||
|
||||
$erreurs = [];
|
||||
foreach ($pages as $i => $visite) {
|
||||
preg_match_all('#<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>#s', $visite()->getContent(), $scripts);
|
||||
foreach ($scripts[1] as $j => $code) {
|
||||
$fichier = tempnam(sys_get_temp_dir(), 'js').'.js';
|
||||
file_put_contents($fichier, $code);
|
||||
$resultat = Process::run(['node', '--check', $fichier]);
|
||||
unlink($fichier);
|
||||
if ($resultat->failed()) {
|
||||
$erreurs[] = "page {$i} script {$j} : ".strtok($resultat->errorOutput(), "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect($erreurs)->toBe([]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue