Repository navigation
05 modules scorm quiz
Public : développeurs. Pour l'usage du builder et des quiz côté formateur, voir Profils utilisateurs.
Module
└── ModuleSection (chapitre)
└── ModuleLecture (leçon)
├── Contenu SCORM (package + version)
├── Contenu slides
├── Quiz natif (QuizQuestion + options)
├── Blocs éditoriaux formateur (texte, image, vidéo, citation, séparateur, SCORM)
├── Contenu vidéo / URL
├── LectureObjective (objectifs pédagogiques)
└── LessonResource (ressources téléchargeables)
Fichier : app/Models/Module.php
Champs principaux :
-
module_title/module_name/module_name_slug/description/objectifs -
category_id/subcategory_id -
formateur_id(formateur associé au module) -
is_trainer_authored(module créé par un formateur, exclu du catalogue public) -
status(actif/inactif — modifiable par le formateur depuis le panneau Options du builder) -
certificat,label,duree,resources,prerequi,module_video,module_image,header_image(modifiables par le formateur depuis le panneau Options du builder) -
bestseller,vedette,surevalue(flags marketing catalogue admin uniquement, non exposés au formateur) -
evaluation_id(évaluation SCORM associée)
Le modèle calcule une durée pédagogique via :
-
getTotalSecondsAttribute()— durée brute de toutes les leçons -
getEstimatedSecondsForUser(int $userId)— ajuste selon le nombre de tentatives passées du stagiaire
Deux flux coexistent :
| Flux | Contrôleur | Usage |
|---|---|---|
| Catalogue admin | Backend/ModuleController |
Création complète des modules publics, SCORM, slides, quiz, évaluations |
| Modules formateur | Formateur/ModuleBuilderController |
Création de modules personnels (avec structure d'exemple pré-remplie), plan continu chapitres/leçons, options du module, duplication d'un module catalogue, assignation aux groupes |
Les modules personnels ont is_trainer_authored = true. Ils sont visibles dans l'espace du formateur propriétaire et des groupes auxquels ils sont affectés, mais exclus de la liste publique (Module::publiclyListable()).
À la création (CreerModule), le module est pré-rempli avec une structure d'exemple : 2 chapitres, le premier avec 2 leçons, le second avec 1 leçon — pour éviter la page blanche et montrer le fonctionnement du plan continu. Le formateur est libre de renommer ou supprimer ces éléments.
Un module peut aussi être créé automatiquement (chapitres, leçons, contenu) à partir d'un thème et/ou d'un document, via Mistral. Voir Génération de contenu par IA.
Le builder formateur est séparé en deux niveaux :
| Niveau | Vue | Rôle |
|---|---|---|
| Plan du module | resources/views/formateur/modules-builder/edit.blade.php |
Titre, description, chapitres, leçons ; panneau replié « Options du module » (média, paramètres, contenu) et « Groupes assignés » |
| Contenu de leçon | resources/views/formateur/modules-builder/lecture-edit.blade.php |
Édition des blocs de contenu d'une leçon |
Le panneau Options + Groupes assignés est replié par défaut (un seul bouton d'ouverture) pour garder l'écran de création concentré sur le titre, la description et le plan. Les options du module (ModuleBuilderController::updateOptions() / ModifierOptionsModule) couvrent label, durée, temps estimé par question, certificat, actif, ressources, prérequis, objectifs pédagogiques (un par ligne, stocké en tableau — voir Génération de contenu par IA), vidéo, image d'en-tête et image principale — tous facultatifs. Les champs catalogue (catégorie, sous-catégorie, formateur, bestseller, vedette, valeur ajoutée) restent réservés à l'admin et n'apparaissent pas ici.
Le plan est monté via [data-outline-editor] et resources/js/outline-editor/OutlineEditor.jsx. Il utilise Tiptap/ProseMirror avec deux noeuds métier :
-
chapterHeadingpour les chapitres ; -
lessonItempour les leçons.
Comportements implémentés :
-
Entréeajoute une nouvelle ligne de leçon ; -
Maj+Entréetransforme une leçon vide en chapitre ; -
Alt+↑/Alt+↓réordonnent les lignes (clavier) ; - une leçon peut aussi être réordonnée à la souris via sa poignée ≡, uniquement à l'intérieur de son chapitre (glisser vers un autre chapitre est refusé) ;
- les boutons "+ Ajouter une leçon" / "+ Ajouter un chapitre" sous le plan ajoutent une ligne vide en fin de document (équivalent souris de
Entrée) ; - le renommage est sauvegardé avec debounce ;
- une leçon peut être déplacée entre chapitres ;
- la suppression d'un chapitre ou d'une leçon passe par une confirmation, et un chapitre contenant des leçons ne peut pas être supprimé (
destroySection()renvoie une erreur 422 plutôt que de supprimer en cascade) ; - une leçon avec contenu existant ne peut pas être promue en chapitre, pour éviter une perte de contenu ;
- chaque leçon/chapitre a un menu "•••" (au lieu d'icônes séparées) avec ses actions : Dupliquer (leçon uniquement) et Supprimer.
Icônes et logique de menu partagées entre chapter-heading-node.js et lesson-item-node.js via row-controls.js (un seul menu "•••" ouvert à la fois, quel que soit l'endroit). Les node views utilisent ignoreMutation() pour empêcher ProseMirror d'annuler les mutations DOM (classe hidden du menu, etc.) faites en dehors de contentDOM.
Endpoints JSON principaux :
| Action | Route nommée |
|---|---|
| Créer / renommer / supprimer un chapitre | formateur.modules.builder.sections.* |
| Réordonner les chapitres | formateur.modules.builder.sections.reorder |
| Créer / renommer / supprimer une leçon | formateur.modules.builder.lectures.* |
| Dupliquer une leçon | formateur.modules.builder.lectures.duplicate |
| Réordonner les leçons d'un chapitre | formateur.modules.builder.lectures.reorder |
| Déplacer une leçon vers un autre chapitre | formateur.modules.builder.lectures.move |
| Transformer une leçon vide en chapitre | formateur.modules.builder.lectures.promote |
Le renommage d'une leçon depuis le plan ne doit pas écraser content_blocks. ModuleBuilderController::updateLecture() ne modifie donc le contenu que si la clé content_blocks est réellement envoyée.
DupliquerLecon clone une leçon (Model::replicate()) avec tous ses champs — titre suffixé par « (copie) », insérée juste après l'originale (positions recalculées pour tout le chapitre). Fonctionne aussi pour les leçons SCORM/slides verrouillées, qui pointent alors vers le même paquet importé que l'original.
Les leçons SCORM ou slides issues d'une duplication catalogue restent présentes dans la copie formateur. Leur contenu importé est affiché comme verrouillé sur la page d'édition de leçon ; le formateur peut seulement renommer la leçon.
Depuis l'écran du plan de module, un bouton « + Générer une leçon (IA) » permet d'importer un document pour pré-remplir une leçon dans un chapitre donné. Voir Génération de contenu par IA pour le détail (flux, garde-fous, configuration).
Les vues formateur de chapitre et de leçon (resources/views/formateur/formations/chapitre.blade.php et resources/views/formateur/formations/lecon.blade.php) privilégient désormais une lecture calme par défaut :
- le contenu ou le point d'entrée du chapitre occupe toute la largeur utile ;
- la vue stagiaire s'ouvre dans la prévisualisation anonyme (
anonymous=1) au lieu d'être mélangée à la vue formateur ; - les objectifs, quiz/corrigés, ressources et outils numériques sont rangés dans un panneau latéral ouvert à la demande ;
- la liste des ressources reste consultable rapidement, tandis que l'ajout, la visibilité stagiaire et la suppression sont repliés derrière l'action "Gérer" ;
- les actions d'animation utilisent le contexte courant (
mode,group_id, module, chapitre, leçon) pour éviter au formateur de ressaisir des choix déjà connus.
Objectif UX : réduire la charge cognitive du formateur en séance sans retirer les contrôles pédagogiques existants.
| Type | Champ | Description |
|---|---|---|
| SCORM |
scorm_path, ScormPackageVersion
|
Package SCORM importé avec versioning |
| Slides |
slides_path, slides_status
|
Présentation convertie et hébergée |
| Quiz natif |
quiz_enabled, quiz_questions_per_attempt
|
Questions et réponses gérées nativement |
| Quiz live | live_quiz_entry_enabled |
Entrée vers une session live depuis la leçon |
| Vidéo / URL |
url, module_video, chemins via config/learning_assets.php
|
Lien externe ou vidéo intégrée |
| Blocs formateur |
content_type = blocks, content_blocks
|
Contenu éditorial structuré en JSON |
Le builder formateur sauvegarde les leçons en content_blocks. Les blocs acceptés sont :
-
textavec HTML limité, édité via TipTap (resources/js/formateur-module-builder-editor.jsx) : gras, italique, souligné, barré, code en ligne, titres H1-H4, liste à puces/numérotée, lien, undo/redo. Le rendu (édition et lecture) utilise la classe CSS.rich-text-content(resources/css/app.css) pour styler ces balises en l'absence du plugin@tailwindcss/typography; -
imageréférencée parmedia_id(voir ci-dessous) ; -
video: URL YouTube/Vimeo/fichier direct (voir ci-dessous) ; -
quote; -
divider; -
scorm: package SCORM importé, isolé du reste de la leçon (voir « SCORM en tant que bloc de leçon » plus bas).
Il n'y a pas de bloc list séparé — les listes se font uniquement via le bloc text (boutons dédiés dans la barre d'outils TipTap). Ce bloc a existé un temps comme type à part entière avant d'être retiré au profit de la liste intégrée au texte.
NettoyeurBlocsModule::sanitizeBlocks() limite les blocs à 100 éléments, supprime les balises/scripts non autorisés (liste blanche : p br strong b em i u s ul ol li h1-h4 blockquote a code pre) et vérifie que chaque media_id d'un bloc image correspond bien à un media Spatie rattaché au module courant.
Les images des blocs de leçon sont gérées par spatie/laravel-medialibrary (table media), rattachées au modèle Module (collection lesson-images) et non à la leçon individuelle — une image envoyée depuis une leçon reste réutilisable dans les autres leçons du même module.
- Upload :
TeleverserImageModule::execute()fait$module->addMedia($file)->toMediaCollection('lesson-images'), appelé depuisModuleBuilderController::uploadImage(). - Conversion
display(définie dansModule::registerMediaConversions()) : redimensionnement max 1600×1600 (Fit::Max), générée de façon synchrone (nonQueued()). - URLs : un générateur d'URL custom (
App\Domains\ModulesFormateur\Support\MediaStorageUrlGenerator, configuré dansconfig/media-library.php) fait passer les médias par la route existante/media/storage/{path}plutôt que par le lien symboliquestorage:link— cohérent avec le reste du projet, qui n'utilise pas ce lien de façon fiable. - Seul
media_idest persisté danscontent_blocks(jamais l'URL, qui est régénérée à chaque affichage viaDonneesModule::resolvedContentBlocks()côté édition et directement danslecture_blocks.blade.phpcôté lecture). - Portée volontairement limitée à ce bloc : les autres uploads d'image de l'app (avatars, badges, groupes, catégories, questions de quiz, couverture de module) continuent à utiliser leur
Storage::storeAs()respectif, non migrés.
Le bloc video stocke une URL brute dans content_blocks (pas de media_id) : App\Domains\ModulesFormateur\Support\ClassifieurUrlVideo::classify() reconnaît les liens YouTube, Vimeo et fichiers directs (.mp4/.webm/.ogg), et calcule l'URL d'embed. Toute URL non reconnue est rejetée par le sanitizer (pas d'iframe vers un domaine arbitraire).
Deux façons d'obtenir cette URL côté formateur :
- coller un lien YouTube/Vimeo/fichier existant ;
- téléverser un fichier vidéo directement (
TeleverserVideoModule, collection Spatielesson-videossurModule, mêmes principes que les images). L'upload retourne l'URL servie via/media/storage/{path}et la place automatiquement dans le champ URL — elle est ensuite traitée exactement comme un lien de fichier direct par le classifieur (aucune distinction stockée entre vidéo hébergée et lien externe). - Limite : 100 Mo par fichier, appliquée à deux niveaux qui doivent rester cohérents entre eux :
- Laravel :
ModuleBuilderController::uploadVideovalidemax:102400(Ko) ; - Spatie Media Library :
config/media-library.php→max_file_size(relevé à 100 Mo ; la valeur par défaut du package est 10 Mo et rejette silencieusement tout fichier plus gros avec uneFileIsTooBigexception, à surveiller si la limite est remontée un jour). - Cote PHP/Apache,
public/.htaccessfixe déjàupload_max_filesize/post_max_sizeà 512/520 Mo (AllowOverride Allsur le vhost) — largement suffisant, aucune modification serveur nécessaire pour rester sous 100 Mo.
- Laravel :
- Pas de transcodage/miniature :
ffmpegn'est pas installé sur le serveur, le fichier est servi tel quel.
Le rendu se fait en iframe responsive (aspect-video) pour YouTube/Vimeo, ou en balise <video> native pour un fichier direct (uploadé ou lien externe).
Contrairement au SCORM « pleine leçon » (content_type = scorm, voir plus bas), le bloc scorm permet de mélanger un ou plusieurs packages SCORM avec du texte/image/vidéo dans une même leçon. Le runtime SCORM (public/scorm_core/js/API.js) suppose historiquement qu'une page = un seul package ; ce mode est rendu possible sans toucher au comportement existant grâce à trois choix d'isolation :
-
Suivi séparé : les tables
content_block_scorm_scores/content_block_scorm_results(miroirs descorm_scores/scorm_results, mais uniques sur(user_id, lecture_id, content_block_key)) évitent de casser les 5+ endroits du code qui supposent une seule lignescorm_scorespar(user_id, lecture_id). -
Dossier d'import scopé par module : chaque bloc importe son ZIP dans
modules/00_Lecons_blocks/module_{moduleId}/block_{content_block_key}/(LearningAssetPath::lessonBlockScormFolder()), jamais dansmodules/00_Lecons/lecture_{id}/utilisé par l'import SCORM admin classique — donc aucun risque qu'un import écrase l'autre. -
Contexte isolé via une page wrapper : le bloc s'affiche dans une iframe pointant vers
lecture.scorm-block(Frontend\LectureController::showScormBlock), qui rendresources/views/shared/scorm_block_wrapper.blade.php. Ce document définitwindow.SCORM_CONTEXT = {embedded: true, lecture_id, content_block_key, is_already_done}sur sa propre fenêtre, puis imbrique le vrai package SCORM dans une iframe interne. CommeAPI.jslitwindow.parent.SCORM_CONTEXT, le package voit le contexte du wrapper et non celui, déjà défini, de la page de leçon englobante.
Dans API.js, envoyerProgression() poste vers /scorm/save-block-progress (ContentBlockScormController::saveProgress) dès que content_block_key est présent dans le contexte, et afficherBoutonSuivantDepuisIframe() ne fait rien si context.embedded === true — un bloc ne pilote pas le bouton « leçon suivante » de la leçon englobante (ambigu s'il y a plusieurs blocs). Ces deux branches ne s'activent que si les nouveaux champs sont présents : le SCORM pleine-page existant ne les envoie jamais, donc son comportement est inchangé.
Sécurité : content_block_key est généré côté client (crypto.randomUUID(), avec repli si non disponible) et validé côté serveur (^[A-Za-z0-9_-]{8,64}$) avant de servir de nom de dossier. NettoyeurBlocsModule revérifie, à chaque sauvegarde, que le ScormPackageVersion référencé par le bloc a bien été importé dans le dossier attendu pour ce module+clé (ScormPackageVersion.folder), ce qui sert de preuve de propriété sans table de correspondance supplémentaire.
Duplication : DupliquerLecon régénère une nouvelle content_block_key et vide scorm_package_version_id pour chaque bloc scorm de la copie — sans ça, l'originale et la copie partageraient le même dossier/package, et un réimport sur l'une écraserait l'autre. La leçon dupliquée affiche donc des blocs SCORM « vides » à réimporter.
La table group_module_lectures permet à chaque formateur de :
- Masquer une leçon pour son groupe
- Réordonner les leçons d'un module pour un groupe
Géré par Formateur/GroupeModuleLessonController.
Fichier : app/Services/Scorm/ScormImporter.php
Le processus d'import :
- Validation du ZIP (taille max 500 Mo, format valide)
- Extraction sécurisée dans
release_YYYYMMDD_HHMMSS_{random}(protection path traversal viasafeExtract()) - Détection du point d'entrée (
imsmanifest.xmlouindex.htmlfallback) -
Injection automatique de
/scorm_core/js/API.jsdans la page d'entrée - Création/mise à jour d'un
ScormPackage(slug stable) et d'uneScormPackageVersion
Le ScormPackageVersion expose un getScormCacheTokenAttribute() basé sur imported_at — cela force le navigateur à recharger l'API.js après chaque réimport, évitant les problèmes de cache.
| Fichier | Standard | Comportement |
|---|---|---|
public/scorm_core/js/API.js |
SCORM 1.2 | Expose window.API, envoie toutes les paires clé/valeur via fetch() vers /scorm/save-progress
|
public/scorm_core/js/api_Scorm2004.js |
SCORM 2004 | Expose window.API_1484_11, stocke en mémoire, ne poste pas les interactions au backend |
| Route | Méthode | CSRF | Authentification |
|---|---|---|---|
/scorm/save-progress |
POST | Désactivé | Session Laravel (Auth::id()) |
/scorm/progress |
POST | Activé |
auth middleware |
/scorm/save-block-progress |
POST | Désactivé | Session Laravel (Auth::id()) — bloc SCORM de leçon mixte |
/lecture/{id}/scorm |
GET | — | Publique |
/lecture/{id}/scorm-block/{key} |
GET | — |
auth middleware — page wrapper d'un bloc SCORM |
/scorm/evaluation-progress |
POST | Désactivé | Session Laravel |
Pour les leçons :
| Table | Contenu |
|---|---|
scorm_results |
Log brut de toutes les paires clé/valeur envoyées |
scorm_scores |
first_score, best_score, last_score, lesson_status, is_completed, session_time, attempts_count
|
scorm_interactions |
Questions/réponses SCORM — non alimenté pour les leçons (bug connu) |
content_block_scorm_results / content_block_scorm_scores
|
Mêmes colonnes que ci-dessus + content_block_key, uniques sur (user_id, lecture_id, content_block_key) — utilisées uniquement par les blocs scorm d'une leçon mixte (voir plus haut) |
Pour les évaluations :
| Table | Contenu |
|---|---|
scorm_evaluation_results |
Log brut évaluation |
scorm_evaluation_scores |
Scores évaluation |
scorm_evaluation_interactions |
Questions/réponses évaluation — alimenté via EvaluationSCORMController
|
| Contexte | Seuil de réussite | Configurable |
|---|---|---|
Leçons (SCORMController) |
Score >= 50% | Non |
Évaluations (EvaluationSCORMController) |
Score >= 75% | Non |
Le statut est monotone : une leçon une fois marquée completed ou passed ne peut pas rétrograder.
-
scorm_interactionsvide pour les leçons → les métriques de questions/réponses dans les dashboards sont structurellement vides pour le contenu SCORM -
last_session_timeutilisé dansSCORMControllermais pas migré → le cumul du temps de session est défaillant (corrigé danscontent_block_scorm_scores, qui a une vraie colonnelast_session_time— bug non reproduit pour les blocs SCORM) -
attempts_countinitialisé à 1 et jamais incrémenté → toujours affiché à 1 - SCORM 2004 : les interactions ne sont pas postées au backend
Les quiz natifs sont construits sur quatre modèles :
| Modèle | Table | Rôle |
|---|---|---|
QuizQuestion |
quiz_questions |
Question avec type, médias, objectifs |
QuizOption |
quiz_options |
Options de réponse |
QuizAttempt |
quiz_attempts |
Tentative d'un utilisateur sur un quiz |
QuizAttemptQuestion |
quiz_attempt_questions |
Réponse par question avec temps et résultat |
- Choix unique
- Choix multiple
- Vrai/Faux
- Texte libre
- Cloze (texte à trous)
- Médias (image, audio) sur la question et les options
-
image_altetaudio_transcriptpour l'accessibilité
La banque de questions est disponible côté admin (Backend\QuizQuestionController) et côté formateur, pour ses propres leçons, depuis "Outils numériques" (tuile "Banque de questions", formateur.outils.quiz-questions.index) ou directement depuis l'onglet "Quiz" de la leçon (bouton "Gérer les questions"). Les deux contrôleurs (admin/formateur) délèguent la validation, la construction des options/payload cloze et l'import CSV au service partagé App\Services\QuizQuestionBuilder, pour éviter la duplication. Les questions créées par un formateur sont tracées via quiz_questions.created_by.
Géré par QuizService :
- Score par question et score global
- Temps par question (
quiz_attempt_questions.time_spent_seconds) - Historique des tentatives avec approche "meilleure tentative" valorisée
- Seuil de réussite configurable par module
Contrairement au SCORM, les quiz natifs alimentent complètement les tableaux de bord :
- Réponses détaillées par question
- Temps de réflexion moyen
- Identification des questions les plus échouées
- Distinction première tentative / meilleure tentative
La progression d'un stagiaire est multi-source, chaque format ayant sa propre table :
Quiz natif → quiz_attempts + quiz_attempt_questions (fiable, complet)
SCORM 1.2 → scorm_scores (scores, statut, temps) (partiel — interactions vides)
SCORM 2004 → scorm_scores (scores, statut) (très partiel — temps et interactions vides)
Vidéo → video_segment_trackings (segmenté)
Manuel → progressions (validation manuelle, sans preuve pédagogique)
Le service LearningAnalyticsService agrège toutes ces sources en un snapshot unifié par paire (user_id, lecture_id).