ajoute le stockage S3 des devoirs maison (bloc B, dernier chantier)
Dépôt de fichier en plus du texte libre pour les devoirs maison, stocké sur Hetzner Object Storage (jamais sur le disque du serveur), masqué côté élève tant que le bucket n'est pas configuré. Purge RGPD étendue pour supprimer aussi les fichiers d'Object Storage. Ansible lit les identifiants depuis un fichier que seul Adam peut créer sur le serveur (runbook docs/runbooks/stockage-s3.md) ; le bloc B du CLAUDE.md est maintenant complet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
79d4a06fdf
commit
6ea7a1cc16
16 changed files with 1022 additions and 59 deletions
|
|
@ -3,7 +3,9 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Classe;
|
||||
use App\Models\RenduDevoir;
|
||||
use App\Services\CacheTableauDeBordProf;
|
||||
use App\Services\Stockage\StockageTravauxEleves;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
|
@ -44,6 +46,13 @@ class PurgerClassesArchivees extends Command
|
|||
continue;
|
||||
}
|
||||
|
||||
// Les fichiers d'Object Storage ne sont pas liés par une clé
|
||||
// étrangère : à supprimer nous-mêmes avant la cascade en base.
|
||||
RenduDevoir::whereHas('devoir', fn ($q) => $q->where('classe_id', $classe->id))
|
||||
->whereNotNull('fichier_chemin')
|
||||
->pluck('fichier_chemin')
|
||||
->each(fn (string $chemin) => StockageTravauxEleves::supprimer($chemin));
|
||||
|
||||
DB::transaction(fn () => $classe->delete());
|
||||
CacheTableauDeBordProf::vider($classe->user_id);
|
||||
Log::info('Purge RGPD : classe supprimée', [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||
use App\Models\Classe;
|
||||
use App\Models\Devoir;
|
||||
use App\Models\RenduDevoir;
|
||||
use App\Services\Stockage\StockageTravauxEleves;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -64,6 +65,16 @@ class DevoirController extends Controller
|
|||
return redirect()->route('mes-classes.devoirs.show', [$classe, $devoir]);
|
||||
}
|
||||
|
||||
public function telechargerRendu(Classe $classe, Devoir $devoir, RenduDevoir $rendu): RedirectResponse
|
||||
{
|
||||
$this->verifierProprietaire($classe);
|
||||
$this->verifierAppartenance($classe, $devoir);
|
||||
abort_if($rendu->devoir_id !== $devoir->id, 404);
|
||||
abort_if($rendu->fichier_chemin === null, 404);
|
||||
|
||||
return redirect()->away(StockageTravauxEleves::urlTemporaire($rendu->fichier_chemin));
|
||||
}
|
||||
|
||||
public function destroy(Classe $classe, Devoir $devoir): RedirectResponse
|
||||
{
|
||||
$this->verifierProprietaire($classe);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Http\Controllers;
|
|||
use App\Models\Devoir;
|
||||
use App\Models\Eleve;
|
||||
use App\Models\RenduDevoir;
|
||||
use App\Services\Stockage\StockageTravauxEleves;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -23,6 +24,7 @@ class DevoirEleveController extends Controller
|
|||
return view('devoirs.eleve', [
|
||||
'devoir' => $devoir,
|
||||
'rendu' => RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first(),
|
||||
'depotFichierDisponible' => StockageTravauxEleves::disponible(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -31,12 +33,39 @@ class DevoirEleveController extends Controller
|
|||
$eleve = $this->verifierAppartenance($devoir);
|
||||
abort_if($devoir->echeanceDepassee(), 409, 'L’échéance est dépassée.');
|
||||
|
||||
$donnees = $request->validate(['reponse' => ['required', 'string', 'max:10000']]);
|
||||
$donnees = $request->validate([
|
||||
'reponse' => ['nullable', 'required_without:fichier', 'string', 'max:10000'],
|
||||
'fichier' => [
|
||||
'nullable', 'required_without:reponse', 'file',
|
||||
'max:'.(int) (StockageTravauxEleves::TAILLE_MAX_OCTETS / 1024),
|
||||
'mimes:'.implode(',', StockageTravauxEleves::EXTENSIONS_ACCEPTEES),
|
||||
],
|
||||
]);
|
||||
|
||||
RenduDevoir::updateOrCreate(
|
||||
['devoir_id' => $devoir->id, 'eleve_id' => $eleve->id],
|
||||
['reponse' => $donnees['reponse'], 'rendu_a' => now()]
|
||||
);
|
||||
if ($request->hasFile('fichier') && ! StockageTravauxEleves::disponible()) {
|
||||
abort(503, 'Le dépôt de fichier n’est pas disponible pour le moment.');
|
||||
}
|
||||
|
||||
$ancienRendu = RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first();
|
||||
|
||||
$valeurs = ['reponse' => $donnees['reponse'] ?? null, 'rendu_a' => now()];
|
||||
|
||||
// Pas de nouveau fichier : celui déjà rendu (s'il existe) reste en
|
||||
// place, updateOrCreate ne touche pas aux colonnes absentes de $valeurs.
|
||||
if ($request->hasFile('fichier')) {
|
||||
$fichier = StockageTravauxEleves::stocker($request->file('fichier'), (string) $devoir->id, (string) $eleve->id);
|
||||
$valeurs['fichier_chemin'] = $fichier['chemin'];
|
||||
$valeurs['fichier_nom_original'] = $fichier['nom_original'];
|
||||
$valeurs['fichier_taille_octets'] = $fichier['taille_octets'];
|
||||
}
|
||||
|
||||
RenduDevoir::updateOrCreate(['devoir_id' => $devoir->id, 'eleve_id' => $eleve->id], $valeurs);
|
||||
|
||||
// Un nouveau fichier remplace l'ancien : celui-ci n'a plus de raison
|
||||
// d'exister sur Object Storage.
|
||||
if ($request->hasFile('fichier') && $ancienRendu?->fichier_chemin) {
|
||||
StockageTravauxEleves::supprimer($ancienRendu->fichier_chemin);
|
||||
}
|
||||
|
||||
return redirect()->route('devoirs.eleve.show', $devoir)->with('rendu_envoye', true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ class RenduDevoir extends Model
|
|||
{
|
||||
protected $table = 'rendus_devoirs';
|
||||
|
||||
protected $fillable = ['devoir_id', 'eleve_id', 'reponse', 'note', 'rendu_a'];
|
||||
protected $fillable = ['devoir_id', 'eleve_id', 'reponse', 'fichier_chemin', 'fichier_nom_original', 'fichier_taille_octets', 'note', 'rendu_a'];
|
||||
|
||||
protected $casts = [
|
||||
'rendu_a' => 'datetime',
|
||||
|
|
@ -27,4 +27,9 @@ class RenduDevoir extends Model
|
|||
{
|
||||
return $this->belongsTo(Eleve::class);
|
||||
}
|
||||
|
||||
public function aUnFichier(): bool
|
||||
{
|
||||
return $this->fichier_chemin !== null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
58
portail/app/Services/Stockage/StockageTravauxEleves.php
Normal file
58
portail/app/Services/Stockage/StockageTravauxEleves.php
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Stockage;
|
||||
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Stockage des rendus de devoirs maison sur Object Storage (bloc B,
|
||||
* Hetzner, 2026-10-14) : jamais sur le disque du serveur. Le bouton de
|
||||
* dépôt de fichier n'apparaît que si le disque est vraiment configuré
|
||||
* (CLAUDE.md : pas de fonction qui a l'air de marcher sans vraiment
|
||||
* marcher) — tant qu'Adam n'a pas créé le bucket, seul le texte libre
|
||||
* reste proposé.
|
||||
*/
|
||||
class StockageTravauxEleves
|
||||
{
|
||||
public const TAILLE_MAX_OCTETS = 10 * 1024 * 1024; // 10 Mo
|
||||
|
||||
public const EXTENSIONS_ACCEPTEES = ['pdf', 'doc', 'docx', 'odt', 'txt', 'png', 'jpg', 'jpeg', 'zip'];
|
||||
|
||||
public static function disponible(): bool
|
||||
{
|
||||
return filled(config('filesystems.disks.s3.bucket'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{chemin: string, nom_original: string, taille_octets: int}
|
||||
*/
|
||||
public static function stocker(UploadedFile $fichier, string $devoirId, string $eleveId): array
|
||||
{
|
||||
$chemin = $fichier->storeAs(
|
||||
"devoirs/{$devoirId}/{$eleveId}",
|
||||
now()->format('Ymd-His').'-'.Str::random(8).'.'.$fichier->getClientOriginalExtension(),
|
||||
's3'
|
||||
);
|
||||
|
||||
return [
|
||||
'chemin' => $chemin,
|
||||
'nom_original' => $fichier->getClientOriginalName(),
|
||||
'taille_octets' => $fichier->getSize(),
|
||||
];
|
||||
}
|
||||
|
||||
public static function supprimer(?string $chemin): void
|
||||
{
|
||||
if ($chemin !== null) {
|
||||
Storage::disk('s3')->delete($chemin);
|
||||
}
|
||||
}
|
||||
|
||||
/** Lien valable 5 minutes, jamais le fichier servi en clair par le portail. */
|
||||
public static function urlTemporaire(string $chemin): string
|
||||
{
|
||||
return Storage::disk('s3')->temporaryUrl($chemin, now()->addMinutes(5));
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue