ajoute les devoirs maison (chantier vrai cockpit, étape 3) : rendu en texte libre, suivi des rendus, notation

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-13 12:44:00 +02:00
parent bd74eaf20f
commit 33531de44d
17 changed files with 582 additions and 2 deletions

View file

@ -935,3 +935,21 @@ La bonne nouvelle de cette fonction, c'est qu'elle n'a presque rien coûté en c
**Suite** **Suite**
Chantier « vrai cockpit », étape 3 : Devoirs maison. Chantier « vrai cockpit », étape 3 : Devoirs maison.
## 2026-10-13 — Chantier « vrai cockpit », étape 3 : Devoirs maison
Deux décisions d'Adam avant de coder : rendu en texte libre (pas de dépôt de fichier, qui demanderait le stockage S3 prévu au bloc B, pas commencé) ; notation sur 20 **et** suivi « rendu / pas rendu » ensemble.
**Fait**
- `Devoir` (titre, consigne, échéance) par classe ; `RenduDevoir` (texte, note sur 20 facultative, horodatage), un seul par élève et par devoir — un nouvel envoi remplace le précédent, pas d'historique. Rendre n'est plus possible après l'échéance.
- Prof : `/mes-classes/{classe}/devoirs` (créer, voir qui a rendu et qui non, lire chaque réponse, noter).
- Élève : une vraie section sur `/eleve` (devoirs des 7 derniers jours, à rendre ou déjà rendus), et une page par devoir pour lire la consigne, rendre ou modifier son rendu, voir sa note une fois donnée.
- Menu : le lien démo « Devoirs maison » du mégamenu pointe vers `/mes-classes` pour un prof connecté — même correctif que pour le tableau de bord, le quiz et la banque.
- 7 nouveaux tests Pest (82 au total) : parcours complet, modification du rendu avant l'échéance (un seul en base), refus après l'échéance, étanchéité entre classes (élève et prof), purge RGPD qui emporte devoirs et rendus.
**Vérifié sur le serveur réel** (scénario jetable, supprimé ensuite) : un prof a créé un devoir avec une échéance à 48 h ; l'élève l'a vu apparaître sur son tableau de bord, a rendu une réponse ; le prof a vu le rendu immédiatement, l'a noté 16/20 ; l'élève a vu sa note. Aucune unité en échec après coup.
**Suite**
Avec Quiz flash, la banque de quiz et les devoirs maison, trois des quatre fonctions fictives du tableau de bord démo sont maintenant réelles. Reste : Observations (puis Surveillance d'examen, la plus sensible — suivi de mineurs à cadrer avec Adam avant de coder).

View file

@ -10,7 +10,7 @@ use Illuminate\Support\Facades\Log;
/** /**
* RGPD (CLAUDE.md, étape 14) : supprime les classes archivées depuis * 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, * 90 jours, avec en cascade leurs élèves, séances, sessions de lab, machines,
* corrections et quiz (chantier « vrai cockpit », étape 2). Le compte du * corrections, quiz et devoirs maison (chantier « vrai cockpit »). Le compte du
* prof est conservé. Le journal ne garde que des identifiants et des * prof est conservé. Le journal ne garde que des identifiants et des
* nombres, jamais de nom d'élève. * nombres, jamais de nom d'élève.
* *

View file

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use App\Models\Classe;
use App\Models\Devoir;
use App\Models\RenduDevoir;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Devoirs maison (chantier « vrai cockpit », étape 3). Côté prof : créer,
* voir qui a rendu, noter. Côté élève : DevoirEleveController.
*/
class DevoirController extends Controller
{
public function index(Classe $classe): View
{
$this->verifierProprietaire($classe);
return view('devoirs.index', ['classe' => $classe, 'devoirs' => $classe->devoirs()->withCount('rendus')->get()]);
}
public function store(Request $request, Classe $classe): RedirectResponse
{
$this->verifierProprietaire($classe);
$donnees = $request->validate([
'titre' => ['required', 'string', 'max:150'],
'consigne' => ['required', 'string', 'max:5000'],
'echeance' => ['required', 'date', 'after:now'],
]);
$devoir = $classe->devoirs()->create($donnees);
return redirect()->route('mes-classes.devoirs.show', [$classe, $devoir]);
}
public function show(Classe $classe, Devoir $devoir): View
{
$this->verifierProprietaire($classe);
$this->verifierAppartenance($classe, $devoir);
$rendus = $devoir->rendus()->with('eleve')->get()->keyBy('eleve_id');
return view('devoirs.show', [
'classe' => $classe,
'devoir' => $devoir,
'eleves' => $classe->eleves,
'rendus' => $rendus,
]);
}
public function noter(Request $request, Classe $classe, Devoir $devoir, RenduDevoir $rendu): RedirectResponse
{
$this->verifierProprietaire($classe);
$this->verifierAppartenance($classe, $devoir);
abort_if($rendu->devoir_id !== $devoir->id, 404);
$donnees = $request->validate(['note' => ['required', 'integer', 'min:0', 'max:20']]);
$rendu->update($donnees);
return redirect()->route('mes-classes.devoirs.show', [$classe, $devoir]);
}
public function destroy(Classe $classe, Devoir $devoir): RedirectResponse
{
$this->verifierProprietaire($classe);
$this->verifierAppartenance($classe, $devoir);
$devoir->delete();
return redirect()->route('mes-classes.devoirs.index', $classe);
}
private function verifierProprietaire(Classe $classe): void
{
abort_if($classe->user_id !== Auth::guard('web')->id(), 403);
}
private function verifierAppartenance(Classe $classe, Devoir $devoir): void
{
abort_if($devoir->classe_id !== $classe->id, 404);
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\Devoir;
use App\Models\Eleve;
use App\Models\RenduDevoir;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\View\View;
/**
* Côté élève des devoirs maison (chantier « vrai cockpit », étape 3) :
* rendu en texte libre, une seule fois par devoir, avant l'échéance.
*/
class DevoirEleveController extends Controller
{
public function show(Devoir $devoir): View
{
$eleve = $this->verifierAppartenance($devoir);
return view('devoirs.eleve', [
'devoir' => $devoir,
'rendu' => RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first(),
]);
}
public function rendre(Request $request, Devoir $devoir): RedirectResponse
{
$eleve = $this->verifierAppartenance($devoir);
abort_if($devoir->echeanceDepassee(), 409, 'L’échéance est dépassée.');
$donnees = $request->validate(['reponse' => ['required', 'string', 'max:10000']]);
RenduDevoir::updateOrCreate(
['devoir_id' => $devoir->id, 'eleve_id' => $eleve->id],
['reponse' => $donnees['reponse'], 'rendu_a' => now()]
);
return redirect()->route('devoirs.eleve.show', $devoir)->with('rendu_envoye', true);
}
private function verifierAppartenance(Devoir $devoir): Eleve
{
$eleve = Auth::guard('eleve')->user();
abort_if($devoir->classe_id !== $eleve->classe_id, 403);
return $eleve;
}
}

View file

@ -73,6 +73,12 @@ class Classe extends Model
return $this->hasMany(Quiz::class)->latest(); return $this->hasMany(Quiz::class)->latest();
} }
/** @return HasMany<Devoir, $this> */
public function devoirs(): HasMany
{
return $this->hasMany(Devoir::class)->latest('echeance');
}
public function estArchivee(): bool public function estArchivee(): bool
{ {
return $this->archivee_a !== null; return $this->archivee_a !== null;

View file

@ -0,0 +1,37 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Devoir maison (chantier « vrai cockpit », étape 3 — 2026-10-13) : rendu en
* texte libre, une seule fois par élève, avant l'échéance.
*/
class Devoir extends Model
{
protected $fillable = ['classe_id', 'titre', 'consigne', 'echeance'];
protected $casts = [
'echeance' => 'datetime',
];
/** @return BelongsTo<Classe, $this> */
public function classe(): BelongsTo
{
return $this->belongsTo(Classe::class);
}
/** @return HasMany<RenduDevoir, $this> */
public function rendus(): HasMany
{
return $this->hasMany(RenduDevoir::class);
}
public function echeanceDepassee(): bool
{
return $this->echeance->isPast();
}
}

View file

@ -0,0 +1,30 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class RenduDevoir extends Model
{
protected $table = 'rendus_devoirs';
protected $fillable = ['devoir_id', 'eleve_id', 'reponse', 'note', 'rendu_a'];
protected $casts = [
'rendu_a' => 'datetime',
'note' => 'integer',
];
/** @return BelongsTo<Devoir, $this> */
public function devoir(): BelongsTo
{
return $this->belongsTo(Devoir::class);
}
/** @return BelongsTo<Eleve, $this> */
public function eleve(): BelongsTo
{
return $this->belongsTo(Eleve::class);
}
}

View file

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Chantier « vrai cockpit », étape 3 : Devoirs maison. Rendu en texte libre
* pour la version 1 (pas de dépôt de fichier : ça demande le stockage S3
* prévu au bloc B, pas construit).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('devoirs', function (Blueprint $table) {
$table->id();
$table->foreignId('classe_id')->constrained()->cascadeOnDelete();
$table->string('titre');
$table->text('consigne');
$table->timestamp('echeance');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('devoirs');
}
};

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Un seul rendu par élève et par devoir (contrainte unique) : un nouvel
* envoi avant l'échéance remplace le précédent, il n'y a pas d'historique.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('rendus_devoirs', function (Blueprint $table) {
$table->id();
$table->foreignId('devoir_id')->constrained()->cascadeOnDelete();
$table->foreignId('eleve_id')->constrained()->cascadeOnDelete();
$table->text('reponse');
$table->unsignedTinyInteger('note')->nullable();
$table->timestamp('rendu_a');
$table->timestamps();
$table->unique(['devoir_id', 'eleve_id']);
});
}
public function down(): void
{
Schema::dropIfExists('rendus_devoirs');
}
};

View file

@ -0,0 +1,41 @@
@extends('layouts.vitrine')
@section('titre', $devoir->titre)
@section('contenu')
<div class="mx-auto max-w-3xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold"><a href="{{ route('eleve') }}" class="underline underline-offset-4">Mon espace</a></p>
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">{{ $devoir->titre }}</h1>
<p class="mt-2 font-mono text-sm font-semibold {{ $devoir->echeanceDepassee() ? 'text-[#A63D2A]' : '' }}">{{ $devoir->echeanceDepassee() ? 'Échéance dépassée le' : 'À rendre avant le' }} {{ $devoir->echeance->timezone(config('app.fuseau_utilisateurs'))->translatedFormat('d/m/Y H:i') }}</p>
<p class="mt-4 whitespace-pre-wrap rounded-2xl border border-[#e3e5e8] bg-white p-5 font-semibold">{{ $devoir->consigne }}</p>
@if (session('rendu_envoye'))
<p class="mt-4 rounded-2xl border border-[#e3e5e8] bg-[#e6f4ea] p-4 font-semibold">Rendu envoyé.</p>
@endif
@if ($rendu && $rendu->note !== null)
<p class="mt-6 font-mono text-2xl font-extrabold">Note : {{ $rendu->note }}/20</p>
@endif
@if ($devoir->echeanceDepassee() && ! $rendu)
<p class="mt-6 rounded-2xl border border-[#A63D2A] bg-[#fce8e6] p-5 font-bold text-[#A63D2A]">Échéance dépassée : tu n'as pas rendu ce devoir.</p>
@else
<section class="mt-6 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-rendre">
<h2 id="t-rendre" class="text-xl font-extrabold">{{ $rendu ? 'Modifier mon rendu' : 'Rendre ce devoir' }}</h2>
@if ($rendu)
<p class="mt-2 font-semibold text-[#5f6368]">Rendu le {{ $rendu->rendu_a->timezone(config('app.fuseau_utilisateurs'))->translatedFormat('d/m/Y H:i') }}. Un nouvel envoi remplace celui-ci{{ $devoir->echeanceDepassee() ? '' : ', jusqu\'à l\'échéance' }}.</p>
@endif
@error('reponse')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
@if (! $devoir->echeanceDepassee())
<form action="{{ route('devoirs.eleve.rendre', $devoir) }}" method="post" class="mt-4 grid gap-3">
@csrf
<textarea name="reponse" required rows="8" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">{{ old('reponse', $rendu->reponse ?? '') }}</textarea>
<button type="submit" class="mt-2 inline-flex h-11 w-fit items-center rounded-full bg-black px-5 font-bold text-white">Envoyer</button>
</form>
@elseif ($rendu)
<p class="mt-4 whitespace-pre-wrap font-semibold">{{ $rendu->reponse }}</p>
@endif
</section>
@endif
</div>
@endsection

