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:
parent
bd74eaf20f
commit
33531de44d
17 changed files with 582 additions and 2 deletions
|
|
@ -10,7 +10,7 @@ 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 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
|
||||
* nombres, jamais de nom d'élève.
|
||||
*
|
||||
|
|
|
|||
85
portail/app/Http/Controllers/DevoirController.php
Normal file
85
portail/app/Http/Controllers/DevoirController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
51
portail/app/Http/Controllers/DevoirEleveController.php
Normal file
51
portail/app/Http/Controllers/DevoirEleveController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -73,6 +73,12 @@ class Classe extends Model
|
|||
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
|
||||
{
|
||||
return $this->archivee_a !== null;
|
||||
|
|
|
|||
37
portail/app/Models/Devoir.php
Normal file
37
portail/app/Models/Devoir.php
Normal 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();
|
||||
}
|
||||
}
|
||||
30
portail/app/Models/RenduDevoir.php
Normal file
30
portail/app/Models/RenduDevoir.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
41
portail/resources/views/devoirs/eleve.blade.php
Normal file
41
portail/resources/views/devoirs/eleve.blade.php
Normal 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
|
||||
46
portail/resources/views/devoirs/index.blade.php
Normal file
46
portail/resources/views/devoirs/index.blade.php
Normal 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
|
||||
52
portail/resources/views/devoirs/show.blade.php
Normal file
52
portail/resources/views/devoirs/show.blade.php
Normal 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
|
||||
|
|
@ -43,6 +43,29 @@
|
|||
</ul>
|
||||
</section>
|
||||
@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">
|
||||
<section class="lg:col-span-7" aria-labelledby="t-afaire">
|
||||
<h2 id="t-afaire" class="text-4xl font-extrabold tracking-tight">À faire</h2>
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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="#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.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>
|
||||
@if ($seance)
|
||||
<div class="mt-6 rounded-lg border border-[#dadce0] bg-white p-3 text-sm font-semibold">
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ use App\Http\Controllers\BanqueQuizController;
|
|||
use App\Http\Controllers\ClasseController;
|
||||
use App\Http\Controllers\ConsoleController;
|
||||
use App\Http\Controllers\CorrectionController;
|
||||
use App\Http\Controllers\DevoirController;
|
||||
use App\Http\Controllers\DevoirEleveController;
|
||||
use App\Http\Controllers\EleveTableauDeBordController;
|
||||
use App\Http\Controllers\InscriptionController;
|
||||
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'])
|
||||
->middleware('throttle:30,1')
|
||||
->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) ---
|
||||
|
|
@ -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::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');
|
||||
|
||||
// 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');
|
||||
});
|
||||
|
|
|
|||
115
portail/tests/Feature/DevoirTest.php
Normal file
115
portail/tests/Feature/DevoirTest.php
Normal 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([]);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue