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:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -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 — <t t-out="attempt.exam_id.title"/></h1>
|
||||
<div class="ec-timer" id="timer">Loading…</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…</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"/>
|
||||
|
||||
Reference in New Issue
Block a user