View file

@ -0,0 +1,46 @@
@extends('layouts.vitrine')
@section('titre', 'Devoirs · '.$classe->nom)
@section('contenu')
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.show', $classe) }}" class="underline underline-offset-4">{{ $classe->nom }}</a></p>
<h1 class="mt-1 text-4xl font-extrabold tracking-tight">Devoirs maison</h1>
<p class="mt-2 font-semibold">Les élèves rendent un texte libre (pas de fichier pour l'instant) avant l'échéance. Passée l'échéance, le rendu n'est plus possible.</p>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white p-6" aria-labelledby="t-nouveau">
<h2 id="t-nouveau" class="text-xl font-extrabold">Nouveau devoir</h2>
@error('titre')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
@error('consigne')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
@error('echeance')<p class="mt-2 font-bold text-[#A63D2A]">L'échéance doit être dans le futur.</p>@enderror
<form action="{{ route('mes-classes.devoirs.store', $classe) }}" method="post" class="mt-4 grid gap-3">
@csrf
<input name="titre" required placeholder="Ex. Plan d'adressage" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold">
<textarea name="consigne" required rows="4" placeholder="Consigne" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold"></textarea>
<label class="grid gap-1 font-bold">Échéance<input type="datetime-local" name="echeance" required class="w-fit rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold"></label>
<button type="submit" class="mt-2 inline-flex h-11 w-fit items-center rounded-full bg-[#0b57d0] px-5 font-bold text-white">Créer</button>
</form>
</section>
<section class="mt-8" aria-labelledby="t-liste">
<h2 id="t-liste" class="text-xl font-extrabold">Devoirs ({{ $devoirs->count() }})</h2>
@if ($devoirs->isEmpty())
<p class="mt-2 font-semibold">Aucun devoir pour l'instant.</p>
@else
<ul class="mt-4 grid gap-3">
@foreach ($devoirs as $devoir)
<li>
<a href="{{ route('mes-classes.devoirs.show', [$classe, $devoir]) }}" class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[#e3e5e8] bg-white p-5 hover:bg-[#f8f9fa]">
<span>
<span class="block text-lg font-extrabold">{{ $devoir->titre }}</span>
<span class="block font-mono text-sm font-semibold {{ $devoir->echeanceDepassee() ? 'text-[#A63D2A]' : '' }}">{{ $devoir->echeanceDepassee() ? 'Échéance dépassée' : 'À rendre' }} {{ $devoir->echeance->timezone(config('app.fuseau_utilisateurs'))->translatedFormat('d/m/Y H:i') }}</span>
</span>
<span class="font-mono text-sm font-semibold">{{ $devoir->rendus_count }}/{{ $classe->eleves->count() }} rendu(s)</span>
</a>
</li>
@endforeach
</ul>
@endif
</section>
</div>
@endsection

View file

@ -0,0 +1,52 @@
@extends('layouts.vitrine')
@section('titre', $devoir->titre)
@section('contenu')
<div class="mx-auto max-w-4xl px-5 py-14 lg:px-10">
<p class="font-mono text-sm font-semibold"><a href="{{ route('mes-classes.devoirs.index', $classe) }}" class="underline underline-offset-4">Devoirs · {{ $classe->nom }}</a></p>
<div class="mt-1 flex flex-wrap items-start justify-between gap-4">
<div>
<h1 class="text-4xl font-extrabold tracking-tight">{{ $devoir->titre }}</h1>
<p class="mt-2 font-mono text-sm font-semibold {{ $devoir->echeanceDepassee() ? 'text-[#A63D2A]' : '' }}">{{ $devoir->echeanceDepassee() ? 'Échéance dépassée le' : 'À rendre avant le' }} {{ $devoir->echeance->timezone(config('app.fuseau_utilisateurs'))->translatedFormat('d/m/Y H:i') }}</p>
</div>
<form action="{{ route('mes-classes.devoirs.destroy', [$classe, $devoir]) }}" method="post" data-confirmer="Supprimer ce devoir et tous ses rendus ?">
@csrf
<button type="submit" class="font-bold text-[#A63D2A] underline underline-offset-4">Supprimer</button>
</form>
</div>
<p class="mt-4 whitespace-pre-wrap font-semibold">{{ $devoir->consigne }}</p>
<section class="mt-8 rounded-2xl border border-[#e3e5e8] bg-white" aria-labelledby="t-rendus">
<h2 id="t-rendus" class="px-6 pt-6 text-xl font-extrabold">Rendus ({{ $rendus->count() }}/{{ $eleves->count() }})</h2>
<ul class="mt-4 divide-y divide-[#e3e5e8]">
@foreach ($eleves as $eleve)
@php($rendu = $rendus->get($eleve->id))
<li class="p-6">
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="text-lg font-extrabold">{{ $eleve->nomAffiche() }}</span>
@if ($rendu)
<span class="font-mono text-sm font-semibold text-[#3C8A5A]">Rendu le {{ $rendu->rendu_a->timezone(config('app.fuseau_utilisateurs'))->translatedFormat('d/m/Y H:i') }}</span>
@else
<span class="font-mono text-sm font-semibold text-[#5f6368]">Pas encore rendu</span>
@endif
</div>
@if ($rendu)
<p class="mt-3 whitespace-pre-wrap rounded-xl border border-[#e3e5e8] bg-[#f8f9fa] p-4 font-semibold">{{ $rendu->reponse }}</p>
<form action="{{ route('mes-classes.devoirs.noter', [$classe, $devoir, $rendu]) }}" method="post" class="mt-3 flex items-center gap-2">
@csrf
<label class="font-bold">Note <input type="number" name="note" min="0" max="20" value="{{ $rendu->note }}" class="w-20 rounded-xl border border-[#c4c7c5] bg-white px-2 py-1 font-semibold"> / 20</label>
<button type="submit" class="inline-flex h-9 items-center rounded-full bg-[#0b57d0] px-4 text-sm font-bold text-white">Enregistrer</button>
</form>
@endif
</li>
@endforeach
</ul>
</section>
</div>
<script nonce="{{ Vite::cspNonce() }}">
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
});
</script>
@endsection

