diff --git a/new_project/custom_addons/encoach_api/controllers/base.py b/new_project/custom_addons/encoach_api/controllers/base.py index e38e227..1e8b940 100644 --- a/new_project/custom_addons/encoach_api/controllers/base.py +++ b/new_project/custom_addons/encoach_api/controllers/base.py @@ -70,13 +70,14 @@ def validate_token(): def jwt_required(func): - """Decorator that validates the JWT token and injects the user as first arg.""" + """Decorator that validates the JWT token and sets request.env user context.""" @functools.wraps(func) def wrapper(*args, **kwargs): user = validate_token() if not user: return _error_response("Authentication required", status=401) - return func(*args, user=user, **kwargs) + request.update_env(user=user.id) + return func(*args, **kwargs) return wrapper diff --git a/new_project/custom_addons/encoach_exam_session/controllers/exam_session.py b/new_project/custom_addons/encoach_exam_session/controllers/exam_session.py index c94214a..603d00f 100644 --- a/new_project/custom_addons/encoach_exam_session/controllers/exam_session.py +++ b/new_project/custom_addons/encoach_exam_session/controllers/exam_session.py @@ -1,4 +1,7 @@ import logging +import time + +import jwt as pyjwt from odoo import http from odoo.http import request @@ -8,19 +11,29 @@ _logger = logging.getLogger(__name__) class ExamSessionController(http.Controller): + def _generate_exam_jwt(self, user_id): + secret = request.env['ir.config_parameter'].sudo().get_param('encoach.jwt_secret') + if not secret: + return '' + return pyjwt.encode( + {'user_id': user_id, 'exp': int(time.time()) + 7200}, + secret, algorithm='HS256', + ) + @http.route('/exam/session/', type='http', auth='user', website=False) def exam_session(self, attempt_id, **kw): attempt = request.env['encoach.student.attempt'].sudo().browse(attempt_id) if not attempt.exists() or attempt.student_id.id != request.env.user.id: return request.not_found() + token = self._generate_exam_jwt(request.env.user.id) return request.render('encoach_exam_session.exam_session_page', { 'attempt': attempt, - 'session_token': request.session.sid, + 'session_token': token, }) @http.route('/placement/test/', type='http', - auth='user', website=False) + auth='user', website=True) def placement_test(self, session_id, **kw): session = request.env['encoach.cat.session'].sudo().browse(session_id) if not session.exists() or session.student_id.id != request.env.user.id: diff --git a/new_project/custom_addons/encoach_exam_session/static/src/js/exam_delivery.js b/new_project/custom_addons/encoach_exam_session/static/src/js/exam_delivery.js new file mode 100644 index 0000000..1315bea --- /dev/null +++ b/new_project/custom_addons/encoach_exam_session/static/src/js/exam_delivery.js @@ -0,0 +1,368 @@ +(function () { + "use strict"; + + const app = document.getElementById("exam_session_app"); + if (!app) return; + + const ATTEMPT_ID = parseInt(app.dataset.attemptId); + const EXAM_ID = parseInt(app.dataset.examId); + const SESSION_TOKEN = app.dataset.sessionToken; + + let sections = []; + let currentSection = 0; + let currentQuestion = 0; + let answers = {}; + let totalTime = 0; + let timeLeft = 0; + let timerInterval = null; + + const sidebar = document.getElementById("sidebar"); + const mainContent = document.getElementById("mainContent"); + const timerEl = document.getElementById("timer"); + const progressBar = document.getElementById("progressBar"); + + async function fetchJSON(url, options) { + options = options || {}; + options.headers = Object.assign( + { "Content-Type": "application/json" }, + options.headers || {} + ); + const res = await fetch(url, options); + return res.json(); + } + + async function loadSession() { + const data = await fetchJSON("/api/exam/" + EXAM_ID + "/session", { + headers: { Authorization: "Bearer " + SESSION_TOKEN }, + }); + if (data.error) { + mainContent.innerHTML = + '

Error

' + + data.error + + "

"; + return; + } + sections = data.sections || []; + totalTime = (data.total_time_min || 30) * 60; + timeLeft = totalTime; + + sections.forEach(function (sec) { + (sec.questions || []).forEach(function (q) { + if (q.saved_answer) answers[q.id] = q.saved_answer; + }); + }); + + buildSidebar(); + renderQuestion(); + startTimer(); + } + + function buildSidebar() { + var html = ""; + sections.forEach(function (sec, si) { + html += '
' + sec.title + "
"; + (sec.questions || []).forEach(function (q, qi) { + var cls = answers[q.id] ? "answered" : ""; + var act = + si === currentSection && qi === currentQuestion + ? "active" + : ""; + html += + '"; + }); + html += "
"; + }); + sidebar.innerHTML = html; + sidebar.querySelectorAll(".q-btn").forEach(function (btn) { + btn.addEventListener("click", function () { + jumpTo( + parseInt(this.dataset.si), + parseInt(this.dataset.qi) + ); + }); + }); + } + + function renderQuestion() { + var sec = sections[currentSection]; + if (!sec) return; + var q = sec.questions[currentQuestion]; + if (!q) return; + + var globalIdx = + sections + .slice(0, currentSection) + .reduce(function (a, s) { + return a + s.questions.length; + }, 0) + + currentQuestion + + 1; + var totalQ = sections.reduce(function (a, s) { + return a + s.questions.length; + }, 0); + + progressBar.style.width = (globalIdx / totalQ) * 100 + "%"; + + var optionsHtml = ""; + if (q.options && q.options.length > 0) { + q.options.forEach(function (opt) { + var key = opt.key || opt.text; + var sel = answers[q.id] === key ? "selected" : ""; + optionsHtml += + '"; + }); + } else { + var val = answers[q.id] || ""; + optionsHtml = + ''; + } + + var isFirst = currentSection === 0 && currentQuestion === 0; + var isLast = + currentSection === sections.length - 1 && + currentQuestion === sec.questions.length - 1; + + mainContent.innerHTML = + '
' + + '
' + + sec.title + + " \u2014 Question " + + (currentQuestion + 1) + + " of " + + sec.questions.length + + '' + + (q.difficulty || "") + + " \u2022 " + + (q.marks || 1) + + " mark" + + ((q.marks || 1) > 1 ? "s" : "") + + "
" + + '
' + + q.stem + + "
" + + optionsHtml + + "
" + + '
' + + '" + + (isLast + ? '' + : '') + + "
"; + + mainContent.querySelectorAll(".ec-option").forEach(function (el) { + el.addEventListener("click", function () { + selectAnswer( + parseInt(this.dataset.qid), + this.dataset.key, + this + ); + }); + }); + + var textInput = document.getElementById("textAnswer"); + if (textInput) { + textInput.addEventListener("change", function () { + answers[q.id] = this.value; + }); + } + + var btnPrev = document.getElementById("btnPrev"); + if (btnPrev) + btnPrev.addEventListener("click", function () { + navigate(-1); + }); + + var btnNext = document.getElementById("btnNext"); + if (btnNext) + btnNext.addEventListener("click", function () { + navigate(1); + }); + + var btnSubmit = document.getElementById("btnSubmit"); + if (btnSubmit) btnSubmit.addEventListener("click", submitExam); + + buildSidebar(); + } + + function selectAnswer(qid, key, el) { + answers[qid] = key; + el.closest(".ec-card") + .querySelectorAll(".ec-option") + .forEach(function (o) { + o.classList.remove("selected"); + }); + el.classList.add("selected"); + buildSidebar(); + } + + function navigate(dir) { + var sec = sections[currentSection]; + var next = currentQuestion + dir; + if (next >= 0 && next < sec.questions.length) { + currentQuestion = next; + } else if (dir > 0 && currentSection < sections.length - 1) { + currentSection++; + currentQuestion = 0; + } else if (dir < 0 && currentSection > 0) { + currentSection--; + currentQuestion = sections[currentSection].questions.length - 1; + } + renderQuestion(); + autoSave(); + } + + function jumpTo(si, qi) { + currentSection = si; + currentQuestion = qi; + renderQuestion(); + } + + function startTimer() { + timerInterval = setInterval(function () { + timeLeft--; + if (timeLeft <= 0) { + clearInterval(timerInterval); + submitExam(); + return; + } + var m = Math.floor(timeLeft / 60); + var s = timeLeft % 60; + timerEl.textContent = + String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0"); + timerEl.className = + "ec-timer" + + (timeLeft < 120 + ? " danger" + : timeLeft < 300 + ? " warning" + : ""); + }, 1000); + } + + var saveTimeout; + function autoSave() { + clearTimeout(saveTimeout); + saveTimeout = setTimeout(function () { + var ansArr = Object.entries(answers).map(function (entry) { + return { question_id: parseInt(entry[0]), answer: entry[1] }; + }); + fetchJSON("/api/exam/" + EXAM_ID + "/autosave", { + method: "POST", + headers: { Authorization: "Bearer " + SESSION_TOKEN }, + body: JSON.stringify({ + attempt_id: ATTEMPT_ID, + answers: ansArr, + current_section: + sections[currentSection] + ? sections[currentSection].skill + : "", + time_remaining_sec: timeLeft, + }), + }); + }, 2000); + } + + function submitExam() { + if ( + !confirm( + "Are you sure you want to submit? You cannot change answers after submission." + ) + ) + return; + clearInterval(timerInterval); + mainContent.innerHTML = + '
' + + "

Submitting\u2026

" + + '
'; + + var ansArr = Object.entries(answers).map(function (entry) { + return { question_id: parseInt(entry[0]), answer: entry[1] }; + }); + fetchJSON("/api/exam/" + EXAM_ID + "/submit", { + method: "POST", + headers: { Authorization: "Bearer " + SESSION_TOKEN }, + body: JSON.stringify({ attempt_id: ATTEMPT_ID, answers: ansArr }), + }).then(function (result) { + if (result.scores) { + showResults(result.scores); + } else { + mainContent.innerHTML = + '
' + + "

Exam Submitted

" + + "

Status: " + + result.status + + "

" + + "

Your results will be released soon.

"; + } + sidebar.innerHTML = + '
Exam completed
'; + }); + } + + function showResults(scores) { + var skills = ["listening", "reading", "writing", "speaking"].filter( + function (s) { + return scores[s] > 0; + } + ); + var rows = skills + .map(function (s) { + return ( + '
' + + s + + "" + + scores[s].toFixed(1) + + "
" + ); + }) + .join(""); + mainContent.innerHTML = + '
' + + '

Exam Complete!

' + + '
' + + scores.overall.toFixed(1) + + "
" + + '
Overall Band Score \u2014 CEFR ' + + (scores.cefr || "").toUpperCase() + + "
" + + '
' + + rows + + "
" + + 'Return to Dashboard' + + "
"; + } + + loadSession(); +})(); diff --git a/new_project/custom_addons/encoach_exam_session/views/exam_session_templates.xml b/new_project/custom_addons/encoach_exam_session/views/exam_session_templates.xml index b6e9b4b..b5ba5a8 100644 --- a/new_project/custom_addons/encoach_exam_session/views/exam_session_templates.xml +++ b/new_project/custom_addons/encoach_exam_session/views/exam_session_templates.xml @@ -1,20 +1,71 @@ -