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

@ -144,6 +144,50 @@
# redémarrage, il garderait l'ancien mot de passe après une rotation. # redémarrage, il garderait l'ancien mot de passe après une rotation.
notify: redémarrer horizon notify: redémarrer horizon
# Bloc B, stockage S3 (2026-10-14) : fichier créé à la main par Adam sur le
# serveur, jamais par Ansible ni par Claude (CLAUDE.md, commandes et
# dépenses). Tant qu'il n'existe pas, ce bloc ne touche à rien : le dépôt de
# fichier des devoirs reste simplement masqué côté portail
# (StockageTravauxEleves::disponible()) — voir docs/runbooks/stockage-s3.md.
- name: vérifier si les identifiants S3 existent (étape 10, facultatif)
ansible.builtin.stat:
path: /etc/syllabus/s3-paillasse.yaml
register: portail_s3_stat
- name: lire les identifiants S3 du portail (étape 10)
ansible.builtin.slurp:
src: /etc/syllabus/s3-paillasse.yaml
register: portail_s3_brut
when: portail_s3_stat.stat.exists
- name: extraire les champs S3
ansible.builtin.set_fact:
portail_s3_access_key: "{{ portail_s3_contenu | regex_search('access_key:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
portail_s3_secret_key: "{{ portail_s3_contenu | regex_search('secret_key:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
portail_s3_bucket: "{{ portail_s3_contenu | regex_search('bucket:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
portail_s3_region: "{{ portail_s3_contenu | regex_search('region:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
portail_s3_endpoint: "{{ portail_s3_contenu | regex_search('endpoint:\\s*\"?([^\"\\n]+)', '\\1') | first }}"
vars:
portail_s3_contenu: "{{ portail_s3_brut.content | b64decode }}"
when: portail_s3_stat.stat.exists
no_log: true
- name: ajouter/mettre à jour les variables S3 dans .env
ansible.builtin.lineinfile:
path: "{{ portail_dossier_app }}/.env"
regexp: "^{{ item.cle }}="
line: "{{ item.cle }}={{ item.valeur }}"
loop:
- { cle: "AWS_ACCESS_KEY_ID", valeur: "{{ portail_s3_access_key }}" }
- { cle: "AWS_SECRET_ACCESS_KEY", valeur: "{{ portail_s3_secret_key }}" }
- { cle: "AWS_BUCKET", valeur: "{{ portail_s3_bucket }}" }
- { cle: "AWS_DEFAULT_REGION", valeur: "{{ portail_s3_region }}" }
- { cle: "AWS_ENDPOINT", valeur: "{{ portail_s3_endpoint }}" }
- { cle: "AWS_USE_PATH_STYLE_ENDPOINT", valeur: "false" }
when: portail_s3_stat.stat.exists
no_log: true
notify: redémarrer horizon
- name: installer les dépendances JS (npm) - name: installer les dépendances JS (npm)
ansible.builtin.command: ansible.builtin.command:
cmd: npm ci cmd: npm ci

View file