View file

@ -43,6 +43,29 @@
</ul> </ul>
</section> </section>
@endif @endif
{{-- Chantier « vrai cockpit », étape 3 : Devoirs maison, réels. --}}
@php($devoirsClasse = $eleve->classe->devoirs()->with(['rendus' => fn ($q) => $q->where('eleve_id', $eleve->id)])->where('echeance', '>=', now()->subDays(7))->get())
@if ($devoirsClasse->isNotEmpty())
<section class="mx-auto max-w-7xl px-5 pt-10 lg:px-10" aria-labelledby="t-devoirs">
<h2 id="t-devoirs" class="text-2xl font-extrabold">Devoirs maison</h2>
<ul class="mt-4 grid gap-3">
@foreach ($devoirsClasse as $devoir)
@php($monRendu = $devoir->rendus->first())
<li>
<a href="{{ route('devoirs.eleve.show', $devoir) }}" class="flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-[#e3e5e8] p-5 {{ $monRendu ? 'bg-[#e6f4ea]' : ($devoir->echeanceDepassee() ? 'bg-[#fce8e6]' : 'bg-[#fef7e0]') }}">
<span class="text-xl font-extrabold">{{ $devoir->titre }}</span>
<span class="font-mono text-sm font-semibold">
@if ($monRendu) Rendu
@elseif ($devoir->echeanceDepassee()) Non rendu, échéance dépassée
@else À rendre avant le {{ $devoir->echeance->timezone(config('app.fuseau_utilisateurs'))->translatedFormat('d/m H:i') }}
@endif
</span>
</a>
</li>
@endforeach
</ul>
</section>
@endif
<div class="mx-auto grid max-w-7xl gap-12 px-5 py-14 lg:grid-cols-12 lg:px-10"> <div class="mx-auto grid max-w-7xl gap-12 px-5 py-14 lg:grid-cols-12 lg:px-10">
<section class="lg:col-span-7" aria-labelledby="t-afaire"> <section class="lg:col-span-7" aria-labelledby="t-afaire">
<h2 id="t-afaire" class="text-4xl font-extrabold tracking-tight">À faire</h2> <h2 id="t-afaire" class="text-4xl font-extrabold tracking-tight">À faire</h2>

