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:
Adam Belkacemi 2026-10-14 09:52:15 +02:00
parent 79d4a06fdf
commit 6ea7a1cc16
16 changed files with 1022 additions and 59 deletions

View file

@ -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', [

View file

@ -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);

View file

@ -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);
}

View file

@ -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;
}
}

View 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));
}
}

View file

@ -8,10 +8,12 @@
"require": {
"php": "^8.3",
"bacon/bacon-qr-code": "^3.1",
"doctrine/dbal": "^4.5",
"laravel/framework": "^13.17",
"laravel/horizon": "^5.50",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "^3.0",
"symfony/yaml": "^7.4"
},
"require-dev": {

595
portail/composer.lock generated
View file

@ -4,8 +4,159 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "4f63b39390b44505487b3c5999569f85",
"content-hash": "6f5b54180f0d3c00c4708c345a8ac918",
"packages": [
{
"name": "aws/aws-crt-php",
"version": "v1.2.7",
"source": {
"type": "git",
"url": "https://github.com/awslabs/aws-crt-php.git",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/awslabs/aws-crt-php/zipball/d71d9906c7bb63a28295447ba12e74723bd3730e",
"reference": "d71d9906c7bb63a28295447ba12e74723bd3730e",
"shasum": ""
},
"require": {
"php": ">=5.5"
},
"require-dev": {
"phpunit/phpunit": "^4.8.35||^5.6.3||^9.5",
"yoast/phpunit-polyfills": "^1.0"
},
"suggest": {
"ext-awscrt": "Make sure you install awscrt native extension to use any of the functionality."
},
"type": "library",
"autoload": {
"classmap": [
"src/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "AWS SDK Common Runtime Team",
"email": "aws-sdk-common-runtime@amazon.com"
}
],
"description": "AWS Common Runtime for PHP",
"homepage": "https://github.com/awslabs/aws-crt-php",
"keywords": [
"amazon",
"aws",
"crt",
"sdk"
],
"support": {
"issues": "https://github.com/awslabs/aws-crt-php/issues",
"source": "https://github.com/awslabs/aws-crt-php/tree/v1.2.7"
},
"time": "2024-10-18T22:15:13+00:00"
},
{
"name": "aws/aws-sdk-php",
"version": "3.401.1",
"source": {
"type": "git",
"url": "https://github.com/aws/aws-sdk-php.git",
"reference": "4cdda6c2d836fa91a8177535530952c96a582f57"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/4cdda6c2d836fa91a8177535530952c96a582f57",
"reference": "4cdda6c2d836fa91a8177535530952c96a582f57",
"shasum": ""
},
"require": {
"aws/aws-crt-php": "^1.2.3",
"ext-json": "*",
"ext-pcre": "*",
"ext-simplexml": "*",
"guzzlehttp/guzzle": "^7.8.2 || ^8.0",
"guzzlehttp/promises": "^2.0.3 || ^3.0",
"guzzlehttp/psr7": "^2.6.3 || ^3.0",
"mtdowling/jmespath.php": "^2.9.1",
"php": ">=8.1",
"psr/http-message": "^1.0 || ^2.0",
"symfony/filesystem": "^v5.4.45 || ^v6.4.3 || ^v7.1.0 || ^v8.0.0"
},
"require-dev": {
"andrewsville/php-token-reflection": "^1.4",
"aws/aws-php-sns-message-validator": "~1.0",
"behat/behat": "~3.0",
"composer/composer": "^2.7.8",
"dms/phpunit-arraysubset-asserts": "^v0.5.0",
"doctrine/cache": "~1.4",
"ext-dom": "*",
"ext-openssl": "*",
"ext-sockets": "*",
"phpunit/phpunit": "^10.0",
"psr/cache": "^2.0 || ^3.0",
"psr/simple-cache": "^2.0 || ^3.0",
"sebastian/comparator": "^1.2.3 || ^4.0 || ^5.0",
"yoast/phpunit-polyfills": "^2.0"
},
"suggest": {
"aws/aws-php-sns-message-validator": "To validate incoming SNS notifications",
"doctrine/cache": "To use the DoctrineCacheAdapter",
"ext-curl": "To send requests using cURL",
"ext-openssl": "Allows working with CloudFront private distributions and verifying received SNS messages",
"ext-pcntl": "To use client-side monitoring",
"ext-sockets": "To use client-side monitoring"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Aws\\": "src/"
},
"exclude-from-classmap": [
"src/data/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"Apache-2.0"
],
"authors": [
{
"name": "Amazon Web Services",
"homepage": "https://aws.amazon.com"
}
],
"description": "AWS SDK for PHP - Use Amazon Web Services in your PHP project",
"homepage": "https://aws.amazon.com/sdk-for-php",
"keywords": [
"amazon",
"aws",
"cloud",
"dynamodb",
"ec2",
"glacier",
"s3",
"sdk"
],
"support": {
"forum": "https://github.com/aws/aws-sdk-php/discussions",
"issues": "https://github.com/aws/aws-sdk-php/issues",
"source": "https://github.com/aws/aws-sdk-php/tree/3.401.1"
},
"time": "2026-10-13T18:14:25+00:00"
},
{
"name": "bacon/bacon-qr-code",
"version": "v3.1.1",
@ -320,6 +471,160 @@
},
"time": "2024-07-08T12:26:09+00:00"
},
{
"name": "doctrine/dbal",
"version": "4.5.0",
"source": {
"type": "git",
"url": "https://github.com/doctrine/dbal.git",
"reference": "c435cd7ea33e17d2a89288caf994cf3d4549b879"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/dbal/zipball/c435cd7ea33e17d2a89288caf994cf3d4549b879",
"reference": "c435cd7ea33e17d2a89288caf994cf3d4549b879",
"shasum": ""
},
"require": {
"doctrine/deprecations": "^1.1.5",
"php": "^8.2",
"psr/cache": "^1|^2|^3",
"psr/log": "^1|^2|^3"
},
"require-dev": {
"doctrine/coding-standard": "14.0.0",
"fig/log-test": "^1",
"jetbrains/phpstorm-stubs": "2023.2",
"phpstan/phpstan": "2.1.30",
"phpstan/phpstan-phpunit": "2.0.7",
"phpstan/phpstan-strict-rules": "^2",
"phpunit/phpunit": "11.5.56",
"slevomat/coding-standard": "8.31.1",
"squizlabs/php_codesniffer": "4.0.4",
"symfony/cache": "^6.3.8|^7.0|^8.0",
"symfony/console": "^5.4|^6.3|^7.0|^8.0"
},
"suggest": {
"symfony/console": "For helpful console commands such as SQL execution and import of files."
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\DBAL\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Guilherme Blanco",
"email": "guilhermeblanco@gmail.com"
},
{
"name": "Roman Borschel",
"email": "roman@code-factory.org"
},
{
"name": "Benjamin Eberlei",
"email": "kontakt@beberlei.de"
},
{
"name": "Jonathan Wage",
"email": "jonwage@gmail.com"
}
],
"description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
"homepage": "https://www.doctrine-project.org/projects/dbal.html",
"keywords": [
"abstraction",
"database",
"db2",
"dbal",
"mariadb",
"mssql",
"mysql",
"oci8",
"oracle",
"pdo",
"pgsql",
"postgresql",
"queryobject",
"sasql",
"sql",
"sqlite",
"sqlserver",
"sqlsrv"
],
"support": {
"issues": "https://github.com/doctrine/dbal/issues",
"source": "https://github.com/doctrine/dbal/tree/4.5.0"
},
"funding": [
{
"url": "https://www.doctrine-project.org/sponsorship.html",
"type": "custom"
},
{
"url": "https://www.patreon.com/phpdoctrine",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
"type": "tidelift"
}
],
"time": "2026-09-24T16:11:26+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
"doctrine/coding-standard": "^9 || ^12 || ^14",
"phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Deprecations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
"time": "2026-02-07T07:09:04+00:00"
},
{
"name": "doctrine/inflector",
"version": "2.1.0",
@ -2053,6 +2358,61 @@
},
"time": "2026-09-02T08:00:27+00:00"
},
{
"name": "league/flysystem-aws-s3-v3",
"version": "3.35.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/flysystem-aws-s3-v3.git",
"reference": "b03780cb97585ee7e48977f40ed599b33b751634"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/flysystem-aws-s3-v3/zipball/b03780cb97585ee7e48977f40ed599b33b751634",
"reference": "b03780cb97585ee7e48977f40ed599b33b751634",
"shasum": ""
},
"require": {
"aws/aws-sdk-php": "^3.371.5",
"league/flysystem": "^3.10.0",
"league/mime-type-detection": "^1.0.0",
"php": "^8.0.2"
},
"conflict": {
"guzzlehttp/guzzle": "<7.0",
"guzzlehttp/ringphp": "<1.1.1"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\Flysystem\\AwsS3V3\\": ""
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frankdejonge.nl"
}
],
"description": "AWS S3 filesystem adapter for Flysystem.",
"keywords": [
"Flysystem",
"aws",
"file",
"files",
"filesystem",
"s3",
"storage"
],
"support": {
"source": "https://github.com/thephpleague/flysystem-aws-s3-v3/tree/3.35.3"
},
"time": "2026-08-08T16:19:23+00:00"
},
{
"name": "league/flysystem-local",
"version": "3.35.3",
@ -2445,6 +2805,72 @@
],
"time": "2026-09-29T19:21:12+00:00"
},
{
"name": "mtdowling/jmespath.php",
"version": "2.9.2",
"source": {
"type": "git",
"url": "https://github.com/jmespath/jmespath.php.git",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/jmespath/jmespath.php/zipball/2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"reference": "2157c5e50e813ec6a96c1eed3be7f64a20fb32a8",
"shasum": ""
},
"require": {
"php": "^7.2.5 || ^8.0",
"symfony/polyfill-mbstring": "^1.17"
},
"require-dev": {
"composer/xdebug-handler": "^3.0.3",
"phpunit/phpunit": "^8.5.52"
},
"bin": [
"bin/jp.php"
],
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.9-dev"
}
},
"autoload": {
"files": [
"src/JmesPath.php"
],
"psr-4": {
"JmesPath\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Graham Campbell",
"email": "hello@gjcampbell.co.uk",
"homepage": "https://github.com/GrahamCampbell"
},
{
"name": "Michael Dowling",
"email": "mtdowling@gmail.com",
"homepage": "https://github.com/mtdowling"
}
],
"description": "Declaratively specify how to extract elements from a JSON document",
"keywords": [
"json",
"jsonpath"
],
"support": {
"issues": "https://github.com/jmespath/jmespath.php/issues",
"source": "https://github.com/jmespath/jmespath.php/tree/2.9.2"
},
"time": "2026-07-06T18:56:19+00:00"
},
{
"name": "nesbot/carbon",
"version": "3.14.2",
@ -2927,6 +3353,55 @@
],
"time": "2026-08-24T00:54:40+00:00"
},
{
"name": "psr/cache",
"version": "3.0.0",
"source": {
"type": "git",
"url": "https://github.com/php-fig/cache.git",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
"shasum": ""
},
"require": {
"php": ">=8.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Cache\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for caching libraries",
"keywords": [
"cache",
"psr",
"psr-6"
],
"support": {
"source": "https://github.com/php-fig/cache/tree/3.0.0"
},
"time": "2021-02-03T23:26:27+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
@ -4135,6 +4610,76 @@
],
"time": "2026-06-05T06:23:12+00:00"
},
{
"name": "symfony/filesystem",
"version": "v7.4.21",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8",
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/polyfill-ctype": "~1.8",
"symfony/polyfill-mbstring": "~1.8"
},
"require-dev": {
"symfony/process": "^6.4|^7.0|^8.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Filesystem\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v7.4.21"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-10-06T20:05:19+00:00"
},
{
"name": "symfony/finder",
"version": "v7.4.20",
@ -6767,54 +7312,6 @@
],
"time": "2024-05-06T16:37:16+00:00"
},
{
"name": "doctrine/deprecations",
"version": "1.1.6",
"source": {
"type": "git",
"url": "https://github.com/doctrine/deprecations.git",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"conflict": {
"phpunit/phpunit": "<=7.5 || >=14"
},
"require-dev": {
"doctrine/coding-standard": "^9 || ^12 || ^14",
"phpstan/phpstan": "1.4.10 || 2.1.30",
"phpstan/phpstan-phpunit": "^1.0 || ^2",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
"psr/log": "^1 || ^2 || ^3"
},
"suggest": {
"psr/log": "Allows logging deprecations via PSR-3 logger implementation"
},
"type": "library",
"autoload": {
"psr-4": {
"Doctrine\\Deprecations\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
"homepage": "https://www.doctrine-project.org/",
"support": {
"issues": "https://github.com/doctrine/deprecations/issues",
"source": "https://github.com/doctrine/deprecations/tree/1.1.6"
},
"time": "2026-02-07T07:09:04+00:00"
},
{
"name": "fakerphp/faker",
"version": "v1.24.1",

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Dépôt de fichier pour les devoirs maison (bloc B, stockage S3, 2026-10-14) :
* en plus du texte libre, pas à sa place — un rendu a au moins l'un des
* deux. Le fichier n'est jamais sur le serveur, seulement son chemin sur
* Object Storage (Hetzner, disque « s3 »).
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('rendus_devoirs', function (Blueprint $table) {
$table->text('reponse')->nullable()->change();
$table->string('fichier_chemin')->nullable()->after('reponse');
$table->string('fichier_nom_original')->nullable()->after('fichier_chemin');
$table->unsignedInteger('fichier_taille_octets')->nullable()->after('fichier_nom_original');
});
}
public function down(): void
{
Schema::table('rendus_devoirs', function (Blueprint $table) {
$table->dropColumn(['fichier_chemin', 'fichier_nom_original', 'fichier_taille_octets']);
$table->text('reponse')->nullable(false)->change();
});
}
};

View file

@ -26,14 +26,26 @@
<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
@error('fichier')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror
@if ($rendu && $rendu->aUnFichier())
<p class="mt-2 font-semibold text-[#5f6368]">Fichier déjà envoyé : {{ $rendu->fichier_nom_original }}</p>
@endif
@if (! $devoir->echeanceDepassee())
<form action="{{ route('devoirs.eleve.rendre', $devoir) }}" method="post" class="mt-4 grid gap-3">
<form action="{{ route('devoirs.eleve.rendre', $devoir) }}" method="post" enctype="multipart/form-data" 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>
<textarea name="reponse" rows="8" class="rounded-xl border border-[#c4c7c5] bg-white px-3 py-2 font-semibold" placeholder="Ta réponse (facultatif si tu joins un fichier)">{{ old('reponse', $rendu->reponse ?? '') }}</textarea>
@if ($depotFichierDisponible)
<label class="font-bold">
Joindre un fichier (facultatif)
<input type="file" name="fichier" class="mt-1 block font-semibold">
</label>
@endif
<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>
@if ($rendu->reponse)
<p class="mt-4 whitespace-pre-wrap font-semibold">{{ $rendu->reponse }}</p>
@endif
@endif
</section>
@endif

View file

@ -32,7 +32,14 @@
@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>
@if ($rendu->reponse)
<p class="mt-3 whitespace-pre-wrap rounded-xl border border-[#e3e5e8] bg-[#f8f9fa] p-4 font-semibold">{{ $rendu->reponse }}</p>
@endif
@if ($rendu->aUnFichier())
<a href="{{ route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]) }}" class="mt-3 inline-flex items-center gap-2 rounded-xl border border-[#e3e5e8] bg-[#f8f9fa] px-4 py-2 font-bold underline underline-offset-4">
Fichier : {{ $rendu->fichier_nom_original }} ({{ number_format($rendu->fichier_taille_octets / 1024, 0) }} Ko)
</a>
@endif
<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>

View file

@ -207,6 +207,7 @@ Route::middleware(['auth:web', 'session.unique:web'])->group(function () {
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');
Route::get('/mes-classes/{classe}/devoirs/{devoir}/rendus/{rendu}/telecharger', [DevoirController::class, 'telechargerRendu'])->name('mes-classes.devoirs.telecharger');
// Séparation vitrine/application (2026-10-13) : Ma classe (annonces),
// Résultats (observations), Séances en direct, Bibliothèque, chat.

View file

@ -0,0 +1,147 @@
<?php
use App\Models\Classe;
use App\Models\Devoir;
use App\Models\Eleve;
use App\Models\RenduDevoir;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
/** Active le disque s3 factice et le fait passer pour configuré. */
function avecDisqueS3Factice(): void
{
config(['filesystems.disks.s3.bucket' => 'bucket-de-test']);
Storage::fake('s3');
}
test('tant que le bucket s3 n’est pas configuré, le dépôt de fichier est masqué et refusé', 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)->get(route('devoirs.eleve.show', $devoir))->assertOk()->assertDontSee('Joindre un fichier');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertStatus(503);
});
test('un élève peut rendre un devoir avec un fichier seul, sans texte', function () {
avecDisqueS3Factice();
$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)->get(route('devoirs.eleve.show', $devoir))->assertOk()->assertSee('Joindre un fichier');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertRedirect();
$rendu = RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first();
expect($rendu)->not->toBeNull();
expect($rendu->reponse)->toBeNull();
expect($rendu->aUnFichier())->toBeTrue();
expect($rendu->fichier_nom_original)->toBe('devoir.pdf');
Storage::disk('s3')->assertExists($rendu->fichier_chemin);
});
test('un rendu sans réponse ni fichier est refusé', function () {
avecDisqueS3Factice();
$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), [])->assertSessionHasErrors(['reponse', 'fichier']);
expect(RenduDevoir::count())->toBe(0);
});
test('un fichier trop lourd ou d’une extension refusée est rejeté', function () {
avecDisqueS3Factice();
$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), [
'fichier' => UploadedFile::fake()->create('trop-gros.pdf', 11 * 1024, 'application/pdf'),
])->assertSessionHasErrors('fichier');
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('script.exe', 10, 'application/x-msdownload'),
])->assertSessionHasErrors('fichier');
expect(RenduDevoir::count())->toBe(0);
});
test('remplacer son fichier supprime l’ancien de l’object storage, un nouvel envoi en texte seul le conserve', function () {
avecDisqueS3Factice();
$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), [
'fichier' => UploadedFile::fake()->create('premier.pdf', 100, 'application/pdf'),
])->assertRedirect();
$cheminPremier = RenduDevoir::first()->fichier_chemin;
Storage::disk('s3')->assertExists($cheminPremier);
// Un nouvel envoi en texte seul (pas de nouveau fichier) garde l'ancien fichier.
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), ['reponse' => 'Je précise ma réponse'])->assertRedirect();
$rendu = RenduDevoir::first();
expect($rendu->fichier_chemin)->toBe($cheminPremier)->and($rendu->reponse)->toBe('Je précise ma réponse');
Storage::disk('s3')->assertExists($cheminPremier);
// Un nouveau fichier remplace l'ancien, qui est supprimé de l'object storage.
eleve($this, $eleve)->post(route('devoirs.eleve.rendre', $devoir), [
'fichier' => UploadedFile::fake()->create('second.pdf', 100, 'application/pdf'),
])->assertRedirect();
Storage::disk('s3')->assertMissing($cheminPremier);
expect(RenduDevoir::count())->toBe(1);
});
test('seul le prof propriétaire peut télécharger le fichier rendu par un élève', function () {
avecDisqueS3Factice();
$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), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertRedirect();
$rendu = RenduDevoir::first();
commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]))->assertRedirect();
$autreClasse = Classe::factory()->create();
commeProf($this, $autreClasse->prof)->get(route('mes-classes.devoirs.telecharger', [$classe, $devoir, $rendu]))->assertForbidden();
});
test('le prof voit le fichier déposé par un élève dans la page du devoir', function () {
avecDisqueS3Factice();
$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), [
'fichier' => UploadedFile::fake()->create('rapport-reseau.pdf', 100, 'application/pdf'),
])->assertRedirect();
commeProf($this, $classe->prof)->get(route('mes-classes.devoirs.show', [$classe, $devoir]))
->assertSee('rapport-reseau.pdf');
});
test('purger une classe archivée supprime aussi les fichiers de ses rendus sur l’object storage', function () {
avecDisqueS3Factice();
$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), [
'fichier' => UploadedFile::fake()->create('devoir.pdf', 100, 'application/pdf'),
])->assertRedirect();
$chemin = RenduDevoir::first()->fichier_chemin;
Storage::disk('s3')->assertExists($chemin);
$classe->forceFill(['archivee_a' => now()->subDays(91)])->save();
$this->artisan('classes:purger-archivees');
Storage::disk('s3')->assertMissing($chemin);
expect(RenduDevoir::count())->toBe(0);
});