Lire les quiz édités dans Odoo (eLearning/ website_slides)
& de les afficher dans votre clone ChatGPT (React),
Oui—c’est parfaitement envisageable, et proprement. L’idée est de lire vos quiz Odoo (eLearning / website_slides) via RPC, de les afficher dans votre clone ChatGPT (React), puis de laisser l’utilisateur dialoguer à l’oral pour demander des explications sur ses réponses, tout en renvoyant la tentative à Odoo pour l’évaluation et le suivi.
Architecture en 7 points (simple & robuste)
Front React (clone ChatGPT)
Store: quiz, attempt, feedback.
UI: zone de tchat + carte « question en cours » (MCQ / vrai-faux / texte libre).
Flux:
a) GetData → charge le quiz (par slide_id ou channel_id).
b) L’utilisateur répond (voix/texte).
c) POST Attempt → récupère score & feedback.
d) Le bot explique (LLM) : « pourquoi c’est correct/incorrect » et peut pousser médias pertinents (vidéos, fiches Odoo).
Source des données (Odoo)
- Modèles clés (noms usuels) : slide.channel (cours), slide.slide (contenu/quiz), slide.question (questions), slide.answer (réponses).
-
Exposez deux services (RPC natif ou un petit contrôleur REST custom pour simplifier CORS) :
- GET Quiz (lecture) → renvoie: slide_id, title, questions: [{id, type, text, choices:[{id, text}]}]. (⚠️ ne JAMAIS renvoyer les bonnes réponses côté client).
- POST Attempt (évaluation) → reçoit {slide_id, answers:[{question_id, choice_id|text}]} et renvoie {score, corrections, feedback_by_question}. Côté Odoo, appelez les méthodes internes d’évaluation et logguez la tentative (traçabilité eLearning).
Sécurité & aut
- Idéal: token (API Key) + controller REST → évite la complexité CSRF/CORS des endpoints RPC bruts.
- Sinon: /jsonrpc avec session (login/mot de passe technique) et CORS configuré.
Voix (entrée/sortie)
- Web: Web Speech API (SpeechRecognition + TTS) pour un POC 100% navigateur.
- Qualité pro: ASR/TTS cloud (latence faible + meilleure diacritique FR, malgache, etc.).
- Gestion multi-tours: détection de fin de parole + barge-in (stop TTS si l’utilisateur parle).
Explication des réponses (votre “avantage”)
- Après l’évaluation Odoo, vous composez un prompt : contexte (question, réponse de l’utilisateur, bonne réponse, extrait du cours/documentation), puis générez une explication courte + possibilité « approfondir » → liens Odoo Learning, vidéos internes, PDF techniques.
Journalisation & analytics
- Côté Odoo : conservez les tentatives, pour progression/achèvement.
- Côté app : événements (temps de réponse, hésitations, demandes d’aide) → tableau de bord formateur.
RGPD & conformité
- Minimiser les données personnelles côté app.
- Conserver les scores/feedback dans Odoo (source of truth).
.
ANNEXES TECHNIQUES
Exemples d’appels (concrets)
A. JSON-RPC (lecture quiz) — minimal
// GET quiz by slide_id async function getQuiz({odooUrl, db, username, password, slideId}) { const body = { jsonrpc: "2.0", method: "call", params: { service: "object", method: "execute_kw", args: [ db, /* uid */ 2, password, "slide.slide", "read", [[slideId]], {fields: ["id","name","question_ids"]} ] }, id: Date.now() }; const res = await fetch(`${odooUrl}/jsonrpc`, { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(body) }); const {result} = await res.json(); // Ensuite: lire les questions & choix // slide.question: fields ["id","question","question_type","answer_ids"] // slide.answer: fields ["id","text"] }
En pratique, pour éviter plusieurs allers-retours, implémentez un contrôleur REST Odoo (/quiz/<slide_id>) qui agrège et retourne directement {title, questions:[...choices...]} sans révéler les bonnes réponses.
B. REST custom Odoo (recommandé)
# Odoo controller (extrait) @http.route("/api/quiz/<int:slide_id>", type="json", auth="user", csrf=False) def get_quiz(slide_id): slide = request.env["slide.slide"].browse(slide_id).sudo() q_list = [] for q in slide.question_ids: q_list.append({ "id": q.id, "type": q.question_type, # 'simple_choice', 'multiple_choice', 'text', etc. "text": q.question, "choices": [{"id": a.id, "text": a.text} for a in q.answer_ids], }) return {"slide_id": slide.id, "title": slide.name, "questions": q_list}
C. Soumettre une tentative (évaluation côté Odoo)
@http.route("/api/quiz/attempt", type="json", auth="user", csrf=False) def post_attempt(): payload = request.jsonrequest # {slide_id, answers:[{question_id, answer_ids or text}]} slide = request.env["slide.slide"].browse(payload["slide_id"]).sudo() # appeler les méthodes d’évaluation internes (selon votre version) # puis créer un log de tentative associé à l'utilisateur courant score = ... # calculé via Odoo feedback = ... # par question (correct/incorrect, explication courte) return {"score": score, "feedback": feedback}
Squelette React (super léger)
function QuizChat({slideId}) { const [quiz, setQuiz] = useState(null); const [answers, setAnswers] = useState({}); const [feedback, setFeedback] = useState(null); const [messages, setMessages] = useState([]); useEffect(() => { fetch(`/api/quiz/${slideId}`, {method:"POST", headers:{'Content-Type':'application/json'}, body: JSON.stringify({})}) .then(r=>r.json()).then(setQuiz); }, [slideId]); const submit = async () => { const res = await fetch(`/api/quiz/attempt`, { method:"POST", headers:{'Content-Type':'application/json'}, body: JSON.stringify({slide_id: quiz.slide_id, answers: toAttemptPayload(answers)}) }); const data = await res.json(); setFeedback(data); // Ici: appeler votre LLM pour générer l’explication dialoguée question par question // setMessages([...messages, {role:"assistant", content: explication}]); }; return ( <div className="chat-like"> {/* Messages (bot & user) + composant Voix */} {/* Carte question courante + choix */} <button onClick={submit}>Valider</button> {feedback && <ScoreCard score={feedback.score} details={feedback.feedback}/>} </div> ); }
Points d’attention (pour que ça “marche du premier coup”)
- Version Odoo : les noms de modèles/méthodes varient légèrement (v15–17). Un petit contrôleur REST agrégateur vous met à l’abri de ces détails côté frontend.
- CORS : autorisez votre domaine React.
- Pas de fuite des bonnes réponses : elles restent côté serveur ; seul le feedback est renvoyé.
- Multilingue : servez les libellés dans la langue de l’utilisateur (context={'lang':'fr_FR' | 'mg_MG'} côté Odoo).
- Accessibilité voix : prévoyez fallback clavier + sous-titres.
ANNEXES : POC complet prêt à coller : un contrôleur REST Odoo (lecture du quiz + soumission de tentative) et un front React (hook + composant chat avec voix)
voici un POC complet prêt à coller : un contrôleur REST Odoo (lecture du quiz + soumission de tentative) et un front React (hook + composant chat avec voix). Objectif : afficher vos quiz Odoo dans une UI type ChatGPT, évaluer côté Odoo, puis expliquer à l’oral les bonnes/mauvaises réponses.
1) Odoo — contrôleur REST (Python)
Placez ce code dans un module custom, p.ex. odoo_quiz_api/controllers/main.py.
Il expose:
- POST /api/quiz/<slide_id> → lit le quiz sans divulguer les bonnes réponses
- POST /api/quiz/attempt → évalue et retourne score + feedback par question
- Auth via X-API-Key (à stocker dans ir.config_parameter)
# -*- coding: utf-8 -*- from odoo import http, _ from odoo.http import request API_PARAM_KEY = "quiz_api.key" # ir.config_parameter key def _check_api_key(): cfg_key = request.env["ir.config_parameter"].sudo().get_param(API_PARAM_KEY) hdr_key = request.httprequest.headers.get("X-API-Key") if not cfg_key or not hdr_key or hdr_key != cfg_key: return False return True def _cors_headers(): # Ajustez le domaine si besoin (en prod, fixez une whitelist précise) return { "Access-Control-Allow-Origin": request.httprequest.headers.get("Origin") or "*", "Access-Control-Allow-Headers": "Content-Type, X-API-Key", "Access-Control-Allow-Methods": "POST, OPTIONS", } class QuizApiController(http.Controller): @http.route("/api/quiz/<int:slide_id>", type="json", auth="public", csrf=False, methods=["POST","OPTIONS"]) def get_quiz(self, slide_id, **kwargs): # CORS preflight if request.httprequest.method == "OPTIONS": return request.make_response("", headers=_cors_headers()) if not _check_api_key(): return request.make_response( {"error": "Unauthorized"}, headers=_cors_headers(), status=401 ) # Contexte de langue (facultatif) lang = (kwargs or {}).get("lang") or request.context.get("lang") or "fr_FR" with request.env.cr.savepoint(): slide = request.env["slide.slide"].with_context(lang=lang).sudo().browse(slide_id) if not slide.exists(): return request.make_response({"error": "Slide not found"}, headers=_cors_headers(), status=404) # On ne renvoie que l'énoncé + choix, JAMAIS la solution questions = [] for q in slide.question_ids: choices = [{"id": a.id, "text": a.text} for a in q.answer_ids] questions.append({ "id": q.id, "type": q.question_type, # simple_choice, multiple_choice, text "text": q.question, "choices": choices if q.question_type != "text" else [], }) payload = { "slide_id": slide.id, "title": slide.name, "channel_id": slide.channel_id.id if slide.channel_id else None, "questions": questions, } return request.make_response(payload, headers=_cors_headers(), status=200) @http.route("/api/quiz/attempt", type="json", auth="public", csrf=False, methods=["POST","OPTIONS"]) def post_attempt(self, **kwargs): # CORS preflight if request.httprequest.method == "OPTIONS": return request.make_response("", headers=_cors_headers()) if not _check_api_key(): return request.make_response( {"error": "Unauthorized"}, headers=_cors_headers(), status=401 ) data = request.jsonrequest or {} slide_id = data.get("slide_id") answers = data.get("answers", []) # [{question_id, answer_ids:[...]}] ou {question_id, text:"..."} lang = data.get("lang") or request.context.get("lang") or "fr_FR" if not slide_id or not isinstance(answers, list): return request.make_response({"error": "Invalid payload"}, headers=_cors_headers(), status=400) slide = request.env["slide.slide"].with_context(lang=lang).sudo().browse(slide_id) if not slide.exists(): return request.make_response({"error": "Slide not found"}, headers=_cors_headers(), status=404) # Évaluation locale (sans rien divulguer) # Hypothèse: slide.answer a le booléen 'is_correct' ; pour 'text', on valide par mots-clés simples (exemple POC). q_map = {q.id: q for q in slide.question_ids} correct_count = 0 detailed = [] for item in answers: qid = item.get("question_id") q = q_map.get(qid) if not q: continue result = {"question_id": qid, "status": "incorrect", "explanation": ""} if q.question_type in ("simple_choice", "multiple_choice"): submitted = set(item.get("answer_ids", [])) correct_ids = set(a.id for a in q.answer_ids if getattr(a, "is_correct", False)) if submitted and submitted == correct_ids: result["status"] = "correct" correct_count += 1 # Mini feedback neutre (sans révéler explicitement les bonnes réponses) result["explanation"] = _("Vérifiez les notions du cours liées à cette question.") else: # question_type == 'text' : POC très basique (à remplacer par logique métier) text = (item.get("text") or "").strip().lower() # Heuristique POC : si longueur > 5, on considère "partiellement correct" if len(text) > 5: result["status"] = "partial" result["explanation"] = _("Relisez la définition dans le support du module.") detailed.append(result) total = len(q_map) score = round((correct_count / total) * 100, 1) if total else 0.0 # (Optionnel) journaliser la tentative côté eLearning (liaison utilisateur) # Vous pouvez créer un modèle 'slide.quiz.attempt' custom, ou utiliser les logs Odoo si dispo. # Exemple rapide (custom): # request.env["slide.quiz.attempt"].sudo().create({ # "slide_id": slide.id, # "score": score, # "details_json": json.dumps(detailed, ensure_ascii=False), # "partner_id": request.env.user.partner_id.id if request.env.user and request.env.user.id else False, # }) resp = { "score": score, "feedback": detailed, "message": _("Évaluation effectuée. Voulez-vous des explications détaillées question par question ?"), } return request.make_response(resp, headers=_cors_headers(), status=200)
Activation rapide :
# Dans Odoo > Paramètres > Paramètres système : # Clé: quiz_api.key # Valeur: VOTRE_TOKEN_LONG_ET_SECRET
CURL test :
curl -X POST https://votre-odoo.tld/api/quiz/42 \ -H 'Content-Type: application/json' -H 'X-API-Key: VOTRE_TOKEN' \ -d '{}' curl -X POST https://votre-odoo.tld/api/quiz/attempt \ -H 'Content-Type: application/json' -H 'X-API-Key: VOTRE_TOKEN' \ -d '{"slide_id":42,"answers":[{"question_id":101,"answer_ids":[1001]},{"question_id":102,"text":"ma réponse"}]}'
2) Front — React (hook + UI chat + voix)
Voix: Web Speech API (ASR+TTS) pour un POC navigateur.
UX: tchat + carte de question, validation, puis explications dialoguées.
a) Hook useQuizApi.ts
// useQuizApi.ts import { useState, useCallback } from "react"; type Choice = { id: number; text: string }; type Question = { id: number; type: "simple_choice"|"multiple_choice"|"text"; text: string; choices: Choice[] }; type Quiz = { slide_id: number; title: string; channel_id?: number; questions: Question[] }; export function useQuizApi(baseUrl: string, apiKey: string, lang = "fr_FR") { const [quiz, setQuiz] = useState<Quiz | null>(null); const [loading, setLoading] = useState(false); const [feedback, setFeedback] = useState<any>(null); const [error, setError] = useState<string | null>(null); const getQuiz = useCallback(async (slideId: number) => { setLoading(true); setError(null); try { const res = await fetch(`${baseUrl}/api/quiz/${slideId}`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": apiKey }, body: JSON.stringify({ lang }), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error || "Erreur chargement quiz"); setQuiz(data); } catch (e: any) { setError(e.message); } finally { setLoading(false); } }, [baseUrl, apiKey, lang]); const submitAttempt = useCallback(async (payload: { slide_id: number, answers: any[] }) => { setLoading(true); setError(null); try { const res = await fetch(`${baseUrl}/api/quiz/attempt`, { method: "POST", headers: { "Content-Type": "application/json", "X-API-Key": apiKey }, body: JSON.stringify({ ...payload, lang }), }); const data = await res.json(); if (!res.ok) throw new Error(data?.error || "Erreur évaluation"); setFeedback(data); return data; } catch (e: any) { setError(e.message); throw e; } finally { setLoading(false); } }, [baseUrl, apiKey, lang]); return { quiz, loading, error, feedback, getQuiz, submitAttempt, setFeedback }; }
b) Composant voix minimal useVoice.ts
// useVoice.ts export function useVoice() { const speak = (text: string) => { if (!("speechSynthesis" in window)) return; const utter = new SpeechSynthesisUtterance(text); // Laissez le navigateur choisir la voix par défaut (FR si dispo) window.speechSynthesis.cancel(); window.speechSynthesis.speak(utter); }; const listen = (onResult: (txt: string) => void) => { const SR = (window as any).webkitSpeechRecognition || (window as any).SpeechRecognition; if (!SR) return null; const rec = new SR(); rec.lang = "fr-FR"; rec.interimResults = false; rec.maxAlternatives = 1; rec.onresult = (e: any) => { const txt = e.results?.[0]?.[0]?.transcript || ""; onResult(txt); }; rec.start(); return rec; }; return { speak, listen }; }
c) UI « type ChatGPT » (très compact)
// QuizChat.tsx import React, { useEffect, useMemo, useState } from "react"; import { useQuizApi } from "./useQuizApi"; import { useVoice } from "./useVoice"; type AnswerMap = Record<number, { answer_ids?: number[]; text?: string }>; export default function QuizChat({ baseUrl, apiKey, slideId }: { baseUrl: string; apiKey: string; slideId: number }) { const { quiz, loading, error, feedback, getQuiz, submitAttempt, setFeedback } = useQuizApi(baseUrl, apiKey); const { speak, listen } = useVoice(); const [answers, setAnswers] = useState<AnswerMap>({}); const [messages, setMessages] = useState<{role:"user"|"assistant", content:string}[]>([ { role: "assistant", content: "Bonjour ! Voulez-vous commencer le quiz ?" } ]); useEffect(() => { getQuiz(slideId); }, [slideId, getQuiz]); const onPickChoice = (qid: number, choiceId: number, multiple: boolean) => { setAnswers(prev => { const prevQ = prev[qid] || {}; if (multiple) { const set = new Set(prevQ.answer_ids || []); set.has(choiceId) ? set.delete(choiceId) : set.add(choiceId); return { ...prev, [qid]: { answer_ids: Array.from(set) } }; } else { return { ...prev, [qid]: { answer_ids: [choiceId] } }; } }); }; const onFreeText = (qid: number, text: string) => { setAnswers(prev => ({ ...prev, [qid]: { text } })); }; const submit = async () => { if (!quiz) return; const payload = { slide_id: quiz.slide_id, answers: Object.entries(answers).map(([qid, v]) => ({ question_id: Number(qid), ...v })), }; const res = await submitAttempt(payload); const summary = `Score: ${res.score}%. Souhaitez-vous des explications détaillées ?`; setMessages(m => [...m, { role: "assistant", content: summary }]); speak(summary); }; const explainAll = async () => { if (!feedback || !quiz) return; const qsById = Object.fromEntries(quiz.questions.map(q => [q.id, q])); // Génération d’explications locales (POC). En prod, appelez votre LLM. const text = feedback.feedback.map((f: any) => { const q = qsById[f.question_id]; const status = f.status === "correct" ? "✅" : f.status === "partial" ? "🟡" : "❌"; return `${status} Q: ${q?.text}\n→ ${f.explanation}`; }).join("\n\n"); setMessages(m => [...m, { role: "assistant", content: text }]); speak("Je vous ai détaillé les explications à l’écran."); }; const current = useMemo(() => quiz?.questions || [], [quiz]); return ( <div className="w-full max-w-3xl mx-auto p-4 space-y-3"> <div className="rounded-2xl shadow p-4"> <div className="text-xl font-semibold mb-2">{quiz?.title || "Chargement..."}</div> {error && <div className="text-red-600">{error}</div>} {!quiz && loading && <div>Chargement…</div>} {current.map(q => ( <div key={q.id} className="border rounded-xl p-3 my-3"> <div className="font-medium mb-2">{q.text}</div> {q.type === "text" ? ( <textarea className="w-full border rounded p-2" placeholder="Réponse orale ou texte…" value={answers[q.id]?.text || ""} onChange={e => onFreeText(q.id, e.target.value)} /> ) : ( <div className="space-y-2"> {q.choices.map(c => { const multiple = q.type === "multiple_choice"; const selected = multiple ? (answers[q.id]?.answer_ids || []).includes(c.id) : (answers[q.id]?.answer_ids || [])[0] === c.id; return ( <button key={c.id} className={`w-full text-left border rounded p-2 ${selected ? "bg-black text-white" : ""}`} onClick={() => onPickChoice(q.id, c.id, multiple)} > {c.text} </button> ); })} </div> )} <div className="flex gap-2 mt-2"> <button className="border rounded px-3 py-1" onClick={() => { const rec = listen(txt => { onFreeText(q.id, txt); setMessages(m => [...m, { role: "user", content: txt }]); }); if (!rec) alert("Reconnaissance vocale non disponible."); }} > 🎤 Répondre à l’oral </button> </div> </div> ))} <div className="flex gap-2 mt-4"> <button className="border rounded px-4 py-2" disabled={loading || !quiz} onClick={submit}> ✅ Valider mes réponses </button> <button className="border rounded px-4 py-2" disabled={!feedback} onClick={explainAll}> 💬 Explanations </button> </div> </div> <div className="rounded-2xl shadow p-4"> <div className="font-semibold mb-2">Conversation</div> <div className="space-y-2"> {messages.map((m, i) => ( <div key={i} className={m.role === "assistant" ? "text-black" : "text-blue-700"}> <b>{m.role === "assistant" ? "Assistant" : "Vous"} :</b> {m.content} </div> ))} </div> </div> </div> ); }
3) Points clés d’implémentation
- Ne divulguez jamais les bonnes réponses dans l’API. L’évaluation reste serveur.
- Langues: passez context={'lang': 'fr_FR'|'mg_MG'} côté Odoo pour servir les libellés localisés.
- CORS: la route gère OPTIONS + headers. En prod, remplacez * par votre domaine React.
- Auth: X-API-Key simple et efficace pour un POC. En prod, envisagez OAuth2/JWT.
- Explications « premium »: dans explainAll(), remplacez la logique POC par un appel LLM + poussage auto des supports (liens Odoo Learning, PDF, vidéos internes).
- Traçabilité: créez un modèle slide.quiz.attempt custom si vous voulez historiser finement chaque tentative et alimenter vos dashboards formateurs.
- Voix: Web Speech API pour le POC ; basculez ensuite vers un ASR/TTS cloud pour meilleure précision FR/MG et gestion du barge-in.
ANNEXES: e manifest du module Odoo (__manifest__.py) et un mini modèle slide.quiz.attempt pour journaliser proprement les tentatives
Parfait. Voici le module Odoo complet (squelette minimal prêt à installer) avec :
- __manifest__.py
- arborescence controllers/ (votre contrôleur REST déjà fourni fonctionne tel quel)
- models/slide_quiz_attempt.py (journalisation des tentatives)
- security/ir.model.access.csv
- __init__.py correspondants
Adaptez le nom technique du module si besoin (odoo_quiz_api).
1) __manifest__.py
# -*- coding: utf-8 -*- { "name": "Quiz API for Odoo eLearning", "summary": "Exposition REST des quiz (lecture + tentative) et journalisation", "version": "16.0.1.0.0", # adaptez à votre version d'Odoo "author": "Votre Société", "website": "https://votre-domaine.tld", "category": "eLearning", "license": "LGPL-3", "depends": ["website_slides"], # module eLearning "data": [ "security/ir.model.access.csv", ], "application": False, "installable": True, }
2) __init__.py (racine du module)
# -*- coding: utf-8 -*- from . import controllers from . import models
3) controllers/__init__.py
# -*- coding: utf-8 -*- from . import main
4) controllers/main.py
Utilisez le contrôleur que je vous ai déjà donné (copiez-le ici tel quel).
Il expose POST /api/quiz/<slide_id> et POST /api/quiz/attempt avec Auth via X-API-Key.
(Si besoin je peux le recoller ici, mais vous avez déjà le fichier complet.)
5) models/__init__.py
# -*- coding: utf-8 -*- from . import slide_quiz_attempt
6) models/slide_quiz_attempt.py
# -*- coding: utf-8 -*- from odoo import api, fields, models class SlideQuizAttempt(models.Model): _name = "slide.quiz.attempt" _description = "Journal des tentatives de quiz (API)" _order = "create_date desc" name = fields.Char(string="Nom", compute="_compute_name", store=True) slide_id = fields.Many2one("slide.slide", string="Quiz (Slide)", required=True, index=True, ondelete="cascade") partner_id = fields.Many2one("res.partner", string="Participant", index=True) user_id = fields.Many2one("res.users", string="Utilisateur", index=True, default=lambda self: self.env.user) score = fields.Float(string="Score (%)", digits=(5, 1)) details_json = fields.Text(string="Détails (JSON)") # feedback par question lang = fields.Char(string="Langue", default="fr_FR") @api.depends("slide_id", "partner_id", "create_date") def _compute_name(self): for rec in self: base = rec.slide_id.name or "Quiz" who = rec.partner_id.display_name if rec.partner_id else (rec.user_id.name or "Anonyme") rec.name = f"{base} - {who}"
7) security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_slide_quiz_attempt_user,access_slide_quiz_attempt_user,model_slide_quiz_attempt,base.group_user,1,1,1,0
Donne aux utilisateurs internes la lecture/écriture/création sur les tentatives (pas de suppression par défaut). Ajustez selon vos besoins (ex. ajoutez un groupe formateur).
Intégration contrôleur ↔ modèle (journalisation)
Ajoutez quelques lignes dans controllers/main.py (méthode post_attempt) pour créer un enregistrement de tentative après calcul du score :
# ... resp = { "score": score, "feedback": detailed, "message": _("Évaluation effectuée. Voulez-vous des explications détaillées question par question ?"), } # Journalisation (safe, même si utilisateur public) try: partner_id = request.env.user.partner_id.id if (request.env.user and request.env.user.id) else False request.env["slide.quiz.attempt"].sudo().create({ "slide_id": slide.id, "partner_id": partner_id, "user_id": request.env.user.id if request.env.user and request.env.user.id else False, "score": score, "details_json": request.httprequest.data.decode("utf-8") if request.httprequest.data else "", "lang": lang, }) except Exception: # Ne pas bloquer l'API si le log échoue pass return request.make_response(resp, headers=_cors_headers(), status=200)
(Si vous souhaitez stocker le feedback calculé plutôt que le payload entrant, remplacez details_json par json.dumps({"feedback": detailed}, ensure_ascii=False))
Mise en service (checklist rapide)
- Installer le module dans Odoo (copier le dossier dans addons/ puis Apps > Update Apps List > installer).
-
Créer la clé API : Paramètres → Paramètres techniques → Paramètres système
- Clé : quiz_api.key
- Valeur : un token long & secret (ex. généré par openssl rand -hex 32).
- CORS : si votre front est sur un domaine spécifique, remplacez * par ce domaine dans _cors_headers().
- Tester :
curl -X POST https://votre-odoo.tld/api/quiz/42 \ -H 'Content-Type: application/json' -H 'X-API-Key: VOTRE_TOKEN' \ -d '{}'
- Brancher votre front React avec baseUrl, apiKey et le slideId voulu (cf. composants fournis précédemment).
Notes de prod
- Permissions : si vous mettez auth="public" (comme dans l’exemple), la sécurité repose sur X-API-Key. En prod, vous pouvez basculer sur auth="user" + clés par utilisateur/groupe, ou OAuth2/JWT via un reverse proxy.
- Ne divulguez jamais les bonnes réponses côté API ; seules les explications/feedback doivent sortir.
- Langues : respectez lang pour servir les libellés localisés et tracer la langue dans slide.quiz.attempt.
- Traçabilité : exploitez le modèle slide.quiz.attempt pour dashboards (listes par slide_id, distributions de scores, etc.).
ANNEXES : un menu/Action Odoo (tree/form) pour visualiser les tentatives, + un rapport XLSX simple d’export des résultats, et une route REST pour lister les tentatives (formateurs).
J’ajoute ce que vous avez demandé : menus/formulaires, liste des tentatives, et export XLSX, + route REST de listing pour les formateurs.
1) Sécurité — groupe “Formateur Quiz”
security/quiz_security.xml
<odoo> <data noupdate="1"> <record id="group_quiz_trainer" model="res.groups"> <field name="name">Formateur Quiz</field> <field name="category_id" ref="base.module_category_hidden"/> <field name="implied_ids" eval="[(4, ref('base.group_user'))]"/> </record> </data> </odoo>
security/ir.model.access.csv
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_slide_quiz_attempt_user,access_slide_quiz_attempt_user,model_slide_quiz_attempt,base.group_user,1,1,1,0 access_slide_quiz_attempt_trainer,access_slide_quiz_attempt_trainer,model_slide_quiz_attempt,odoo_quiz_api.group_quiz_trainer,1,1,1,1
Le groupe Formateur Quiz pourra voir/éditer/créer et supprimer si besoin (ajustez selon votre politique).
2) Vues, action et menu (liste & fiche)
views/quiz_attempt_views.xml
<odoo> <record id="view_slide_quiz_attempt_tree" model="ir.ui.view"> <field name="name">slide.quiz.attempt.tree</field> <field name="model">slide.quiz.attempt</field> <field name="arch" type="xml"> <tree create="false"> <field name="create_date"/> <field name="slide_id"/> <field name="partner_id"/> <field name="user_id"/> <field name="score"/> <field name="lang"/> </tree> </field> </record> <record id="view_slide_quiz_attempt_form" model="ir.ui.view"> <field name="name">slide.quiz.attempt.form</field> <field name="model">slide.quiz.attempt</field> <field name="arch" type="xml"> <form string="Tentative de quiz"> <sheet> <group> <field name="create_date" readonly="1"/> <field name="slide_id" readonly="1"/> <field name="partner_id" readonly="1"/> <field name="user_id" readonly="1"/> <field name="score" readonly="1"/> <field name="lang" readonly="1"/> </group> <group string="Détails"> <field name="details_json" widget="text" readonly="1"/> </group> </sheet> </form> </field> </record> <record id="action_slide_quiz_attempts" model="ir.actions.act_window"> <field name="name">Tentatives Quiz</field> <field name="res_model">slide.quiz.attempt</field> <field name="view_mode">tree,form</field> <field name="domain">[]</field> <field name="context">{}</field> </record> <!-- Menu sous eLearning --> <menuitem id="menu_quiz_root" name="Quiz (API)" parent="website_slides.menu_website_slides_root" sequence="90" groups="odoo_quiz_api.group_quiz_trainer"/> <menuitem id="menu_quiz_attempts" name="Tentatives" parent="odoo_quiz_api.menu_quiz_root" action="odoo_quiz_api.action_slide_quiz_attempts" groups="odoo_quiz_api.group_quiz_trainer" sequence="10"/> </odoo>
3) Manifest — ajoutez data/views
__manifest__.py (complétez votre fichier existant)
"data": [ "security/quiz_security.xml", "security/ir.model.access.csv", "views/quiz_attempt_views.xml", ],
4) Routes REST “liste” + “export XLSX”
Ajoutez à controllers/main.py :
# -*- coding: utf-8 -*- from odoo import http, _ from odoo.http import request from datetime import datetime import io try: import xlsxwriter except Exception: xlsxwriter = None # ... (vos fonctions _check_api_key et _cors_headers + classes existantes) class QuizApiTrainerController(http.Controller): def _ensure_trainer(self): user = request.env.user if not user or not user.has_group("odoo_quiz_api.group_quiz_trainer"): return False return True @http.route("/api/quiz/attempts", type="json", auth="user", csrf=False, methods=["POST","OPTIONS"]) def list_attempts(self, **kwargs): # CORS preflight if request.httprequest.method == "OPTIONS": return request.make_response("", headers=_cors_headers()) if not self._ensure_trainer(): return request.make_response({"error": "Forbidden"}, headers=_cors_headers(), status=403) data = request.jsonrequest or {} slide_id = data.get("slide_id") date_from = data.get("date_from") # "YYYY-MM-DD" date_to = data.get("date_to") # "YYYY-MM-DD" min_score = data.get("min_score") # float dom = [] if slide_id: dom.append(("slide_id", "=", slide_id)) if date_from: dom.append(("create_date", ">=", f"{date_from} 00:00:00")) if date_to: dom.append(("create_date", "<=", f"{date_to} 23:59:59")) if min_score is not None: dom.append(("score", ">=", float(min_score))) attempts = request.env["slide.quiz.attempt"].sudo().search(dom, order="create_date desc", limit=1000) rows = [] for a in attempts: rows.append({ "id": a.id, "create_date": a.create_date and a.create_date.strftime("%Y-%m-%d %H:%M:%S"), "slide_id": a.slide_id.id, "slide": a.slide_id.name, "partner": a.partner_id.display_name, "user": a.user_id.name, "score": a.score, "lang": a.lang, }) return request.make_response({"count": len(rows), "results": rows}, headers=_cors_headers(), status=200) @http.route("/api/quiz/attempts/export", type="http", auth="user", csrf=False, methods=["GET"]) def export_attempts_xlsx(self, **kwargs): # Vérif groupe if not self._ensure_trainer(): return request.not_found() if xlsxwriter is None: return request.make_response("xlsxwriter non disponible sur le serveur", status=500) slide_id = kwargs.get("slide_id") and int(kwargs.get("slide_id")) date_from = kwargs.get("date_from") # "YYYY-MM-DD" date_to = kwargs.get("date_to") # "YYYY-MM-DD" min_score = kwargs.get("min_score") dom = [] if slide_id: dom.append(("slide_id", "=", slide_id)) if date_from: dom.append(("create_date", ">=", f"{date_from} 00:00:00")) if date_to: dom.append(("create_date", "<=", f"{date_to} 23:59:59")) if min_score is not None: dom.append(("score", ">=", float(min_score))) attempts = request.env["slide.quiz.attempt"].sudo().search(dom, order="create_date desc", limit=5000) buffer = io.BytesIO() wb = xlsxwriter.Workbook(buffer, {'in_memory': True}) ws = wb.add_worksheet("Tentatives") headers = ["Date", "Slide ID", "Slide", "Participant", "Utilisateur", "Score (%)", "Langue"] for col, h in enumerate(headers): ws.write(0, col, h) row = 1 for a in attempts: ws.write(row, 0, a.create_date.strftime("%Y-%m-%d %H:%M:%S") if a.create_date else "") ws.write(row, 1, a.slide_id.id) ws.write(row, 2, a.slide_id.name or "") ws.write(row, 3, a.partner_id.display_name or "") ws.write(row, 4, a.user_id.name or "") ws.write(row, 5, a.score or 0.0) ws.write(row, 6, a.lang or "") row += 1 wb.close() xlsx = buffer.getvalue() buffer.close() filename = f"tentatives_quiz_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.xlsx" headers = [ ('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'), ('Content-Length', len(xlsx)), ('Content-Disposition', f'attachment; filename="{filename}"'), ('Access-Control-Allow-Origin', request.httprequest.headers.get("Origin") or "*"), ] return request.make_response(xlsx, headers=headers)
Exemples d’appels :
# Listing JSON (POST, authentifié, groupe formateur) curl -X POST https://votre-odoo.tld/api/quiz/attempts \ -H 'Content-Type: application/json' \ -H 'X-Openerp-Session-Id: <si vous utilisez la session web>' \ -d '{"slide_id":42,"date_from":"2025-01-01","min_score":60}'
# Export XLSX (GET, authentifié, groupe formateur) https://votre-odoo.tld/api/quiz/attempts/export?slide_id=42&date_from=2025-01-01&min_score=60
Auth : ces routes utilisent auth="user" + contrôle de groupe Formateur Quiz.
Si vous préférez un token (API key), ajoutez la vérification comme dans les autres routes.
Bonus : bouton d’export dans la vue liste
Ajoutez un smart button simple via une action serveur ou un act_url :
views/quiz_attempt_views.xml (ajout dans l’action pour un bouton “Exporter en XLSX” via lien) :
<record id="action_slide_quiz_attempts" model="ir.actions.act_window"> <field name="name">Tentatives Quiz</field> <field name="res_model">slide.quiz.attempt</field> <field name="view_mode">tree,form</field> <field name="context">{'show_export_xlsx': 1}</field> </record> <record id="view_slide_quiz_attempt_tree" model="ir.ui.view"> <field name="name">slide.quiz.attempt.tree</field> <field name="model">slide.quiz.attempt</field> <field name="arch" type="xml"> <tree create="false"> <button name="%(odoo_quiz_api.action_slide_quiz_attempts)d" string="Exporter XLSX" type="action" class="oe_highlight" attrs="{'invisible': [('id','=',False)]}" help="Utilisez le menu 'Favoris > Export XLSX' ou la route REST dédiée."/> <field name="create_date"/> <field name="slide_id"/> <field name="partner_id"/> <field name="user_id"/> <field name="score"/> <field name="lang"/> </tree> </field> </record>
(Optionnel) Vous pouvez remplacer par un lien (act_url) pointant vers /api/quiz/attempts/export?… si vous voulez un vrai clic d’export direct (paramétrage selon votre usage).
Check-list rapide
- Installer/mettre à jour le module.
- Assigner les formateurs au groupe “Formateur Quiz”.
- Tester l’interface : eLearning → Quiz (API) → Tentatives.
- Tester export XLSX via l’URL GET.
- (Optionnel) Restreindre CORS du contrôleur d’export au domaine du front.
Souhaitez-vous que j’ajoute aussi :
- un smart button sur la fiche Slide pour ouvrir les tentatives liées,
- un graph view (barres par score, date),
- ou un petit dashboard Kanban (répartitions par slide) ?