@ -35,3 +35,4 @@ retirer la ligne (et le noter dans le journal) quand c'est fait.
| Chat : pas de notification sonore ni de notification navigateur, sondage toutes les 3 s (ouvert) / 15 s (badge) | Suffisant pour un usage en classe ; à revoir si le besoin d'alerte immédiate apparaît. | Si ça gêne en usage réel | 2026-10-14 | | Chat : pas de notification sonore ni de notification navigateur, sondage toutes les 3 s (ouvert) / 15 s (badge) | Suffisant pour un usage en classe ; à revoir si le besoin d'alerte immédiate apparaît. | Si ça gêne en usage réel | 2026-10-14 |
| « Mes cours » : un seul lab (08) a un vrai cours.md | Les séquences 01 à 07 n'ont pas de cours rédigé (Adam écrit ce contenu lui-même, voir la banque de quiz). La page reste honnête : vide pour un élève qui n'a pratiqué que ces labs, jamais de contenu inventé pour combler. | Quand Adam rédige les cours | 2026-10-14 | | « Mes cours » : un seul lab (08) a un vrai cours.md | Les séquences 01 à 07 n'ont pas de cours rédigé (Adam écrit ce contenu lui-même, voir la banque de quiz). La page reste honnête : vide pour un élève qui n'a pratiqué que ces labs, jamais de contenu inventé pour combler. | Quand Adam rédige les cours | 2026-10-14 |
| « Mes notes » : pas de notion de compétence pour les devoirs maison (seulement pour les labs, via `labs.competences`) | Un devoir maison n'est lié à aucune compétence du référentiel ; ses notes sortent sous « Sans compétence associée ». Ajouter un champ compétence(s) sur `Devoir` si besoin. | Si ça gêne en usage réel | 2026-10-14 | | « Mes notes » : pas de notion de compétence pour les devoirs maison (seulement pour les labs, via `labs.competences`) | Un devoir maison n'est lié à aucune compétence du référentiel ; ses notes sortent sous « Sans compétence associée ». Ajouter un champ compétence(s) sur `Devoir` si besoin. | Si ça gêne en usage réel | 2026-10-14 |
| Stockage S3 des devoirs maison : bucket Hetzner Object Storage pas encore créé | Code déployé et vérifié (`Storage::fake('s3')` en test, `disponible() === false` confirmé en réel) mais jamais exercé contre un vrai bucket — pas à Claude de le créer (CLAUDE.md, commandes et dépenses). Le dépôt de fichier reste masqué côté élève jusque-là, texte libre toujours disponible. Marche à suivre : `docs/runbooks/stockage-s3.md`. | Quand Adam crée le bucket | 2026-10-14 |

View file

@ -1150,3 +1150,28 @@ Deux fois dans ce chantier, mesurer plutôt que deviner a changé la décision :
**Suite** **Suite**
Dernier point du bloc B : stockage S3 des travaux d'élèves (Hetzner Object Storage, avec versioning). Dernier point du bloc B : stockage S3 des travaux d'élèves (Hetzner Object Storage, avec versioning).
## 2026-10-14 — Stockage S3 des devoirs maison (bloc B, dernier chantier)
Dernier point du bloc B annoncé par le CLAUDE.md : dépôt de fichier pour les devoirs maison, en plus de la réponse en texte libre déjà existante, stocké sur Hetzner Object Storage (jamais sur le disque du serveur).
**Fait**
- Migration : `reponse` devient facultative sur `rendus_devoirs`, trois colonnes ajoutées (`fichier_chemin`, `fichier_nom_original`, `fichier_taille_octets`) — un rendu a désormais au moins l'un des deux, texte ou fichier.
- `App\Services\Stockage\StockageTravauxEleves` : stocke/supprime sur le disque `s3` (`league/flysystem-aws-s3-v3`), lien de téléchargement temporaire (5 min), jamais le fichier servi en clair par le portail. `disponible()` renvoie `false` tant que `AWS_BUCKET` n'est pas renseigné — le bouton de dépôt reste alors simplement masqué côté élève (CLAUDE.md : pas de fonction qui a l'air de marcher sans vraiment marcher).
- `DevoirEleveController::rendre()` : validation « l'un ou l'autre » (`required_without`), 10 Mo max, extensions limitées (pdf, doc(x), odt, txt, images, zip), 503 si un fichier est envoyé alors que le disque n'est pas configuré.
- Bug trouvé et corrigé avant même de tester (relecture de mon propre code) : la première version ne supprimait l'ancien fichier d'Object Storage que par comparaison de chemins, ce qui aurait effacé un fichier déjà déposé à chaque renvoi en texte seul. Corrigé : la suppression de l'ancien fichier n'a lieu que si un *nouveau* fichier est vraiment envoyé dans la requête.
- Téléchargement côté prof : `DevoirController::telechargerRendu`, redirige vers un lien signé temporaire, réservé au prof propriétaire de la classe.
- Purge RGPD (`classes:purger-archivees`) étendue : les fichiers d'Object Storage des devoirs de la classe sont supprimés avant la suppression en base (la cascade en base ne connaît pas Object Storage).
- Ansible (`roles/portail/tasks/03-code.yml`) : bloc conditionnel qui lit `/etc/syllabus/s3-paillasse.yaml` s'il existe et écrit les variables `AWS_*` dans `.env` — fichier à créer par Adam lui-même sur le serveur (jamais par Claude, CLAUDE.md « commandes et dépenses »), rien ne change tant qu'il n'existe pas. Runbook dédié : `docs/runbooks/stockage-s3.md`.
- 9 tests Pest (`Storage::fake('s3')`) : dépôt masqué/refusé sans bucket configuré, fichier seul sans texte, validation (ni l'un ni l'autre, taille, extension), remplacement supprime l'ancien mais un renvoi texte seul le conserve, téléchargement réservé au prof propriétaire, affichage côté prof, suppression lors de la purge RGPD. 125 tests au total, tous verts ; Pint et Larastan (niveau 6) sans erreur sur tout le projet.
**Comprendre**
Le dernier bug (suppression accidentelle d'un fichier conservé) est le genre d'erreur qu'un test aurait aussi bien pu laisser passer si je ne l'avais écrit qu'après coup en pensant au cas « remplacement » et en oubliant le cas « renvoi texte seul » : relire sa propre logique de décision (« dans quels cas est-ce qu'on supprime ? ») avant d'écrire le test, pas seulement après, aide à voir le cas manquant.
**Vérifié en réel** : déployé sur le serveur, migration appliquée, bloc Ansible S3 confirmé sans effet tant que `/etc/syllabus/s3-paillasse.yaml` n'existe pas (aucune variable `AWS_` dans `.env`), `StockageTravauxEleves::disponible()` renvoie bien `false` en production (vérifié par tinker, compte jetable supprimé ensuite). Le dépôt réel contre un vrai bucket Hetzner ne pourra être vérifié qu'une fois le bucket créé par Adam (hors de portée de Claude) — couvert pour l'instant par les tests contre un disque S3 factice.
**Suite**
Le bloc B du CLAUDE.md est maintenant complet (API publique, cache Redis, optimisation MariaDB, stockage S3). La suite reste au choix d'Adam : créer le bucket S3 (runbook ci-dessus), avancer sur le bloc C, ou organiser le pilote de la semaine 8 du bloc A.

View file

@ -0,0 +1,83 @@
# Runbook — Stockage S3 des devoirs maison
Les fichiers que les élèves joignent à leurs devoirs maison sont stockés sur
Hetzner Object Storage (S3-compatible), jamais sur le disque du serveur
(`app/Services/Stockage/StockageTravauxEleves.php`). Tant que le bucket n'est
pas configuré, le bouton de dépôt de fichier reste simplement masqué côté
élève (texte libre toujours disponible) — rien ne casse.
Claude ne peut ni créer le bucket ni générer les clés (CLAUDE.md : commandes
et dépenses jamais par Claude). Cette étape est entièrement à faire par Adam.
## 1. Créer le bucket (console Hetzner)
Dans le **projet Hetzner dédié à Paillasse** (jamais `tripoli-cloud`) :
1. Object Storage → créer un bucket, région au choix (ex. `fsn1`).
2. Activer le versioning sur le bucket (un fichier écrasé reste récupérable
quelques temps).
3. Générer une paire de clés d'accès **scopée à ce bucket seulement** (pas
une clé avec accès à tout le compte).
Vérifier les noms exacts des options dans la console/doc Hetzner au moment de
le faire : l'interface peut avoir changé depuis l'écriture de ce runbook.
## 2. Créer le fichier de secrets sur le serveur
Sur `163.172.4.195`, jamais dans Git, jamais collé dans une conversation avec
Claude :
```
ssh paillasse
sudo mkdir -p /etc/syllabus
sudo tee /etc/syllabus/s3-paillasse.yaml > /dev/null <<'EOF'
access_key: "COLLER_LA_CLE_ICI"
secret_key: "COLLER_LE_SECRET_ICI"
bucket: "nom-du-bucket"
region: "fsn1"
endpoint: "https://fsn1.your-objectstorage.com"
EOF
sudo chmod 600 /etc/syllabus/s3-paillasse.yaml
sudo chown root:root /etc/syllabus/s3-paillasse.yaml
```
Adapter `region`/`endpoint` à ce que la console Hetzner affiche pour le
bucket créé.
## 3. Déployer
Depuis le Mac :
```
cd ansible && script -q /dev/null ansible-playbook portail.yml -i inventory.ini
```
Le rôle `portail` (`roles/portail/tasks/03-code.yml`) lit ce fichier s'il
existe, écrit `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`/`AWS_BUCKET`/
`AWS_DEFAULT_REGION`/`AWS_ENDPOINT` dans `.env` et redémarre Horizon. S'il
n'existe pas encore, ce bloc ne fait rien.
## 4. Vérifier
```
cd /var/www/portail
sudo -u www-data HOME=/tmp php8.3 artisan tinker \
--execute="echo App\Services\Stockage\StockageTravauxEleves::disponible() ? 'ok' : 'non configure';"
```
Puis, en élève de test, ouvrir un devoir : le champ « Joindre un fichier »
doit apparaître. Déposer un petit fichier, vérifier côté prof qu'il apparaît
et se télécharge. Supprimer le compte de test ensuite.
## RGPD
Un fichier déposé est supprimé d'Object Storage dès que la classe qui le
contient est purgée (`classes:purger-archivees`, 90 jours après archivage) —
voir `app/Console/Commands/PurgerClassesArchivees.php`.
## Rotation des clés
Même logique que pour le compte de service OpenStack
([runbook dédié](compte-service-openstack.md)) : régénérer les clés dans la
console Hetzner, remplacer `access_key`/`secret_key` dans
`/etc/syllabus/s3-paillasse.yaml`, redéployer (étape 3).

View file

