feat: quiz bank export to grist
This commit is contained in:
@@ -0,0 +1,500 @@
|
|||||||
|
{
|
||||||
|
"courses": {
|
||||||
|
"3": {
|
||||||
|
"quizzes": [
|
||||||
|
{
|
||||||
|
"quiz_n": 1,
|
||||||
|
"cmid": 51,
|
||||||
|
"name": "Quiz 1 : Découverte",
|
||||||
|
"gradepass": 60,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": 106,
|
||||||
|
"text": "<p>Dans quel ordre s'effectuent les étapes de conception dans KiCad ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 421,
|
||||||
|
"text": "Schéma → Attribution des empreintes → PCB Layout → Fichiers Gerber"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 422,
|
||||||
|
"text": "PCB Layout → Schéma → Fichiers Gerber → Attribution des empreintes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 423,
|
||||||
|
"text": "Attribution des empreintes → Schéma → PCB Layout → Fichiers Gerber"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 424,
|
||||||
|
"text": "Schéma → PCB Layout → Attribution des empreintes → Fichiers Gerber"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 107,
|
||||||
|
"text": "<p>Qu'est-ce qu'une <strong>netlist</strong> dans KiCad ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 425,
|
||||||
|
"text": "Un fichier décrivant tous les nœuds électriques (nets) et les connexions entre composants"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 426,
|
||||||
|
"text": "La liste des composants avec leurs prix"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 427,
|
||||||
|
"text": "Le fichier envoyé directement au fabricant de PCB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 428,
|
||||||
|
"text": "La liste des couches PCB actives"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 108,
|
||||||
|
"text": "<p>Quelle est la différence entre un <strong>symbole</strong> et un <strong>footprint (empreinte)</strong> dans KiCad ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 429,
|
||||||
|
"text": "Le symbole représente le composant dans le schéma électrique (logique) ; le footprint représente ses pastilles physiques sur le PCB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 430,
|
||||||
|
"text": "Le symbole est en 3D ; le footprint est en 2D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 431,
|
||||||
|
"text": "Le symbole est pour les composants SMD ; le footprint pour les composants traversants"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 432,
|
||||||
|
"text": "C'est la même chose, juste deux noms différents dans KiCad"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 109,
|
||||||
|
"text": "<p>Quel outil KiCad vérifie les erreurs dans le <strong>schéma électrique</strong> (connexions manquantes, conflits de type de pin) ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 433,
|
||||||
|
"text": "ERC (Electrical Rules Check)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 434,
|
||||||
|
"text": "DRC (Design Rules Check)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 435,
|
||||||
|
"text": "La visionneuse 3D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 436,
|
||||||
|
"text": "Le gestionnaire de bibliothèques"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 110,
|
||||||
|
"text": "<p>Dans KiCad, que sont les fichiers <strong>Gerber</strong> ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 437,
|
||||||
|
"text": "Un format de fichier standard décrivant chaque couche du PCB pour la fabrication industrielle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 438,
|
||||||
|
"text": "Les fichiers de sauvegarde automatique de KiCad"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 439,
|
||||||
|
"text": "Les modèles 3D des composants"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 440,
|
||||||
|
"text": "Les bibliothèques de symboles KiCad"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quiz_n": 2,
|
||||||
|
"cmid": 52,
|
||||||
|
"name": "Quiz 2 : Schématique",
|
||||||
|
"gradepass": 60,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": 111,
|
||||||
|
"text": "<p>Que signifie <strong>ERC</strong> dans KiCad et que vérifie-t-il ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 441,
|
||||||
|
"text": "Electrical Rule Check — vérifie la cohérence logique du schéma (connexions manquantes, conflits de type de pin)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 442,
|
||||||
|
"text": "Electronic Routing Check — vérifie le routage des pistes sur le PCB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 443,
|
||||||
|
"text": "External Reference Compiler — compile les références des composants externes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 444,
|
||||||
|
"text": "Edge Resistance Calculator — calcule la résistance des pistes de bord"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 112,
|
||||||
|
"text": "<p>Pourquoi faut-il ajouter des symboles <strong>PWR_FLAG</strong> dans le schéma KiCad ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 445,
|
||||||
|
"text": "Pour indiquer à l'ERC qu'un net d'alimentation est intentionnellement alimenté, évitant l'erreur \"Power pin not driven\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 446,
|
||||||
|
"text": "Pour limiter le courant sur les rails de puissance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 447,
|
||||||
|
"text": "Pour générer automatiquement les plans de masse dans le PCB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 448,
|
||||||
|
"text": "Pour attribuer automatiquement les footprints aux composants de puissance"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 113,
|
||||||
|
"text": "<p>Quel est l'avantage d'utiliser des <strong>net labels</strong> plutôt que des fils directs pour connecter des composants éloignés dans le schéma ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 449,
|
||||||
|
"text": "Le schéma reste lisible sans fils qui traversent toute la feuille — deux labels identiques = connexion électrique identique"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 450,
|
||||||
|
"text": "Les net labels réduisent la résistance des connexions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 451,
|
||||||
|
"text": "Les net labels sont obligatoires pour passer l'ERC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 452,
|
||||||
|
"text": "Les net labels accélèrent le routage automatique du PCB"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 114,
|
||||||
|
"text": "<p>Pourquoi mettre une résistance de 330Ω en série avec une LED alimentée en 3.3V ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 453,
|
||||||
|
"text": "Pour limiter le courant à environ 4 mA et protéger la LED — calcul : (3.3V - 2.0V) / 330Ω ≈ 4mA"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 454,
|
||||||
|
"text": "Pour augmenter la luminosité de la LED"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 455,
|
||||||
|
"text": "Pour filtrer le bruit haute fréquence sur la ligne d'alimentation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 456,
|
||||||
|
"text": "C'est optionnel, on peut connecter la LED directement au GPIO de l'ESP32"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 115,
|
||||||
|
"text": "<p>Pourquoi faut-il des résistances de 5.1kΩ sur les broches <strong>CC1/CC2</strong> du connecteur USB-C ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 457,
|
||||||
|
"text": "Elles identifient l'appareil comme un \"sink\" USB-C (consommateur d'énergie) — sans elles, le port USB ne fournit pas de courant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 458,
|
||||||
|
"text": "Elles protègent l'ESP32 contre les surtensions USB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 459,
|
||||||
|
"text": "Elles permettent la communication de données USB (D+/D-)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 460,
|
||||||
|
"text": "Elles sont optionnelles — le connecteur fonctionne sans elles"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quiz_n": 3,
|
||||||
|
"cmid": 53,
|
||||||
|
"name": "Quiz 3 : PCB",
|
||||||
|
"gradepass": 60,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": 116,
|
||||||
|
"text": "<p>Pourquoi utilise-t-on des pistes plus larges (0.5mm) pour les rails d'alimentation VIN et +3V3 ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 461,
|
||||||
|
"text": "Les pistes plus larges ont une résistance plus faible et supportent plus de courant sans échauffement"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 462,
|
||||||
|
"text": "C'est purement esthétique pour distinguer visuellement les pistes de puissance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 463,
|
||||||
|
"text": "Les pistes plus larges transmettent le signal plus rapidement"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 464,
|
||||||
|
"text": "C'est obligatoire pour passer le DRC de JLCPCB"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 117,
|
||||||
|
"text": "<p>Qu'est-ce qu'un <strong>via</strong> dans KiCad et quand l'utilise-t-on ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 465,
|
||||||
|
"text": "Un trou métallisé qui connecte la couche dessus (F.Cu) à la couche dessous (B.Cu) — utilisé quand deux pistes doivent se croiser"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 466,
|
||||||
|
"text": "Un trou pour insérer un composant traversant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 467,
|
||||||
|
"text": "Un point de test pour mesurer la tension"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 468,
|
||||||
|
"text": "Un trou de montage pour visser la carte dans un boîtier"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 118,
|
||||||
|
"text": "<p>Quels sont les avantages d'un plan de masse (copper pour GND) sur la couche B.Cu ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 469,
|
||||||
|
"text": "Meilleur retour de courant GND, réduction des interférences EMI, et meilleure dissipation thermique"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 470,
|
||||||
|
"text": "Il sert uniquement à remplir l'espace vide pour économiser le cuivre de gravure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 471,
|
||||||
|
"text": "Il est obligatoire pour tout PCB 2 couches selon la norme IPC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 472,
|
||||||
|
"text": "Il remplace les condensateurs de découplage et simplifie le schéma"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 119,
|
||||||
|
"text": "<p>Pourquoi la valeur de <strong>clearance</strong> (espacement minimum entre conducteurs) est-elle importante pour la fabrication ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 473,
|
||||||
|
"text": "Un espacement insuffisant peut causer des courts-circuits lors de la fabrication à cause des tolérances de gravure"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 474,
|
||||||
|
"text": "C'est purement esthétique, ça n'affecte pas la fabrication"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 475,
|
||||||
|
"text": "Plus la clearance est grande, plus la carte conduit bien le courant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 476,
|
||||||
|
"text": "La clearance ne s'applique qu'aux composants traversants, pas aux SMD"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 120,
|
||||||
|
"text": "<p>Que signifie le code \"0805\" pour un composant SMD et pourquoi le choisit-on pour un maker débutant ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 477,
|
||||||
|
"text": "Dimensions 0.08\" × 0.05\" (soit 2.0mm × 1.25mm) — le 0805 est le plus facile à souder à la main parmi les SMD courants"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 478,
|
||||||
|
"text": "C'est le numéro de série du fabricant YAGEO"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 479,
|
||||||
|
"text": "La puissance maximale du composant (805 mW)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 480,
|
||||||
|
"text": "La tolérance de valeur (8.05%)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"quiz_n": 4,
|
||||||
|
"cmid": 54,
|
||||||
|
"name": "Quiz 4 : Fabrication",
|
||||||
|
"gradepass": 60,
|
||||||
|
"questions": [
|
||||||
|
{
|
||||||
|
"id": 121,
|
||||||
|
"text": "<p>Pour un PCB 2 couches standard, combien de fichiers Gerber minimum faut-il générer (hors fichier de perçage) ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 481,
|
||||||
|
"text": "7 fichiers : F.Cu, B.Cu, F.Mask, B.Mask, F.SilkS, B.SilkS, Edge.Cuts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 482,
|
||||||
|
"text": "2 fichiers : un pour le dessus, un pour le dessous"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 483,
|
||||||
|
"text": "1 seul fichier ZIP contenant toutes les couches"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 484,
|
||||||
|
"text": "3 fichiers : cuivre dessus, cuivre dessous, contour"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 122,
|
||||||
|
"text": "<p>Quel format est utilisé pour le <strong>fichier de perçage</strong> (drill file) dans KiCad ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 485,
|
||||||
|
"text": "Excellon (.drl) — le standard industriel pour les données de perçage"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 486,
|
||||||
|
"text": "Gerber (.gbr) — le même format que les couches cuivre"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 487,
|
||||||
|
"text": "G-code (.nc) — le format des fraiseuses CNC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 488,
|
||||||
|
"text": "DXF (.dxf) — le format de dessin CAO"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 123,
|
||||||
|
"text": "<p>Que contient une <strong>BOM</strong> (Bill of Materials) et à quoi sert-elle ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 489,
|
||||||
|
"text": "La liste complète des composants avec références, valeurs, footprints et quantités — c'est la \"liste de courses\" pour assembler la carte"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 490,
|
||||||
|
"text": "Les instructions de routage des pistes cuivrées"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 491,
|
||||||
|
"text": "Les résultats du DRC avec les erreurs trouvées"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 492,
|
||||||
|
"text": "Le schéma de câblage pour souder les composants à la main"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 124,
|
||||||
|
"text": "<p>Quelle finition de surface est la plus <strong>économique</strong> pour un prototype et pourquoi ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 493,
|
||||||
|
"text": "HASL (Hot Air Solder Leveling) — étain fondu nivelé à l'air chaud, le traitement le moins cher"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 494,
|
||||||
|
"text": "ENIG (Electroless Nickel Immersion Gold) — dorure, la plus répandue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 495,
|
||||||
|
"text": "OSP (Organic Solderability Preservative) — le plus durable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 496,
|
||||||
|
"text": "Immersion Silver — le plus commun chez JLCPCB"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 125,
|
||||||
|
"text": "<p>Quel est le coût approximatif pour fabriquer <strong>5 exemplaires</strong> d'un PCB 2 couches 50×30mm chez JLCPCB (hors livraison) ?</p>",
|
||||||
|
"single": 1,
|
||||||
|
"choices": [
|
||||||
|
{
|
||||||
|
"id": 497,
|
||||||
|
"text": "Environ 2$ (deux dollars) — JLCPCB propose les petits PCB standards à des prix très bas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 498,
|
||||||
|
"text": "Environ 50$ — la fabrication PCB est un processus industriel coûteux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 499,
|
||||||
|
"text": "Environ 20$ par carte individuelle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 500,
|
||||||
|
"text": "C'est gratuit pour les designs de moins de 100×100mm"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -266,3 +266,62 @@ curl -s "https://moodle.saillant.cc/webservice/rest/server.php" \
|
|||||||
--data-urlencode "newstate=$STATE"; echo; done
|
--data-urlencode "newstate=$STATE"; echo; done
|
||||||
# expected: {"cmid":27,"userid":3,"state":<STATE>,...,"overrideby":<wssync id>}
|
# expected: {"cmid":27,"userid":3,"state":<STATE>,...,"overrideby":<wssync id>}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## SP3 — Quiz bank export → Grist (`QuizBank`)
|
||||||
|
|
||||||
|
### export-quiz-bank.php (read-only)
|
||||||
|
|
||||||
|
Dumps the whole quiz question bank (all courses id > 1) as ONE JSON object
|
||||||
|
on stdout. Pure `$DB` reads, never writes. Moodle 4.5 schema chain (verified
|
||||||
|
against the live DB — `quiz_slots` no longer carries `questionid`):
|
||||||
|
|
||||||
|
```
|
||||||
|
quiz_slots.id
|
||||||
|
→ question_references (component='mod_quiz', questionarea='slot',
|
||||||
|
itemid = slot.id)
|
||||||
|
→ questionbankentryid
|
||||||
|
→ question_versions (MAX(version); reference.version is NULL = "always latest")
|
||||||
|
→ question (+ qtype_multichoice_options.single, question_answers)
|
||||||
|
```
|
||||||
|
|
||||||
|
`gradepass` comes from `grade_items` (`itemmodule='quiz'`,
|
||||||
|
`iteminstance=quiz.id`) and may be 0/NULL. `correct = fraction > 0` on
|
||||||
|
`question_answers`. Quizzes are ordered by name ("Quiz N : …" = module
|
||||||
|
order); `quiz_n` is parsed from the name.
|
||||||
|
|
||||||
|
### Re-export / re-push procedure (from Tower)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker cp ops/export-quiz-bank.php moodle:/tmp/export-quiz-bank.php
|
||||||
|
docker exec moodle php /tmp/export-quiz-bank.php > /tmp/quizbank.json
|
||||||
|
set -a; . /home/clems/formations-app.env; set +a
|
||||||
|
python3 ops/push-quiz-bank.py /tmp/quizbank.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`push-quiz-bank.py` is idempotent: it creates the `QuizBank` table
|
||||||
|
(AddTable apply) if missing; otherwise it deletes ALL rows and re-inserts.
|
||||||
|
Expected: 24 rows (6 courses × 4 quizzes), 5 questions per quiz.
|
||||||
|
|
||||||
|
### Grist table `QuizBank` (doc `bPfitC7Poc4r2xGRx7FgHL`)
|
||||||
|
|
||||||
|
| column | type | content |
|
||||||
|
|---|---|---|
|
||||||
|
| `course_slug` | Text | app slug (id→slug map mirrors `src/config/courses.ts`) |
|
||||||
|
| `quiz_n` | Int | 1..4, parsed from the quiz name |
|
||||||
|
| `cmid` | Int | quiz course-module id (completion override target) |
|
||||||
|
| `name` | Text | quiz name, e.g. "Quiz 1 : Découverte" |
|
||||||
|
| `gradepass` | Numeric | pass threshold in % (0 = unset → app defaults to 50 %) |
|
||||||
|
| `payload` | Text | JSON string `{"questions": [{id, text, single, choices: [{id, text, correct, feedback}]}]}` |
|
||||||
|
|
||||||
|
### ⚠️ Sanitization warning — repo is PUBLIC
|
||||||
|
|
||||||
|
The export and the Grist `payload` contain the **correct flags and the
|
||||||
|
feedback**. **Answers live ONLY in Grist** (API-key protected). Never
|
||||||
|
commit a raw export to this repository. The committed fixture
|
||||||
|
`fixtures/quizbank-3.json` (course 3, kicad-makers) is **sanitized**: every
|
||||||
|
`correct` and `feedback` key stripped recursively. Proof after any
|
||||||
|
regeneration (must print 0):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -c '"correct"\|"feedback"' fixtures/quizbank-3.json
|
||||||
|
```
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
// Read-only export of the Moodle quiz question bank as ONE JSON object on
|
||||||
|
// stdout, consumed by the Grist QuizBank push (SP3). Never writes to the DB.
|
||||||
|
// Run: docker cp ops/export-quiz-bank.php moodle:/tmp/ &&
|
||||||
|
// docker exec moodle php /tmp/export-quiz-bank.php > quizbank.json
|
||||||
|
//
|
||||||
|
// Moodle 4.5 schema chain (quiz_slots no longer carries questionid):
|
||||||
|
// quiz_slots.id -> question_references (component='mod_quiz',
|
||||||
|
// questionarea='slot', itemid=slot.id) -> questionbankentryid ->
|
||||||
|
// question_versions (latest version; qr.version NULL = "always latest")
|
||||||
|
// -> question. Multichoice flag in qtype_multichoice_options.single,
|
||||||
|
// choices in question_answers (fraction>0 = correct).
|
||||||
|
//
|
||||||
|
// Output shape:
|
||||||
|
// {courses: {"<courseid>": {quizzes: [{quiz_n, cmid, name, gradepass,
|
||||||
|
// questions: [{id, text, single, choices: [{id, text, correct,
|
||||||
|
// feedback}]}]}]}}}
|
||||||
|
//
|
||||||
|
// WARNING: the output contains the CORRECT flags and feedback — it must
|
||||||
|
// only ever land in Grist (API-key protected), NEVER in this public repo.
|
||||||
|
define('CLI_SCRIPT', true);
|
||||||
|
require('/var/www/html/config.php');
|
||||||
|
|
||||||
|
$quizmoduleid = $DB->get_field('modules', 'id', ['name' => 'quiz'], MUST_EXIST);
|
||||||
|
|
||||||
|
// Quizzes are named "Quiz N : ..." — name order is module order.
|
||||||
|
$quizzes = $DB->get_records_sql("
|
||||||
|
SELECT q.id, q.course, q.name, cm.id AS cmid
|
||||||
|
FROM {quiz} q
|
||||||
|
JOIN {course_modules} cm ON cm.instance = q.id AND cm.module = :mod
|
||||||
|
WHERE q.course > 1
|
||||||
|
ORDER BY q.course ASC, q.name ASC", ['mod' => $quizmoduleid]);
|
||||||
|
|
||||||
|
$out = ['courses' => []];
|
||||||
|
|
||||||
|
foreach ($quizzes as $quiz) {
|
||||||
|
$gradepass = $DB->get_field('grade_items', 'gradepass',
|
||||||
|
['itemmodule' => 'quiz', 'iteminstance' => $quiz->id]);
|
||||||
|
$gradepass = $gradepass ? (float)$gradepass : 0.0; // may be 0/NULL
|
||||||
|
|
||||||
|
$quizn = 0;
|
||||||
|
if (preg_match('/^Quiz\s+(\d+)/u', $quiz->name, $m)) {
|
||||||
|
$quizn = (int)$m[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$slotrows = $DB->get_records_sql("
|
||||||
|
SELECT qs.id AS slotid, qs.slot, qv.questionid
|
||||||
|
FROM {quiz_slots} qs
|
||||||
|
JOIN {question_references} qr
|
||||||
|
ON qr.component = 'mod_quiz'
|
||||||
|
AND qr.questionarea = 'slot'
|
||||||
|
AND qr.itemid = qs.id
|
||||||
|
JOIN {question_versions} qv
|
||||||
|
ON qv.questionbankentryid = qr.questionbankentryid
|
||||||
|
AND qv.version = (SELECT MAX(v.version)
|
||||||
|
FROM {question_versions} v
|
||||||
|
WHERE v.questionbankentryid
|
||||||
|
= qr.questionbankentryid)
|
||||||
|
WHERE qs.quizid = :quizid
|
||||||
|
ORDER BY qs.slot ASC", ['quizid' => $quiz->id]);
|
||||||
|
|
||||||
|
$questions = [];
|
||||||
|
foreach ($slotrows as $row) {
|
||||||
|
$question = $DB->get_record('question',
|
||||||
|
['id' => $row->questionid], '*', MUST_EXIST);
|
||||||
|
if ($question->qtype !== 'multichoice') {
|
||||||
|
fwrite(STDERR, "skip q{$question->id}: qtype "
|
||||||
|
. "{$question->qtype} (only multichoice supported)\n");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$opts = $DB->get_record('qtype_multichoice_options',
|
||||||
|
['questionid' => $question->id], '*', MUST_EXIST);
|
||||||
|
$answers = $DB->get_records('question_answers',
|
||||||
|
['question' => $question->id], 'id ASC');
|
||||||
|
|
||||||
|
$choices = [];
|
||||||
|
foreach ($answers as $a) {
|
||||||
|
$choices[] = [
|
||||||
|
'id' => (int)$a->id,
|
||||||
|
'text' => $a->answer,
|
||||||
|
'correct' => (float)$a->fraction > 0,
|
||||||
|
'feedback' => $a->feedback,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$questions[] = [
|
||||||
|
'id' => (int)$question->id,
|
||||||
|
'text' => $question->questiontext,
|
||||||
|
'single' => (int)$opts->single,
|
||||||
|
'choices' => $choices,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$courseid = (string)$quiz->course;
|
||||||
|
if (!isset($out['courses'][$courseid])) {
|
||||||
|
$out['courses'][$courseid] = ['quizzes' => []];
|
||||||
|
}
|
||||||
|
$out['courses'][$courseid]['quizzes'][] = [
|
||||||
|
'quiz_n' => $quizn,
|
||||||
|
'cmid' => (int)$quiz->cmid,
|
||||||
|
'name' => $quiz->name,
|
||||||
|
'gradepass' => $gradepass,
|
||||||
|
'questions' => $questions,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
echo json_encode($out,
|
||||||
|
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT), "\n";
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Push the Moodle quiz-bank export into Grist table QuizBank (SP3).
|
||||||
|
|
||||||
|
Usage (on Tower, after running ops/export-quiz-bank.php):
|
||||||
|
set -a; . /home/clems/formations-app.env; set +a
|
||||||
|
python3 ops/push-quiz-bank.py /tmp/quizbank.json
|
||||||
|
|
||||||
|
Reads GRIST_API_KEY and GRIST_DOC_ID from the environment
|
||||||
|
(GRIST_URL optional, defaults to https://grist.saillant.cc).
|
||||||
|
|
||||||
|
Idempotent: creates the QuizBank table if missing (AddTable apply, as
|
||||||
|
SP2 did); if it exists, deletes ALL rows then re-inserts. One row per
|
||||||
|
quiz; `payload` is the JSON string {"questions": [...]} INCLUDING the
|
||||||
|
correct flags and feedback — answers live ONLY in Grist, never in the
|
||||||
|
public repo.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
# Moodle course id -> app slug (mirrors src/config/courses.ts)
|
||||||
|
ID_TO_SLUG = {
|
||||||
|
2: "esp32-ia",
|
||||||
|
3: "kicad-makers",
|
||||||
|
4: "llm-locaux",
|
||||||
|
5: "freertos",
|
||||||
|
6: "iot-az",
|
||||||
|
7: "docker-selfhosting",
|
||||||
|
}
|
||||||
|
|
||||||
|
COLUMNS = [
|
||||||
|
{"id": "course_slug", "type": "Text"},
|
||||||
|
{"id": "quiz_n", "type": "Int"},
|
||||||
|
{"id": "cmid", "type": "Int"},
|
||||||
|
{"id": "name", "type": "Text"},
|
||||||
|
{"id": "gradepass", "type": "Numeric"},
|
||||||
|
{"id": "payload", "type": "Text"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def api(base, key, path, payload=None, method=None):
|
||||||
|
req = urllib.request.Request(
|
||||||
|
base + path,
|
||||||
|
data=json.dumps(payload).encode() if payload is not None else None,
|
||||||
|
headers={
|
||||||
|
"Authorization": "Bearer " + key,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
method=method or ("POST" if payload is not None else "GET"),
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return json.loads(resp.read() or b"null")
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
key = os.environ["GRIST_API_KEY"]
|
||||||
|
doc = os.environ["GRIST_DOC_ID"]
|
||||||
|
url = os.environ.get("GRIST_URL", "https://grist.saillant.cc").rstrip("/")
|
||||||
|
base = f"{url}/api/docs/{doc}"
|
||||||
|
bank = json.load(open(sys.argv[1] if len(sys.argv) > 1
|
||||||
|
else "/tmp/quizbank.json"))
|
||||||
|
|
||||||
|
tables = [t["id"] for t in api(base, key, "/tables")["tables"]]
|
||||||
|
if "QuizBank" not in tables:
|
||||||
|
api(base, key, "/apply", [["AddTable", "QuizBank", COLUMNS]])
|
||||||
|
print("table QuizBank created")
|
||||||
|
else:
|
||||||
|
ids = [r["id"] for r in
|
||||||
|
api(base, key, "/tables/QuizBank/records")["records"]]
|
||||||
|
if ids:
|
||||||
|
api(base, key, "/tables/QuizBank/data/delete", ids)
|
||||||
|
print(f"table QuizBank exists, {len(ids)} rows deleted")
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for cid_str, course in sorted(bank["courses"].items(), key=lambda kv: int(kv[0])):
|
||||||
|
slug = ID_TO_SLUG[int(cid_str)]
|
||||||
|
for quiz in course["quizzes"]:
|
||||||
|
records.append({"fields": {
|
||||||
|
"course_slug": slug,
|
||||||
|
"quiz_n": quiz["quiz_n"],
|
||||||
|
"cmid": quiz["cmid"],
|
||||||
|
"name": quiz["name"],
|
||||||
|
"gradepass": quiz["gradepass"],
|
||||||
|
"payload": json.dumps({"questions": quiz["questions"]},
|
||||||
|
ensure_ascii=False),
|
||||||
|
}})
|
||||||
|
|
||||||
|
api(base, key, "/tables/QuizBank/records", {"records": records})
|
||||||
|
n = len(api(base, key, "/tables/QuizBank/records")["records"])
|
||||||
|
print(f"inserted {len(records)} rows, table now has {n} rows")
|
||||||
|
if n != len(records):
|
||||||
|
sys.exit(f"row count mismatch: expected {len(records)}, got {n}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user