File diff suppressed because one or more lines are too long

View file

@ -31,6 +31,7 @@
<li><a href="#eleves" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Élèves</a></li> <li><a href="#eleves" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Élèves</a></li>
<li><a href="#seance" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">{{ $classe->estArchivee() ? 'Classe archivée' : 'Séance' }}</a></li> <li><a href="#seance" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">{{ $classe->estArchivee() ? 'Classe archivée' : 'Séance' }}</a></li>
<li><a href="{{ route('mes-classes.quiz.index', $classe) }}" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Quiz flash</a></li> <li><a href="{{ route('mes-classes.quiz.index', $classe) }}" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Quiz flash</a></li>
<li><a href="{{ route('mes-classes.devoirs.index', $classe) }}" class="flex items-center rounded-lg border-2 border-transparent px-3 py-2 hover:border-black">Devoirs maison</a></li>
</ul> </ul>
@if ($seance) @if ($seance)
<div class="mt-6 rounded-lg border border-[#dadce0] bg-white p-3 text-sm font-semibold"> <div class="mt-6 rounded-lg border border-[#dadce0] bg-white p-3 text-sm font-semibold">

View file

@ -5,6 +5,8 @@ use App\Http\Controllers\BanqueQuizController;
use App\Http\Controllers\ClasseController; use App\Http\Controllers\ClasseController;
use App\Http\Controllers\ConsoleController; use App\Http\Controllers\ConsoleController;
use App\Http\Controllers\CorrectionController; use App\Http\Controllers\CorrectionController;
use App\Http\Controllers\DevoirController;
use App\Http\Controllers\DevoirEleveController;
use App\Http\Controllers\EleveTableauDeBordController; use App\Http\Controllers\EleveTableauDeBordController;
use App\Http\Controllers\InscriptionController; use App\Http\Controllers\InscriptionController;
use App\Http\Controllers\QuizController; use App\Http\Controllers\QuizController;
@ -106,6 +108,10 @@ Route::middleware(['auth:eleve', 'session.unique:eleve'])->group(function () {
Route::post('/sessions-quiz/{session}/repondre', [QuizEleveController::class, 'repondre']) Route::post('/sessions-quiz/{session}/repondre', [QuizEleveController::class, 'repondre'])
->middleware('throttle:30,1') ->middleware('throttle:30,1')
->name('sessions-quiz.repondre'); ->name('sessions-quiz.repondre');
// Chantier « vrai cockpit », étape 3 : Devoirs maison.
Route::get('/devoirs/{devoir}', [DevoirEleveController::class, 'show'])->name('devoirs.eleve.show');
Route::post('/devoirs/{devoir}/rendre', [DevoirEleveController::class, 'rendre'])->name('devoirs.eleve.rendre');
}); });
// --- Espace prof (protégé, session unique) --- // --- Espace prof (protégé, session unique) ---
@ -149,4 +155,11 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
Route::get('/banque-quiz', [BanqueQuizController::class, 'index'])->name('banque-quiz.index'); Route::get('/banque-quiz', [BanqueQuizController::class, 'index'])->name('banque-quiz.index');
Route::post('/mes-classes/{classe}/quiz/{quiz}/partager', [BanqueQuizController::class, 'partager'])->name('mes-classes.quiz.partager'); Route::post('/mes-classes/{classe}/quiz/{quiz}/partager', [BanqueQuizController::class, 'partager'])->name('mes-classes.quiz.partager');
Route::post('/banque-quiz/{quiz}/dupliquer', [BanqueQuizController::class, 'dupliquer'])->name('banque-quiz.dupliquer'); Route::post('/banque-quiz/{quiz}/dupliquer', [BanqueQuizController::class, 'dupliquer'])->name('banque-quiz.dupliquer');
// Chantier « vrai cockpit », étape 3 : Devoirs maison.
Route::get('/mes-classes/{classe}/devoirs', [DevoirController::class, 'index'])->name('mes-classes.devoirs.index');
Route::post('/mes-classes/{classe}/devoirs', [DevoirController::class, 'store'])->name('mes-classes.devoirs.store');
Route::get('/mes-classes/{classe}/devoirs/{devoir}', [DevoirController::class, 'show'])->name('mes-classes.devoirs.show');
Route::post('/mes-classes/{classe}/devoirs/{devoir}/supprimer', [DevoirController::class, 'destroy'])->name('mes-classes.devoirs.destroy');
Route::post('/mes-classes/{classe}/devoirs/{devoir}/rendus/{rendu}/noter', [DevoirController::class, 'noter'])->name('mes-classes.devoirs.noter');
}); });

View file

@ -0,0 +1,115 @@
<?php
use App\Models\Classe;
use App\Models\Devoir;
use App\Models\Eleve;
use App\Models\RenduDevoir;
use Illuminate\Support\Facades\Process;
test('un prof crée un devoir, un élève le rend, le prof le note', function () {
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
commeProf($this, $classe->prof)->post(route('mes-classes.devoirs.store', $classe), [
'titre' => 'Plan d’adressage',
'consigne' => 'Découpe 192.168.1.0/24 en 4 sous-réseaux.',
'echeance' => now()->addDays(3)->format('Y-m-d\TH:i'),
])->assertRedirect();
$devoir = Devoir::where('titre', 'Plan d’adressage')->first();
expect($devoir)->not->toBeNull()->and($devoir->classe_id)->toBe($classe->id);
eleve($this, $eleve)->get(route('devoirs.eleve.show', $devoir))->assertOk()->assertSee('Découpe 192.168.1.0/24');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), ['reponse' => 'Voici mon découpage : /26 x4.'])
->assertRedirect();
$rendu = RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first();
expect($rendu)->not->toBeNull()->and($rendu->reponse)->toBe('Voici mon découpage : /26 x4.')->and($rendu->note)->toBeNull();
commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.show', [$classe, $devoir]))
->assertSee($eleve->nomAffiche())
->assertSee('Voici mon découpage');
commeProf($this, $classe->prof)->post(route('mes-classes.devoirs.noter', [$classe, $devoir, $rendu]), ['note' => 15])
->assertRedirect();
expect($rendu->refresh()->note)->toBe(15);
});
test('un élève peut modifier son rendu avant l’échéance, un seul rendu par devoir en base', function () {
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), ['reponse' => 'Premier essai'])->assertRedirect();
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), ['reponse' => 'Version corrigée'])->assertRedirect();
expect(RenduDevoir::where('devoir_id', $devoir->id)->count())->toBe(1);
expect(RenduDevoir::first()->reponse)->toBe('Version corrigée');
});
test('un élève ne peut plus rendre après l’échéance', function () {
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->subHour()]);
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), ['reponse' => 'Trop tard'])->assertStatus(409);
expect(RenduDevoir::count())->toBe(0);
});
test('un élève d’une autre classe ne peut pas voir ni rendre ce devoir', function () {
$classe = Classe::factory()->create();
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
$autreEleve = Eleve::factory()->create();
eleve($this, $autreEleve)->get(route('devoirs.eleve.show', $devoir))->assertForbidden();
eleve($this, $autreEleve)->post(route('devoirs.eleve.rendre', $devoir), ['reponse' => 'Intrus'])->assertForbidden();
});
test('un prof d’une autre classe ne peut pas voir ni noter les rendus', function () {
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
$rendu = RenduDevoir::create(['devoir_id' => $devoir->id, 'eleve_id' => $eleve->id, 'reponse' => 'x', 'rendu_a' => now()]);
$autreClasse = Classe::factory()->create();
commeProf($this, $autreClasse->prof)->get(route('mes-classes.devoirs.show', [$classe, $devoir]))->assertForbidden();
commeProf($this, $autreClasse->prof)->post(route('mes-classes.devoirs.noter', [$classe, $devoir, $rendu]), ['note' => 20])->assertForbidden();
});
test('archiver puis purger une classe supprime aussi ses devoirs et rendus', function () {
$classe = Classe::factory()->create();
$eleve = Eleve::factory()->create(['classe_id' => $classe->id]);
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
RenduDevoir::create(['devoir_id' => $devoir->id, 'eleve_id' => $eleve->id, 'reponse' => 'x', 'rendu_a' => now()]);
$classe->forceFill(['archivee_a' => now()->subDays(91)])->save();
$this->artisan('classes:purger-archivees');
expect(Devoir::count())->toBe(0)->and(RenduDevoir::count())->toBe(0);
});
test('les scripts en ligne de la page de notation d’un devoir sont du JavaScript valide', function () {
if (Process::run('node --version')->failed()) {
$this->markTestSkipped('Node absent');
}
$classe = Classe::factory()->create();
$devoir = Devoir::create(['classe_id' => $classe->id, 'titre' => 'Test', 'consigne' => 'Test', 'echeance' => now()->addDay()]);
$html = commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.show', [$classe, $devoir]))->getContent();
preg_match_all('#<script(?![^>]*\bsrc=)[^>]*>(.*?)</script>#s', $html, $scripts);
$erreurs = [];
foreach ($scripts[1] as $i => $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[] = "script {$i} : ".strtok($resultat->errorOutput(), "\n");
}
}
expect($erreurs)->toBe([]);
});