@ -3,7 +3,9 @@
namespace App\Console\Commands; namespace App\Console\Commands;
use App\Models\Classe; use App\Models\Classe;
use App\Models\RenduDevoir;
use App\Services\CacheTableauDeBordProf; use App\Services\CacheTableauDeBordProf;
use App\Services\Stockage\StockageTravauxEleves;
use Illuminate\Console\Command; use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@ -44,6 +46,13 @@ class PurgerClassesArchivees extends Command
continue; 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()); DB::transaction(fn () => $classe->delete());
CacheTableauDeBordProf::vider($classe->user_id); CacheTableauDeBordProf::vider($classe->user_id);
Log::info('Purge RGPD : classe supprimée', [ Log::info('Purge RGPD : classe supprimée', [

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Classe; use App\Models\Classe;
use App\Models\Devoir; use App\Models\Devoir;
use App\Models\RenduDevoir; use App\Models\RenduDevoir;
use App\Services\Stockage\StockageTravauxEleves;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -64,6 +65,16 @@ class DevoirController extends Controller
return redirect()->route('mes-classes.devoirs.show', [$classe, $devoir]); 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 public function destroy(Classe $classe, Devoir $devoir): RedirectResponse
{ {
$this->verifierProprietaire($classe); $this->verifierProprietaire($classe);

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\Devoir; use App\Models\Devoir;
use App\Models\Eleve; use App\Models\Eleve;
use App\Models\RenduDevoir; use App\Models\RenduDevoir;
use App\Services\Stockage\StockageTravauxEleves;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -23,6 +24,7 @@ class DevoirEleveController extends Controller
return view('devoirs.eleve', [ return view('devoirs.eleve', [
'devoir' => $devoir, 'devoir' => $devoir,
'rendu' => RenduDevoir::where('devoir_id', $devoir->id)->where('eleve_id', $eleve->id)->first(), '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); $eleve = $this->verifierAppartenance($devoir);
abort_if($devoir->echeanceDepassee(), 409, 'L’échéance est dépassée.'); 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( if ($request->hasFile('fichier') && ! StockageTravauxEleves::disponible()) {
['devoir_id' => $devoir->id, 'eleve_id' => $eleve->id], abort(503, 'Le dépôt de fichier n’est pas disponible pour le moment.');
['reponse' => $donnees['reponse'], 'rendu_a' => now()] }
);
$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); 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 $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 = [ protected $casts = [
'rendu_a' => 'datetime', 'rendu_a' => 'datetime',
@ -27,4 +27,9 @@ class RenduDevoir extends Model
{ {
return $this->belongsTo(Eleve::class); 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": { "require": {
"php": "^8.3", "php": "^8.3",
"bacon/bacon-qr-code": "^3.1", "bacon/bacon-qr-code": "^3.1",
"doctrine/dbal": "^4.5",
"laravel/framework": "^13.17", "laravel/framework": "^13.17",
"laravel/horizon": "^5.50", "laravel/horizon": "^5.50",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",
"league/flysystem-aws-s3-v3": "^3.0",
"symfony/yaml": "^7.4" "symfony/yaml": "^7.4"
}, },
"require-dev": { "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", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4f63b39390b44505487b3c5999569f85", "content-hash": "6f5b54180f0d3c00c4708c345a8ac918",
"packages": [ "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", "name": "bacon/bacon-qr-code",
"version": "v3.1.1", "version": "v3.1.1",
@ -320,6 +471,160 @@
}, },
"time": "2024-07-08T12:26:09+00:00" "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", "name": "doctrine/inflector",
"version": "2.1.0", "version": "2.1.0",
@ -2053,6 +2358,61 @@
}, },
"time": "2026-09-02T08:00:27+00:00" "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", "name": "league/flysystem-local",
"version": "3.35.3", "version": "3.35.3",
@ -2445,6 +2805,72 @@
], ],
"time": "2026-09-29T19:21:12+00:00" "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", "name": "nesbot/carbon",
"version": "3.14.2", "version": "3.14.2",
@ -2927,6 +3353,55 @@
], ],
"time": "2026-08-24T00:54:40+00:00" "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", "name": "psr/clock",
"version": "1.0.0", "version": "1.0.0",
@ -4135,6 +4610,76 @@
], ],
"time": "2026-06-05T06:23:12+00:00" "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", "name": "symfony/finder",
"version": "v7.4.20", "version": "v7.4.20",
@ -6767,54 +7312,6 @@
], ],
"time": "2024-05-06T16:37:16+00:00" "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", "name": "fakerphp/faker",
"version": "v1.24.1", "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,15 +26,27 @@
<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> <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 @endif
@error('reponse')<p class="mt-2 font-bold text-[#A63D2A]">{{ $message }}</p>@enderror @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()) @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 @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> <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> </form>
@elseif ($rendu) @elseif ($rendu)
@if ($rendu->reponse)
<p class="mt-4 whitespace-pre-wrap font-semibold">{{ $rendu->reponse }}</p> <p class="mt-4 whitespace-pre-wrap font-semibold">{{ $rendu->reponse }}</p>
@endif @endif
@endif
</section> </section>
@endif @endif
</div> </div>

View file

@ -32,7 +32,14 @@
@endif @endif
</div> </div>
@if ($rendu) @if ($rendu)
@if ($rendu->reponse)
<p class="mt-3 whitespace-pre-wrap rounded-xl border border-[#e3e5e8] bg-[#f8f9fa] p-4 font-semibold">{{ $rendu->reponse }}</p> <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"> <form action="{{ route('mes-classes.devoirs.noter', [$classe, $devoir, $rendu]) }}" method="post" class="mt-3 flex items-center gap-2">
@csrf @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> <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::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}/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::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), // Séparation vitrine/application (2026-10-13) : Ma classe (annonces),
// Résultats (observations), Séances en direct, Bibliothèque, chat. // 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);
});