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):
|
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)
|
@functools.wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
user = validate_token()
|
user = validate_token()
|
||||||
if not user:
|
if not user:
|
||||||
return _error_response("Authentication required", status=401)
|
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
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
import jwt as pyjwt
|
||||||
|
|
||||||
from odoo import http
|
from odoo import http
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
@@ -8,19 +11,29 @@ _logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
class ExamSessionController(http.Controller):
|
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',
|
@http.route('/exam/session/<int:attempt_id>', type='http',
|
||||||
auth='user', website=False)
|
auth='user', website=False)
|
||||||
def exam_session(self, attempt_id, **kw):
|
def exam_session(self, attempt_id, **kw):
|
||||||
attempt = request.env['encoach.student.attempt'].sudo().browse(attempt_id)
|
attempt = request.env['encoach.student.attempt'].sudo().browse(attempt_id)
|
||||||
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
|
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
|
||||||
return request.not_found()
|
return request.not_found()
|
||||||
|
token = self._generate_exam_jwt(request.env.user.id)
|
||||||
return request.render('encoach_exam_session.exam_session_page', {
|
return request.render('encoach_exam_session.exam_session_page', {
|
||||||
'attempt': attempt,
|
'attempt': attempt,
|
||||||
'session_token': request.session.sid,
|
'session_token': token,
|
||||||
})
|
})
|
||||||
|
|
||||||
@http.route('/placement/test/<int:session_id>', type='http',
|
@http.route('/placement/test/<int:session_id>', type='http',
|
||||||
auth='user', website=False)
|
auth='user', website=True)
|
||||||
def placement_test(self, session_id, **kw):
|
def placement_test(self, session_id, **kw):
|
||||||
session = request.env['encoach.cat.session'].sudo().browse(session_id)
|
session = request.env['encoach.cat.session'].sudo().browse(session_id)
|
||||||
if not session.exists() or session.student_id.id != request.env.user.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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<odoo>
|
<odoo>
|
||||||
|
|
||||||
<!-- Full-screen exam delivery page (no header/footer) -->
|
|
||||||
<template id="exam_session_page" name="Exam Session">
|
<template id="exam_session_page" name="Exam Session">
|
||||||
<t t-call="web.frontend_layout">
|
<html>
|
||||||
<t t-set="no_header" t-value="True"/>
|
<head>
|
||||||
<t t-set="no_footer" t-value="True"/>
|
<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"
|
<div id="exam_session_app"
|
||||||
t-att-data-attempt-id="attempt.id"
|
t-att-data-attempt-id="attempt.id"
|
||||||
t-att-data-exam-id="attempt.exam_id.id"
|
t-att-data-exam-id="attempt.exam_id.id"
|
||||||
t-att-data-session-token="session_token"
|
t-att-data-session-token="session_token"
|
||||||
class="ec-exam-fullscreen"/>
|
style="display:none"/>
|
||||||
</t>
|
|
||||||
|
<script src="/encoach_exam_session/static/src/js/exam_delivery.js"/>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Full-screen placement CAT test page (no header/footer) -->
|
|
||||||
<template id="placement_session_page" name="Placement Session">
|
<template id="placement_session_page" name="Placement Session">
|
||||||
<t t-call="web.frontend_layout">
|
<t t-call="web.frontend_layout">
|
||||||
<t t-set="no_header" t-value="True"/>
|
<t t-set="no_header" t-value="True"/>
|
||||||
|
|||||||
Reference in New Issue
Block a user