feat: add full-screen exam delivery UI with JWT auth fix

- Fix jwt_required decorator to set request.env user context via
  update_env() instead of passing user as kwarg (fixes null student_id
  on attempt creation for auth='none' API routes)
- Build standalone exam session page with timer, question navigator,
  auto-save, and instant score display (no dependency on web.frontend_layout)
- Add exam_delivery.js for client-side exam interaction (MCQ selection,
  section navigation, countdown, autosave, submit & results)
- Generate per-session JWT in controller for API calls from the exam page

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-07 23:49:45 +04:00
parent d98cd55b99
commit e30e52e21e
4 changed files with 444 additions and 11 deletions

View File

@@ -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

View File

@@ -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/<int:attempt_id>', 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/<int:session_id>', 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:

View File

@@ -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 =
'<div class="ec-card"><h3>Error</h3><p>' +
data.error +
"</p></div>";
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 += '<div class="section-title">' + sec.title + "</div><div>";
(sec.questions || []).forEach(function (q, qi) {
var cls = answers[q.id] ? "answered" : "";
var act =
si === currentSection && qi === currentQuestion
? "active"
: "";
html +=
'<button class="q-btn ' +
cls +
" " +
act +
'" data-si="' +
si +
'" data-qi="' +
qi +
'">' +
(si * 5 + qi + 1) +
"</button>";
});
html += "</div>";
});
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 +=
'<label class="ec-option ' +
sel +
'" data-qid="' +
q.id +
'" data-key="' +
key +
'">' +
'<input type="radio" name="q' +
q.id +
'"' +
(sel ? " checked" : "") +
"/>" +
'<span class="ec-option-key">' +
(opt.key || "") +
"</span>" +
"<span>" +
(opt.text || key) +
"</span>" +
"</label>";
});
} else {
var val = answers[q.id] || "";
optionsHtml =
'<input type="text" class="form-control" id="textAnswer" value="' +
val +
'" placeholder="Type your answer…"/>';
}
var isFirst = currentSection === 0 && currentQuestion === 0;
var isLast =
currentSection === sections.length - 1 &&
currentQuestion === sec.questions.length - 1;
mainContent.innerHTML =
'<div class="ec-card">' +
'<div style="color:#64748B;font-size:.85rem;margin-bottom:12px;">' +
sec.title +
" \u2014 Question " +
(currentQuestion + 1) +
" of " +
sec.questions.length +
'<span style="float:right;color:var(--ec-primary);font-weight:600;">' +
(q.difficulty || "") +
" \u2022 " +
(q.marks || 1) +
" mark" +
((q.marks || 1) > 1 ? "s" : "") +
"</span></div>" +
'<div class="ec-question-stem">' +
q.stem +
"</div>" +
optionsHtml +
"</div>" +
'<div class="ec-nav">' +
'<button class="btn btn-outline-secondary" id="btnPrev"' +
(isFirst ? " disabled" : "") +
">\u2190 Previous</button>" +
(isLast
? '<button class="btn btn-success btn-lg" id="btnSubmit">Submit Exam</button>'
: '<button class="btn text-white" id="btnNext" style="background:var(--ec-primary)">Next \u2192</button>') +
"</div>";
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 =
'<div class="ec-card" style="text-align:center;padding:48px">' +
"<h2>Submitting\u2026</h2>" +
'<div class="spinner-border text-primary mt-3"></div></div>';
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 =
'<div class="ec-card ec-results">' +
"<h2>Exam Submitted</h2>" +
"<p>Status: " +
result.status +
"</p>" +
"<p>Your results will be released soon.</p></div>";
}
sidebar.innerHTML =
'<div style="padding:16px;text-align:center;color:#64748B">Exam completed</div>';
});
}
function showResults(scores) {
var skills = ["listening", "reading", "writing", "speaking"].filter(
function (s) {
return scores[s] > 0;
}
);
var rows = skills
.map(function (s) {
return (
'<div class="ec-skill-row"><span style="text-transform:capitalize">' +
s +
"</span><strong>" +
scores[s].toFixed(1) +
"</strong></div>"
);
})
.join("");
mainContent.innerHTML =
'<div class="ec-card ec-results">' +
'<h2 style="margin-bottom:8px">Exam Complete!</h2>' +
'<div class="ec-band">' +
scores.overall.toFixed(1) +
"</div>" +
'<div style="color:#64748B;margin-bottom:24px">Overall Band Score \u2014 CEFR ' +
(scores.cefr || "").toUpperCase() +
"</div>" +
'<div style="max-width:400px;margin:0 auto;text-align:left">' +
rows +
"</div>" +
'<a href="/web" class="btn btn-primary mt-4">Return to Dashboard</a>' +
"</div>";
}
loadSession();
})();

View File

@@ -1,20 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Full-screen exam delivery page (no header/footer) -->
<template id="exam_session_page" name="Exam Session">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>EnCoach Exam</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"/>
<style>
:root { --ec-primary: #4F46E5; --ec-bg: #F8FAFC; }
body { background: var(--ec-bg); font-family: system-ui, -apple-system, sans-serif; margin:0; }
.ec-topbar { background: var(--ec-primary); color:#fff; padding:12px 24px; display:flex; justify-content:space-between; align-items:center; }
.ec-topbar h1 { font-size:1.1rem; margin:0; font-weight:600; }
.ec-timer { font-size:1.25rem; font-weight:700; font-variant-numeric:tabular-nums; }
.ec-timer.warning { color:#FCD34D; }
.ec-timer.danger { color:#EF4444; animation:pulse 1s infinite; }
@keyframes pulse { 50% { opacity:.5; } }
.ec-sidebar { width:260px; background:#fff; border-right:1px solid #E2E8F0; padding:16px; overflow-y:auto; }
.ec-sidebar .section-title { font-weight:600; color:var(--ec-primary); margin:16px 0 8px; font-size:.85rem; text-transform:uppercase; letter-spacing:.05em; }
.ec-sidebar .q-btn { display:inline-flex; align-items:center; justify-content:center; width:40px; height:40px; border-radius:8px; border:2px solid #E2E8F0; background:#fff; cursor:pointer; font-weight:600; margin:3px; transition:.2s; }
.ec-sidebar .q-btn:hover { border-color:var(--ec-primary); }
.ec-sidebar .q-btn.active { background:var(--ec-primary); color:#fff; border-color:var(--ec-primary); }
.ec-sidebar .q-btn.answered { background:#D1FAE5; border-color:#10B981; color:#065F46; }
.ec-main { flex:1; padding:32px; overflow-y:auto; max-width:900px; margin:0 auto; }
.ec-card { background:#fff; border-radius:12px; box-shadow:0 1px 3px rgba(0,0,0,.1); padding:28px; margin-bottom:24px; }
.ec-question-stem { font-size:1.1rem; line-height:1.6; margin-bottom:20px; }
.ec-option { display:flex; align-items:center; padding:14px 18px; border:2px solid #E2E8F0; border-radius:10px; margin-bottom:10px; cursor:pointer; transition:.15s; }
.ec-option:hover { border-color:var(--ec-primary); background:#EEF2FF; }
.ec-option.selected { border-color:var(--ec-primary); background:#EEF2FF; }
.ec-option input { margin-right:12px; accent-color:var(--ec-primary); }
.ec-option-key { font-weight:700; color:var(--ec-primary); margin-right:10px; min-width:24px; }
.ec-nav { display:flex; justify-content:space-between; margin-top:24px; }
.ec-layout { display:flex; height:calc(100vh - 56px); }
.ec-progress-bar { height:4px; background:#E2E8F0; }
.ec-progress-fill { height:100%; background:var(--ec-primary); transition:width .3s; }
.ec-results { text-align:center; padding:48px; }
.ec-band { font-size:4rem; font-weight:800; color:var(--ec-primary); }
.ec-skill-row { display:flex; justify-content:space-between; padding:12px 0; border-bottom:1px solid #E2E8F0; }
</style>
</head>
<body>
<div class="ec-topbar">
<h1>EnCoach &#8212; <t t-out="attempt.exam_id.title"/></h1>
<div class="ec-timer" id="timer">Loading&#8230;</div>
</div>
<div class="ec-progress-bar"><div class="ec-progress-fill" id="progressBar" style="width:0%"/></div>
<div class="ec-layout">
<div class="ec-sidebar" id="sidebar"/>
<div class="ec-main" id="mainContent">
<div class="ec-card" style="text-align:center; padding:48px;">
<h2>Loading exam&#8230;</h2>
</div>
</div>
</div>
<div id="exam_session_app"
t-att-data-attempt-id="attempt.id"
t-att-data-exam-id="attempt.exam_id.id"
t-att-data-session-token="session_token"
class="ec-exam-fullscreen"/>
</t>
style="display:none"/>
<script src="/encoach_exam_session/static/src/js/exam_delivery.js"/>
</body>
</html>
</template>
<!-- Full-screen placement CAT test page (no header/footer) -->
<template id="placement_session_page" name="Placement Session">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>