feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
This commit is contained in:
1241
custom_addons/encoach_exam_session/static/src/css/exam_session.css
Normal file
1241
custom_addons/encoach_exam_session/static/src/css/exam_session.css
Normal file
File diff suppressed because it is too large
Load Diff
423
custom_addons/encoach_exam_session/static/src/js/exam_app.js
Normal file
423
custom_addons/encoach_exam_session/static/src/js/exam_app.js
Normal file
@@ -0,0 +1,423 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onMounted, onWillStart, onWillUnmount, useState, useRef } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const AUTOSAVE_INTERVAL = 10000;
|
||||
|
||||
export class ExamApp extends Component {
|
||||
static template = "encoach_exam_session.ExamApp";
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
examData: null,
|
||||
currentSectionIdx: 0,
|
||||
currentQuestionIdx: 0,
|
||||
answers: {},
|
||||
flagged: {},
|
||||
timeRemaining: 0,
|
||||
totalTimeRemaining: 0,
|
||||
submitted: false,
|
||||
showReview: false,
|
||||
showConfirmSubmit: false,
|
||||
submitMessage: "",
|
||||
});
|
||||
|
||||
this.timerInterval = null;
|
||||
this.autosaveInterval = null;
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.loadExamData();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this.startTimers();
|
||||
this.enterFullscreen();
|
||||
this.loadFromLocalStorage();
|
||||
document.addEventListener("visibilitychange", this._onVisibility);
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
this.stopTimers();
|
||||
document.removeEventListener("visibilitychange", this._onVisibility);
|
||||
});
|
||||
}
|
||||
|
||||
get attemptId() {
|
||||
const el = document.getElementById("exam_session_app");
|
||||
return el ? parseInt(el.dataset.attemptId) : 0;
|
||||
}
|
||||
|
||||
get examId() {
|
||||
const el = document.getElementById("exam_session_app");
|
||||
return el ? parseInt(el.dataset.examId) : 0;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return (
|
||||
document.cookie
|
||||
.split(";")
|
||||
.find((c) => c.trim().startsWith("session_id="))
|
||||
?.split("=")[1] || ""
|
||||
);
|
||||
}
|
||||
|
||||
// ── Data Loading ──────────────────────────────────────────────────
|
||||
|
||||
async loadExamData() {
|
||||
try {
|
||||
const response = await fetch(`/api/exam/${this.examId}/session`, {
|
||||
headers: { Authorization: `Bearer ${this.getToken()}` },
|
||||
});
|
||||
const data = await response.json();
|
||||
this.state.examData = data;
|
||||
this.state.timeRemaining =
|
||||
data.sections?.[0]?.time_limit_sec || 3600;
|
||||
this.state.totalTimeRemaining = data.total_time_sec || 7200;
|
||||
if (data.saved_answers) {
|
||||
for (const ans of data.saved_answers) {
|
||||
this.state.answers[ans.question_id] = ans.answer;
|
||||
}
|
||||
}
|
||||
this.state.loading = false;
|
||||
} catch (e) {
|
||||
console.error("Failed to load exam data:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Timers ────────────────────────────────────────────────────────
|
||||
|
||||
startTimers() {
|
||||
this.timerInterval = setInterval(() => {
|
||||
if (this.state.timeRemaining > 0) {
|
||||
this.state.timeRemaining--;
|
||||
this.state.totalTimeRemaining--;
|
||||
} else {
|
||||
this.autoSubmitSection();
|
||||
}
|
||||
}, 1000);
|
||||
this.autosaveInterval = setInterval(
|
||||
() => this.autosave(),
|
||||
AUTOSAVE_INTERVAL
|
||||
);
|
||||
}
|
||||
|
||||
stopTimers() {
|
||||
if (this.timerInterval) clearInterval(this.timerInterval);
|
||||
if (this.autosaveInterval) clearInterval(this.autosaveInterval);
|
||||
}
|
||||
|
||||
enterFullscreen() {
|
||||
const el = document.documentElement;
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
formatTime(seconds) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
get timerClass() {
|
||||
if (this.state.timeRemaining < 60) return "ec-timer ec-timer--critical";
|
||||
if (this.state.timeRemaining < 300) return "ec-timer ec-timer--warning";
|
||||
return "ec-timer";
|
||||
}
|
||||
|
||||
// ── Section / Question Navigation ─────────────────────────────────
|
||||
|
||||
get currentSection() {
|
||||
return this.state.examData?.sections?.[this.state.currentSectionIdx];
|
||||
}
|
||||
|
||||
get currentQuestion() {
|
||||
return this.currentSection?.questions?.[this.state.currentQuestionIdx];
|
||||
}
|
||||
|
||||
get totalQuestions() {
|
||||
return this.currentSection?.questions?.length || 0;
|
||||
}
|
||||
|
||||
get answeredCount() {
|
||||
const section = this.currentSection;
|
||||
if (!section) return 0;
|
||||
return section.questions.filter(
|
||||
(q) => this.state.answers[q.id] !== undefined
|
||||
).length;
|
||||
}
|
||||
|
||||
get sections() {
|
||||
return this.state.examData?.sections || [];
|
||||
}
|
||||
|
||||
get questionDots() {
|
||||
const section = this.currentSection;
|
||||
if (!section) return [];
|
||||
return section.questions.map((q, idx) => ({
|
||||
idx,
|
||||
id: q.id,
|
||||
answered: this.state.answers[q.id] !== undefined,
|
||||
flagged: !!this.state.flagged[q.id],
|
||||
current: idx === this.state.currentQuestionIdx,
|
||||
}));
|
||||
}
|
||||
|
||||
get hasPrev() {
|
||||
return this.state.currentQuestionIdx > 0;
|
||||
}
|
||||
|
||||
get hasNext() {
|
||||
return this.state.currentQuestionIdx < this.totalQuestions - 1;
|
||||
}
|
||||
|
||||
get isLastSection() {
|
||||
return (
|
||||
this.state.currentSectionIdx >=
|
||||
(this.state.examData?.sections?.length || 1) - 1
|
||||
);
|
||||
}
|
||||
|
||||
nextQuestion() {
|
||||
if (this.state.currentQuestionIdx < this.totalQuestions - 1) {
|
||||
this.state.currentQuestionIdx++;
|
||||
}
|
||||
}
|
||||
|
||||
prevQuestion() {
|
||||
if (this.state.currentQuestionIdx > 0) {
|
||||
this.state.currentQuestionIdx--;
|
||||
}
|
||||
}
|
||||
|
||||
goToQuestion(idx) {
|
||||
this.state.currentQuestionIdx = idx;
|
||||
this.state.showReview = false;
|
||||
}
|
||||
|
||||
switchSection(idx) {
|
||||
this.state.currentSectionIdx = idx;
|
||||
this.state.currentQuestionIdx = 0;
|
||||
const section = this.state.examData?.sections?.[idx];
|
||||
if (section?.time_limit_sec) {
|
||||
this.state.timeRemaining = section.time_limit_sec;
|
||||
}
|
||||
}
|
||||
|
||||
nextSection() {
|
||||
const sections = this.state.examData?.sections || [];
|
||||
if (this.state.currentSectionIdx < sections.length - 1) {
|
||||
this.state.currentSectionIdx++;
|
||||
this.state.currentQuestionIdx = 0;
|
||||
this.state.timeRemaining =
|
||||
this.currentSection?.time_limit_sec || 3600;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Answer Management ─────────────────────────────────────────────
|
||||
|
||||
setAnswer(questionId, value) {
|
||||
this.state.answers[questionId] = value;
|
||||
this.saveToLocalStorage();
|
||||
}
|
||||
|
||||
onAnswer(ev) {
|
||||
if (ev.detail) {
|
||||
this.setAnswer(ev.detail.questionId, ev.detail.value);
|
||||
}
|
||||
}
|
||||
|
||||
toggleFlag() {
|
||||
const q = this.currentQuestion;
|
||||
if (!q) return;
|
||||
this.state.flagged[q.id] = !this.state.flagged[q.id];
|
||||
}
|
||||
|
||||
get isCurrentFlagged() {
|
||||
const q = this.currentQuestion;
|
||||
return q ? !!this.state.flagged[q.id] : false;
|
||||
}
|
||||
|
||||
// ── LocalStorage Persistence ──────────────────────────────────────
|
||||
|
||||
saveToLocalStorage() {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`exam_${this.attemptId}`,
|
||||
JSON.stringify({
|
||||
answers: this.state.answers,
|
||||
flagged: this.state.flagged,
|
||||
currentSectionIdx: this.state.currentSectionIdx,
|
||||
currentQuestionIdx: this.state.currentQuestionIdx,
|
||||
timeRemaining: this.state.timeRemaining,
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
// storage full or unavailable
|
||||
}
|
||||
}
|
||||
|
||||
loadFromLocalStorage() {
|
||||
try {
|
||||
const saved = localStorage.getItem(`exam_${this.attemptId}`);
|
||||
if (saved) {
|
||||
const data = JSON.parse(saved);
|
||||
Object.assign(this.state.answers, data.answers || {});
|
||||
Object.assign(this.state.flagged, data.flagged || {});
|
||||
}
|
||||
} catch (e) {
|
||||
// corrupt data
|
||||
}
|
||||
}
|
||||
|
||||
// ── Server Autosave ───────────────────────────────────────────────
|
||||
|
||||
async autosave() {
|
||||
try {
|
||||
const answers = Object.entries(this.state.answers).map(
|
||||
([qid, ans]) => ({
|
||||
question_id: parseInt(qid),
|
||||
answer: ans,
|
||||
})
|
||||
);
|
||||
await fetch(`/api/exam/${this.examId}/autosave`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
attempt_id: this.attemptId,
|
||||
answers,
|
||||
current_section: this.currentSection?.skill || "",
|
||||
time_remaining_sec: this.state.timeRemaining,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn("Autosave failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Visibility tracking (tab-switch guard) ────────────────────────
|
||||
|
||||
_onVisibility = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
this.autosave();
|
||||
}
|
||||
};
|
||||
|
||||
// ── Submit Flow ───────────────────────────────────────────────────
|
||||
|
||||
autoSubmitSection() {
|
||||
const sections = this.state.examData?.sections || [];
|
||||
if (this.state.currentSectionIdx < sections.length - 1) {
|
||||
this.nextSection();
|
||||
} else {
|
||||
this.submitExam();
|
||||
}
|
||||
}
|
||||
|
||||
showReviewPanel() {
|
||||
this.state.showReview = true;
|
||||
}
|
||||
|
||||
hideReviewPanel() {
|
||||
this.state.showReview = false;
|
||||
}
|
||||
|
||||
confirmSubmit() {
|
||||
this.state.showConfirmSubmit = true;
|
||||
}
|
||||
|
||||
cancelSubmit() {
|
||||
this.state.showConfirmSubmit = false;
|
||||
}
|
||||
|
||||
async submitExam() {
|
||||
this.stopTimers();
|
||||
this.state.submitted = true;
|
||||
this.state.showConfirmSubmit = false;
|
||||
this.state.submitMessage = "Submitting your answers...";
|
||||
try {
|
||||
const answers = Object.entries(this.state.answers).map(
|
||||
([qid, ans]) => ({
|
||||
question_id: parseInt(qid),
|
||||
answer: ans,
|
||||
})
|
||||
);
|
||||
const response = await fetch(`/api/exam/${this.examId}/submit`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
attempt_id: this.attemptId,
|
||||
answers,
|
||||
}),
|
||||
});
|
||||
await response.json();
|
||||
localStorage.removeItem(`exam_${this.attemptId}`);
|
||||
this.state.submitMessage =
|
||||
"Exam submitted successfully! Redirecting...";
|
||||
setTimeout(() => {
|
||||
window.location.href = `/my/exam/${this.attemptId}/results`;
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
console.error("Submit failed:", e);
|
||||
this.state.submitMessage =
|
||||
"Submission failed. Please try again.";
|
||||
this.state.submitted = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Review summary helpers ────────────────────────────────────────
|
||||
|
||||
get reviewSections() {
|
||||
const sections = this.state.examData?.sections || [];
|
||||
return sections.map((section, sIdx) => ({
|
||||
...section,
|
||||
sIdx,
|
||||
questions: section.questions.map((q, qIdx) => ({
|
||||
...q,
|
||||
qIdx,
|
||||
sIdx,
|
||||
answered: this.state.answers[q.id] !== undefined,
|
||||
flagged: !!this.state.flagged[q.id],
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
get totalAnswered() {
|
||||
return Object.keys(this.state.answers).length;
|
||||
}
|
||||
|
||||
get totalAllQuestions() {
|
||||
const sections = this.state.examData?.sections || [];
|
||||
return sections.reduce((sum, s) => sum + (s.questions?.length || 0), 0);
|
||||
}
|
||||
|
||||
get totalFlagged() {
|
||||
return Object.values(this.state.flagged).filter(Boolean).length;
|
||||
}
|
||||
|
||||
reviewGoTo(sIdx, qIdx) {
|
||||
this.state.currentSectionIdx = sIdx;
|
||||
this.state.currentQuestionIdx = qIdx;
|
||||
this.state.showReview = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-mount on page load ───────────────────────────────────────────
|
||||
|
||||
function mountExamApp() {
|
||||
const target = document.getElementById("exam_session_app");
|
||||
if (target) {
|
||||
const { mount } = owl;
|
||||
mount(ExamApp, target);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountExamApp);
|
||||
} else {
|
||||
mountExamApp();
|
||||
}
|
||||
@@ -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();
|
||||
})();
|
||||
@@ -0,0 +1,199 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onMounted, onWillStart, onWillUnmount, useState, useRef } from "@odoo/owl";
|
||||
|
||||
const SEM_THRESHOLD = 0.3;
|
||||
const MAX_QUESTIONS = 40;
|
||||
|
||||
export class PlacementApp extends Component {
|
||||
static template = "encoach_exam_session.PlacementApp";
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
sessionId: 0,
|
||||
currentQuestion: null,
|
||||
answer: null,
|
||||
questionsAnswered: 0,
|
||||
theta: 0.0,
|
||||
sem: 1.0,
|
||||
completed: false,
|
||||
cefrLevel: null,
|
||||
submitting: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.startSession();
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
this.enterFullscreen();
|
||||
});
|
||||
}
|
||||
|
||||
get sessionIdFromDom() {
|
||||
const el = document.getElementById("placement_session_app");
|
||||
return el ? parseInt(el.dataset.sessionId) : 0;
|
||||
}
|
||||
|
||||
enterFullscreen() {
|
||||
const el = document.documentElement;
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Session Start ─────────────────────────────────────────────
|
||||
|
||||
async startSession() {
|
||||
try {
|
||||
const domSessionId = this.sessionIdFromDom;
|
||||
if (domSessionId) {
|
||||
this.state.sessionId = domSessionId;
|
||||
await this.loadCurrentQuestion();
|
||||
} else {
|
||||
const response = await fetch("/api/placement/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const data = await response.json();
|
||||
this.state.sessionId = data.session_id;
|
||||
this.state.currentQuestion = data.first_question;
|
||||
}
|
||||
this.state.loading = false;
|
||||
} catch (e) {
|
||||
this.state.error = "Failed to start placement test.";
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async loadCurrentQuestion() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/placement/current?session_id=${this.state.sessionId}`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.question) {
|
||||
this.state.currentQuestion = data.question;
|
||||
this.state.questionsAnswered = data.questions_answered || 0;
|
||||
this.state.theta = data.theta || 0.0;
|
||||
this.state.sem = data.sem || 1.0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to load current question:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Answer Handling ───────────────────────────────────────────
|
||||
|
||||
setAnswer(questionId, value) {
|
||||
this.state.answer = value;
|
||||
}
|
||||
|
||||
get hasAnswer() {
|
||||
return this.state.answer !== null && this.state.answer !== undefined && this.state.answer !== "";
|
||||
}
|
||||
|
||||
async submitAnswer() {
|
||||
if (!this.hasAnswer || this.state.submitting) return;
|
||||
this.state.submitting = true;
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/placement/answer", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: this.state.sessionId,
|
||||
question_id: this.state.currentQuestion.id,
|
||||
answer: this.state.answer,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
this.state.theta = data.new_theta;
|
||||
this.state.sem = data.new_sem;
|
||||
this.state.questionsAnswered++;
|
||||
|
||||
if (data.completed) {
|
||||
this.state.completed = true;
|
||||
this.state.cefrLevel = data.cefr_level;
|
||||
} else {
|
||||
this.state.currentQuestion = data.next_question;
|
||||
this.state.answer = null;
|
||||
}
|
||||
} catch (e) {
|
||||
this.state.error = "Failed to submit answer. Please try again.";
|
||||
} finally {
|
||||
this.state.submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Progress Helpers ──────────────────────────────────────────
|
||||
|
||||
get progressPercent() {
|
||||
const semProgress = Math.max(0, (1.0 - this.state.sem) / (1.0 - SEM_THRESHOLD));
|
||||
const questionProgress = this.state.questionsAnswered / MAX_QUESTIONS;
|
||||
return Math.min(Math.max(semProgress, questionProgress) * 100, 100);
|
||||
}
|
||||
|
||||
get progressStyle() {
|
||||
return `width: ${this.progressPercent}%`;
|
||||
}
|
||||
|
||||
get questionType() {
|
||||
return this.state.currentQuestion?.question_type || "mcq";
|
||||
}
|
||||
|
||||
get questionOptions() {
|
||||
const opts = this.state.currentQuestion?.options;
|
||||
if (Array.isArray(opts)) {
|
||||
return opts.map((o, i) => ({
|
||||
key: o.key || String.fromCharCode(65 + i),
|
||||
label: o.label || o.text || o,
|
||||
}));
|
||||
}
|
||||
if (opts && typeof opts === "object") {
|
||||
return Object.entries(opts).map(([key, label]) => ({ key, label }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
selectOption(key) {
|
||||
this.state.answer = key;
|
||||
}
|
||||
|
||||
get cefrLabel() {
|
||||
const map = {
|
||||
pre_a1: "Pre-A1",
|
||||
a1: "A1",
|
||||
a2: "A2",
|
||||
b1: "B1",
|
||||
b2: "B2",
|
||||
c1: "C1",
|
||||
c2: "C2",
|
||||
};
|
||||
return map[this.state.cefrLevel] || this.state.cefrLevel?.toUpperCase() || "";
|
||||
}
|
||||
|
||||
goToDashboard() {
|
||||
window.location.href = "/my/dashboard";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-mount ────────────────────────────────────────────────────
|
||||
|
||||
function mountPlacementApp() {
|
||||
const target = document.getElementById("placement_session_app");
|
||||
if (target) {
|
||||
const { mount } = owl;
|
||||
mount(PlacementApp, target);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", mountPlacementApp);
|
||||
} else {
|
||||
mountPlacementApp();
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, useState, useRef, onMounted, onWillUnmount } from "@odoo/owl";
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ExamMCQ — Single-choice radio buttons
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class ExamMCQ extends Component {
|
||||
static template = "encoach_exam_session.ExamMCQ";
|
||||
static props = {
|
||||
question: Object,
|
||||
answer: { type: [String, { value: undefined }], optional: true },
|
||||
onAnswer: Function,
|
||||
};
|
||||
|
||||
selectOption(optionKey) {
|
||||
this.props.onAnswer(this.props.question.id, optionKey);
|
||||
}
|
||||
|
||||
get options() {
|
||||
const opts = this.props.question.options;
|
||||
if (Array.isArray(opts)) {
|
||||
return opts.map((o, i) => ({
|
||||
key: o.key || String.fromCharCode(65 + i),
|
||||
label: o.label || o.text || o,
|
||||
}));
|
||||
}
|
||||
if (opts && typeof opts === "object") {
|
||||
return Object.entries(opts).map(([key, label]) => ({ key, label }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ExamMCQMulti — Multi-choice checkboxes
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class ExamMCQMulti extends Component {
|
||||
static template = "encoach_exam_session.ExamMCQMulti";
|
||||
static props = {
|
||||
question: Object,
|
||||
answer: { type: [Array, { value: undefined }], optional: true },
|
||||
onAnswer: Function,
|
||||
};
|
||||
|
||||
toggleOption(optionKey) {
|
||||
const current = Array.isArray(this.props.answer)
|
||||
? [...this.props.answer]
|
||||
: [];
|
||||
const idx = current.indexOf(optionKey);
|
||||
if (idx >= 0) {
|
||||
current.splice(idx, 1);
|
||||
} else {
|
||||
current.push(optionKey);
|
||||
}
|
||||
this.props.onAnswer(this.props.question.id, current);
|
||||
}
|
||||
|
||||
isChecked(optionKey) {
|
||||
return Array.isArray(this.props.answer) && this.props.answer.includes(optionKey);
|
||||
}
|
||||
|
||||
get options() {
|
||||
const opts = this.props.question.options;
|
||||
if (Array.isArray(opts)) {
|
||||
return opts.map((o, i) => ({
|
||||
key: o.key || String.fromCharCode(65 + i),
|
||||
label: o.label || o.text || o,
|
||||
}));
|
||||
}
|
||||
if (opts && typeof opts === "object") {
|
||||
return Object.entries(opts).map(([key, label]) => ({ key, label }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ExamGapFill — Inline text inputs within a passage
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class ExamGapFill extends Component {
|
||||
static template = "encoach_exam_session.ExamGapFill";
|
||||
static props = {
|
||||
question: Object,
|
||||
answer: { type: [Object, { value: undefined }], optional: true },
|
||||
onAnswer: Function,
|
||||
};
|
||||
|
||||
onGapInput(gapIdx, ev) {
|
||||
const current = this.props.answer ? { ...this.props.answer } : {};
|
||||
current[gapIdx] = ev.target.value;
|
||||
this.props.onAnswer(this.props.question.id, current);
|
||||
}
|
||||
|
||||
get gaps() {
|
||||
return this.props.question.gaps || [];
|
||||
}
|
||||
|
||||
gapValue(gapIdx) {
|
||||
return this.props.answer?.[gapIdx] || "";
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ExamTFNG — True / False / Not Given selector
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class ExamTFNG extends Component {
|
||||
static template = "encoach_exam_session.ExamTFNG";
|
||||
static props = {
|
||||
question: Object,
|
||||
answer: { type: [String, { value: undefined }], optional: true },
|
||||
onAnswer: Function,
|
||||
};
|
||||
|
||||
select(value) {
|
||||
this.props.onAnswer(this.props.question.id, value);
|
||||
}
|
||||
|
||||
get choices() {
|
||||
return [
|
||||
{ key: "true", label: "True" },
|
||||
{ key: "false", label: "False" },
|
||||
{ key: "not_given", label: "Not Given" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ExamAudioRecorder — Record audio (speaking tasks)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class ExamAudioRecorder extends Component {
|
||||
static template = "encoach_exam_session.ExamAudioRecorder";
|
||||
static props = {
|
||||
question: Object,
|
||||
answer: { type: [String, Object, { value: undefined }], optional: true },
|
||||
onAnswer: Function,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.state = useState({
|
||||
recording: false,
|
||||
recordedUrl: null,
|
||||
duration: 0,
|
||||
error: null,
|
||||
});
|
||||
this.mediaRecorder = null;
|
||||
this.chunks = [];
|
||||
this.durationInterval = null;
|
||||
}
|
||||
|
||||
async startRecording() {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: "audio/webm;codecs=opus",
|
||||
});
|
||||
this.chunks = [];
|
||||
this.mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) this.chunks.push(e.data);
|
||||
};
|
||||
this.mediaRecorder.onstop = () => {
|
||||
const blob = new Blob(this.chunks, { type: "audio/webm" });
|
||||
this.state.recordedUrl = URL.createObjectURL(blob);
|
||||
this.props.onAnswer(this.props.question.id, {
|
||||
type: "audio",
|
||||
blob_url: this.state.recordedUrl,
|
||||
duration: this.state.duration,
|
||||
});
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
this.mediaRecorder.start();
|
||||
this.state.recording = true;
|
||||
this.state.duration = 0;
|
||||
this.durationInterval = setInterval(() => {
|
||||
this.state.duration++;
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
this.state.error = "Microphone access denied. Please allow microphone access.";
|
||||
}
|
||||
}
|
||||
|
||||
stopRecording() {
|
||||
if (this.mediaRecorder && this.state.recording) {
|
||||
this.mediaRecorder.stop();
|
||||
this.state.recording = false;
|
||||
if (this.durationInterval) clearInterval(this.durationInterval);
|
||||
}
|
||||
}
|
||||
|
||||
formatDuration(seconds) {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ExamRichEditor — Writing task with rich text
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class ExamRichEditor extends Component {
|
||||
static template = "encoach_exam_session.ExamRichEditor";
|
||||
static props = {
|
||||
question: Object,
|
||||
answer: { type: [String, { value: undefined }], optional: true },
|
||||
onAnswer: Function,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.editorRef = useRef("editor");
|
||||
this.state = useState({
|
||||
wordCount: this._countWords(this.props.answer || ""),
|
||||
});
|
||||
}
|
||||
|
||||
onInput(ev) {
|
||||
const text = ev.target.value || "";
|
||||
this.state.wordCount = this._countWords(text);
|
||||
this.props.onAnswer(this.props.question.id, text);
|
||||
}
|
||||
|
||||
_countWords(text) {
|
||||
return text
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter((w) => w.length > 0).length;
|
||||
}
|
||||
|
||||
get minWords() {
|
||||
return this.props.question.min_words || 150;
|
||||
}
|
||||
|
||||
get maxWords() {
|
||||
return this.props.question.max_words || 300;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ExamReadingPassage — Scrollable passage display
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class ExamReadingPassage extends Component {
|
||||
static template = "encoach_exam_session.ExamReadingPassage";
|
||||
static props = {
|
||||
passage: { type: [Object, String, { value: undefined }], optional: true },
|
||||
};
|
||||
|
||||
get passageTitle() {
|
||||
if (typeof this.props.passage === "object") {
|
||||
return this.props.passage?.title || "Reading Passage";
|
||||
}
|
||||
return "Reading Passage";
|
||||
}
|
||||
|
||||
get passageContent() {
|
||||
if (typeof this.props.passage === "object") {
|
||||
return this.props.passage?.content || "";
|
||||
}
|
||||
return this.props.passage || "";
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ExamNumericalInput — Number input with optional tolerance
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class ExamNumericalInput extends Component {
|
||||
static template = "encoach_exam_session.ExamNumericalInput";
|
||||
static props = {
|
||||
question: Object,
|
||||
answer: { type: [Number, String, { value: undefined }], optional: true },
|
||||
onAnswer: Function,
|
||||
};
|
||||
|
||||
onInput(ev) {
|
||||
const val = ev.target.value;
|
||||
this.props.onAnswer(this.props.question.id, val);
|
||||
}
|
||||
|
||||
get tolerance() {
|
||||
return this.props.question.tolerance;
|
||||
}
|
||||
|
||||
get unit() {
|
||||
return this.props.question.unit || "";
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// ExamCodeEditor — Code editing textarea
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
export class ExamCodeEditor extends Component {
|
||||
static template = "encoach_exam_session.ExamCodeEditor";
|
||||
static props = {
|
||||
question: Object,
|
||||
answer: { type: [String, { value: undefined }], optional: true },
|
||||
onAnswer: Function,
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.editorRef = useRef("codeEditor");
|
||||
|
||||
onMounted(() => {
|
||||
if (this.editorRef.el) {
|
||||
this.editorRef.el.addEventListener("keydown", this._onKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
onWillUnmount(() => {
|
||||
if (this.editorRef.el) {
|
||||
this.editorRef.el.removeEventListener("keydown", this._onKeydown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_onKeydown = (ev) => {
|
||||
if (ev.key === "Tab") {
|
||||
ev.preventDefault();
|
||||
const ta = ev.target;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
ta.value = ta.value.substring(0, start) + " " + ta.value.substring(end);
|
||||
ta.selectionStart = ta.selectionEnd = start + 4;
|
||||
this.props.onAnswer(this.props.question.id, ta.value);
|
||||
}
|
||||
};
|
||||
|
||||
onInput(ev) {
|
||||
this.props.onAnswer(this.props.question.id, ev.target.value);
|
||||
}
|
||||
|
||||
get language() {
|
||||
return this.props.question.language || "python";
|
||||
}
|
||||
|
||||
get starterCode() {
|
||||
return this.props.question.starter_code || "";
|
||||
}
|
||||
}
|
||||
302
custom_addons/encoach_exam_session/static/src/xml/exam_app.xml
Normal file
302
custom_addons/encoach_exam_session/static/src/xml/exam_app.xml
Normal file
@@ -0,0 +1,302 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamApp — Main full-screen exam orchestrator
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamApp">
|
||||
<div class="ec-exam-fullscreen" t-ref="root">
|
||||
|
||||
<!-- Loading State -->
|
||||
<t t-if="state.loading">
|
||||
<div class="ec-loading">
|
||||
<div class="ec-spinner"/>
|
||||
<div class="ec-loading-text">Loading your exam...</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Submitted State -->
|
||||
<t t-elif="state.submitted">
|
||||
<div class="ec-submitted-overlay">
|
||||
<div class="ec-submitted-icon">✓</div>
|
||||
<div class="ec-submitted-text" t-esc="state.submitMessage"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Exam Active State -->
|
||||
<t t-else="">
|
||||
|
||||
<!-- ── Top Bar ──────────────────────────────────────── -->
|
||||
<div class="ec-topbar">
|
||||
<div class="ec-topbar-title"
|
||||
t-esc="state.examData?.title or 'Exam Session'"/>
|
||||
|
||||
<!-- Section tabs -->
|
||||
<div class="ec-section-tabs">
|
||||
<t t-foreach="sections" t-as="section" t-key="section_index">
|
||||
<button t-att-class="'ec-section-tab' + (section_index === state.currentSectionIdx ? ' ec-section-tab--active' : '')"
|
||||
t-on-click="() => this.switchSection(section_index)">
|
||||
<t t-esc="section.name or section.skill or ('Section ' + (section_index + 1))"/>
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="ec-topbar-spacer"/>
|
||||
|
||||
<!-- Progress -->
|
||||
<div class="ec-progress-info">
|
||||
<t t-esc="answeredCount"/> / <t t-esc="totalQuestions"/> answered
|
||||
</div>
|
||||
|
||||
<!-- Timer -->
|
||||
<div t-att-class="timerClass">
|
||||
<span class="ec-timer-icon">⏱</span>
|
||||
<span t-esc="formatTime(state.timeRemaining)"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Content Area ─────────────────────────────────── -->
|
||||
<div class="ec-content">
|
||||
|
||||
<!-- Passage panel (only if current question has a passage) -->
|
||||
<t t-if="currentQuestion?.passage">
|
||||
<div class="ec-passage-panel">
|
||||
<div class="ec-passage-title"
|
||||
t-esc="currentQuestion.passage.title or 'Reading Passage'"/>
|
||||
<div class="ec-passage-body"
|
||||
t-esc="currentQuestion.passage.content or currentQuestion.passage"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Question panel -->
|
||||
<div t-att-class="'ec-question-panel' + (currentQuestion?.passage ? '' : ' ec-question-panel--full')">
|
||||
<t t-if="currentQuestion">
|
||||
|
||||
<!-- Question header -->
|
||||
<div class="ec-question-header">
|
||||
<div class="ec-question-number"
|
||||
t-esc="state.currentQuestionIdx + 1"/>
|
||||
<div class="ec-question-type"
|
||||
t-esc="currentQuestion.question_type or 'question'"/>
|
||||
</div>
|
||||
|
||||
<!-- Question stem -->
|
||||
<div class="ec-question-stem"
|
||||
t-esc="currentQuestion.stem"/>
|
||||
|
||||
<!-- Question Renderer (dispatched by type) -->
|
||||
<t t-if="currentQuestion.question_type === 'mcq'">
|
||||
<ExamMCQ question="currentQuestion"
|
||||
answer="state.answers[currentQuestion.id]"
|
||||
onAnswer.bind="setAnswer"/>
|
||||
</t>
|
||||
<t t-elif="currentQuestion.question_type === 'mcq_multi'">
|
||||
<ExamMCQMulti question="currentQuestion"
|
||||
answer="state.answers[currentQuestion.id]"
|
||||
onAnswer.bind="setAnswer"/>
|
||||
</t>
|
||||
<t t-elif="currentQuestion.question_type === 'gap_fill'">
|
||||
<ExamGapFill question="currentQuestion"
|
||||
answer="state.answers[currentQuestion.id]"
|
||||
onAnswer.bind="setAnswer"/>
|
||||
</t>
|
||||
<t t-elif="currentQuestion.question_type === 'tfng'">
|
||||
<ExamTFNG question="currentQuestion"
|
||||
answer="state.answers[currentQuestion.id]"
|
||||
onAnswer.bind="setAnswer"/>
|
||||
</t>
|
||||
<t t-elif="currentQuestion.question_type === 'audio_recording'">
|
||||
<ExamAudioRecorder question="currentQuestion"
|
||||
answer="state.answers[currentQuestion.id]"
|
||||
onAnswer.bind="setAnswer"/>
|
||||
</t>
|
||||
<t t-elif="currentQuestion.question_type === 'writing'">
|
||||
<ExamRichEditor question="currentQuestion"
|
||||
answer="state.answers[currentQuestion.id]"
|
||||
onAnswer.bind="setAnswer"/>
|
||||
</t>
|
||||
<t t-elif="currentQuestion.question_type === 'numerical'">
|
||||
<ExamNumericalInput question="currentQuestion"
|
||||
answer="state.answers[currentQuestion.id]"
|
||||
onAnswer.bind="setAnswer"/>
|
||||
</t>
|
||||
<t t-elif="currentQuestion.question_type === 'code'">
|
||||
<ExamCodeEditor question="currentQuestion"
|
||||
answer="state.answers[currentQuestion.id]"
|
||||
onAnswer.bind="setAnswer"/>
|
||||
</t>
|
||||
<!-- Fallback: plain MCQ -->
|
||||
<t t-else="">
|
||||
<ExamMCQ question="currentQuestion"
|
||||
answer="state.answers[currentQuestion.id]"
|
||||
onAnswer.bind="setAnswer"/>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Bottom Bar ───────────────────────────────────── -->
|
||||
<div class="ec-bottombar">
|
||||
<button class="ec-nav-btn"
|
||||
t-att-disabled="!hasPrev"
|
||||
t-on-click="prevQuestion">
|
||||
← Prev
|
||||
</button>
|
||||
<button class="ec-nav-btn"
|
||||
t-att-disabled="!hasNext"
|
||||
t-on-click="nextQuestion">
|
||||
Next →
|
||||
</button>
|
||||
|
||||
<button t-att-class="'ec-flag-btn' + (isCurrentFlagged ? ' ec-flag-btn--active' : '')"
|
||||
t-on-click="toggleFlag">
|
||||
<span>⚑</span>
|
||||
<span t-if="isCurrentFlagged">Flagged</span>
|
||||
<span t-else="">Flag</span>
|
||||
</button>
|
||||
|
||||
<div class="ec-dots">
|
||||
<t t-foreach="questionDots" t-as="dot" t-key="dot.idx">
|
||||
<div t-att-class="'ec-dot'
|
||||
+ (dot.current ? ' ec-dot--current' : '')
|
||||
+ (dot.answered ? ' ec-dot--answered' : '')
|
||||
+ (dot.flagged ? ' ec-dot--flagged' : '')"
|
||||
t-on-click="() => this.goToQuestion(dot.idx)">
|
||||
<t t-esc="dot.idx + 1"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="ec-bottombar-spacer"/>
|
||||
|
||||
<button class="ec-review-btn" t-on-click="showReviewPanel">
|
||||
Review All
|
||||
</button>
|
||||
|
||||
<t t-if="isLastSection">
|
||||
<button class="ec-submit-btn" t-on-click="confirmSubmit">
|
||||
Submit Exam
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<button class="ec-submit-btn" t-on-click="nextSection">
|
||||
Next Section →
|
||||
</button>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- ── Review Overlay ───────────────────────────────── -->
|
||||
<t t-if="state.showReview">
|
||||
<div class="ec-review-overlay" t-on-click.self="hideReviewPanel">
|
||||
<div class="ec-review-panel">
|
||||
<div class="ec-review-header">
|
||||
<div class="ec-review-title">Review Your Answers</div>
|
||||
<button class="ec-review-close"
|
||||
t-on-click="hideReviewPanel">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="ec-review-stats">
|
||||
<div class="ec-review-stat">
|
||||
<div class="ec-review-stat-val"
|
||||
t-esc="totalAnswered"/>
|
||||
<div class="ec-review-stat-label">Answered</div>
|
||||
</div>
|
||||
<div class="ec-review-stat">
|
||||
<div class="ec-review-stat-val"
|
||||
t-esc="totalAllQuestions - totalAnswered"/>
|
||||
<div class="ec-review-stat-label">Unanswered</div>
|
||||
</div>
|
||||
<div class="ec-review-stat">
|
||||
<div class="ec-review-stat-val"
|
||||
t-esc="totalFlagged"/>
|
||||
<div class="ec-review-stat-label">Flagged</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="ec-review-legend">
|
||||
<div class="ec-legend-item">
|
||||
<div class="ec-legend-dot ec-legend-dot--answered"/>
|
||||
<span>Answered</span>
|
||||
</div>
|
||||
<div class="ec-legend-item">
|
||||
<div class="ec-legend-dot ec-legend-dot--flagged"/>
|
||||
<span>Flagged</span>
|
||||
</div>
|
||||
<div class="ec-legend-item">
|
||||
<div class="ec-legend-dot ec-legend-dot--unanswered"/>
|
||||
<span>Unanswered</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section grids -->
|
||||
<t t-foreach="reviewSections" t-as="section" t-key="section.sIdx">
|
||||
<div class="ec-review-section">
|
||||
<div class="ec-review-section-title"
|
||||
t-esc="section.name or section.skill or ('Section ' + (section.sIdx + 1))"/>
|
||||
<div class="ec-review-grid">
|
||||
<t t-foreach="section.questions" t-as="q" t-key="q.id">
|
||||
<div t-att-class="'ec-review-cell'
|
||||
+ (q.answered ? ' ec-review-cell--answered' : ' ec-review-cell--unanswered')
|
||||
+ (q.flagged ? ' ec-review-cell--flagged' : '')"
|
||||
t-on-click="() => this.reviewGoTo(q.sIdx, q.qIdx)">
|
||||
<t t-esc="q.qIdx + 1"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div class="ec-review-footer">
|
||||
<button class="ec-modal-cancel"
|
||||
t-on-click="hideReviewPanel">
|
||||
Back to Exam
|
||||
</button>
|
||||
<button class="ec-modal-confirm"
|
||||
t-on-click="confirmSubmit">
|
||||
Submit Exam
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ── Submit Confirmation Modal ────────────────────── -->
|
||||
<t t-if="state.showConfirmSubmit">
|
||||
<div class="ec-modal-overlay" t-on-click.self="cancelSubmit">
|
||||
<div class="ec-modal">
|
||||
<div class="ec-modal-icon">📝</div>
|
||||
<div class="ec-modal-title">Submit Exam?</div>
|
||||
<div class="ec-modal-body">
|
||||
You have answered <b t-esc="totalAnswered"/> out of
|
||||
<b t-esc="totalAllQuestions"/> questions.
|
||||
<t t-if="totalAllQuestions - totalAnswered > 0">
|
||||
<br/>
|
||||
<span style="color: var(--ec-warning);">
|
||||
<t t-esc="totalAllQuestions - totalAnswered"/>
|
||||
question(s) are still unanswered.
|
||||
</span>
|
||||
</t>
|
||||
<br/><br/>
|
||||
Once submitted, you cannot change your answers.
|
||||
</div>
|
||||
<div class="ec-modal-actions">
|
||||
<button class="ec-modal-cancel"
|
||||
t-on-click="cancelSubmit">
|
||||
Go Back
|
||||
</button>
|
||||
<button class="ec-modal-confirm"
|
||||
t-on-click="submitExam">
|
||||
Submit Now
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,158 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
PlacementApp — CAT adaptive placement test
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.PlacementApp">
|
||||
<div class="ec-exam-fullscreen" t-ref="root">
|
||||
|
||||
<!-- Loading -->
|
||||
<t t-if="state.loading">
|
||||
<div class="ec-loading">
|
||||
<div class="ec-spinner"/>
|
||||
<div class="ec-loading-text">Preparing your placement test...</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Error -->
|
||||
<t t-elif="state.error and !state.currentQuestion">
|
||||
<div class="ec-loading">
|
||||
<div class="ec-loading-text" style="color: var(--ec-danger);"
|
||||
t-esc="state.error"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Completed — Show results -->
|
||||
<t t-elif="state.completed">
|
||||
<div class="ec-topbar">
|
||||
<div class="ec-topbar-title">Placement Test — Complete</div>
|
||||
</div>
|
||||
<div class="ec-placement-center">
|
||||
<div class="ec-placement-card">
|
||||
<div class="ec-placement-results">
|
||||
<div class="ec-placement-results-icon">✓</div>
|
||||
<div class="ec-placement-results-title">
|
||||
Placement Complete!
|
||||
</div>
|
||||
<div class="ec-placement-results-level"
|
||||
t-esc="cefrLabel"/>
|
||||
<div class="ec-placement-results-desc">
|
||||
Based on your responses, you have been placed at
|
||||
the <b t-esc="cefrLabel"/> level.
|
||||
Your personalized learning path is now ready.
|
||||
</div>
|
||||
<button class="ec-placement-results-btn"
|
||||
t-on-click="goToDashboard">
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Active Question -->
|
||||
<t t-else="">
|
||||
|
||||
<!-- Top bar with progress -->
|
||||
<div class="ec-topbar">
|
||||
<div class="ec-topbar-title">Placement Test</div>
|
||||
|
||||
<div class="ec-placement-progress">
|
||||
<div class="ec-progress-bar-container">
|
||||
<div class="ec-progress-bar-fill"
|
||||
t-att-style="progressStyle"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ec-placement-info">
|
||||
Question <t t-esc="state.questionsAnswered + 1"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question card -->
|
||||
<div class="ec-placement-center">
|
||||
<div class="ec-placement-card" t-if="state.currentQuestion">
|
||||
<div class="ec-placement-card-header">
|
||||
<div class="ec-question-type"
|
||||
t-esc="state.currentQuestion.question_type or 'question'"/>
|
||||
<div class="ec-question-type"
|
||||
t-esc="state.currentQuestion.skill or ''"/>
|
||||
</div>
|
||||
<div class="ec-placement-card-body">
|
||||
<!-- Stem -->
|
||||
<div class="ec-question-stem"
|
||||
t-esc="state.currentQuestion.stem"/>
|
||||
|
||||
<!-- MCQ options (default for placement) -->
|
||||
<t t-if="questionType === 'mcq' or questionType === 'multiple_choice'">
|
||||
<div class="ec-option-list">
|
||||
<t t-foreach="questionOptions" t-as="opt" t-key="opt.key">
|
||||
<div t-att-class="'ec-option' + (state.answer === opt.key ? ' ec-option--selected' : '')"
|
||||
t-on-click="() => this.selectOption(opt.key)">
|
||||
<div class="ec-option-radio"/>
|
||||
<span class="ec-option-key" t-esc="opt.key + '.'"/>
|
||||
<span class="ec-option-label" t-esc="opt.label"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- TFNG -->
|
||||
<t t-elif="questionType === 'tfng'">
|
||||
<div class="ec-tfng-options">
|
||||
<div t-att-class="'ec-tfng-btn' + (state.answer === 'true' ? ' ec-tfng-btn--selected' : '')"
|
||||
t-on-click="() => this.selectOption('true')">True</div>
|
||||
<div t-att-class="'ec-tfng-btn' + (state.answer === 'false' ? ' ec-tfng-btn--selected' : '')"
|
||||
t-on-click="() => this.selectOption('false')">False</div>
|
||||
<div t-att-class="'ec-tfng-btn' + (state.answer === 'not_given' ? ' ec-tfng-btn--selected' : '')"
|
||||
t-on-click="() => this.selectOption('not_given')">Not Given</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Gap fill -->
|
||||
<t t-elif="questionType === 'gap_fill'">
|
||||
<input type="text"
|
||||
class="ec-gap-input"
|
||||
t-att-value="state.answer or ''"
|
||||
t-on-input="(ev) => this.state.answer = ev.target.value"
|
||||
placeholder="Type your answer..."/>
|
||||
</t>
|
||||
|
||||
<!-- Fallback: text input -->
|
||||
<t t-else="">
|
||||
<div class="ec-option-list">
|
||||
<t t-foreach="questionOptions" t-as="opt" t-key="opt.key">
|
||||
<div t-att-class="'ec-option' + (state.answer === opt.key ? ' ec-option--selected' : '')"
|
||||
t-on-click="() => this.selectOption(opt.key)">
|
||||
<div class="ec-option-radio"/>
|
||||
<span class="ec-option-key" t-esc="opt.key + '.'"/>
|
||||
<span class="ec-option-label" t-esc="opt.label"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Error message -->
|
||||
<t t-if="state.error">
|
||||
<div style="color: var(--ec-danger); margin-top: 12px; font-size: 14px;"
|
||||
t-esc="state.error"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="ec-placement-actions">
|
||||
<button class="ec-placement-next"
|
||||
t-att-disabled="!hasAnswer or state.submitting"
|
||||
t-on-click="submitAnswer">
|
||||
<t t-if="state.submitting">Submitting...</t>
|
||||
<t t-else="">Next Question →</t>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,188 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamMCQ — Single-choice radio buttons
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamMCQ">
|
||||
<div class="ec-option-list">
|
||||
<t t-foreach="options" t-as="opt" t-key="opt.key">
|
||||
<div t-att-class="'ec-option' + (props.answer === opt.key ? ' ec-option--selected' : '')"
|
||||
t-on-click="() => this.selectOption(opt.key)">
|
||||
<div class="ec-option-radio"/>
|
||||
<span class="ec-option-key" t-esc="opt.key + '.'"/>
|
||||
<span class="ec-option-label" t-esc="opt.label"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamMCQMulti — Multi-choice checkboxes
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamMCQMulti">
|
||||
<div class="ec-option-list">
|
||||
<t t-foreach="options" t-as="opt" t-key="opt.key">
|
||||
<div t-att-class="'ec-option' + (this.isChecked(opt.key) ? ' ec-option--selected' : '')"
|
||||
t-on-click="() => this.toggleOption(opt.key)">
|
||||
<div class="ec-option-checkbox"/>
|
||||
<span class="ec-option-key" t-esc="opt.key + '.'"/>
|
||||
<span class="ec-option-label" t-esc="opt.label"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamGapFill — Inline text inputs
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamGapFill">
|
||||
<div class="ec-gap-fill">
|
||||
<t t-foreach="gaps" t-as="gap" t-key="gap_index">
|
||||
<div class="ec-gap-row">
|
||||
<span class="ec-gap-label" t-esc="(gap_index + 1) + '.'"/>
|
||||
<input type="text"
|
||||
class="ec-gap-input"
|
||||
t-att-placeholder="gap.hint or 'Type your answer...'"
|
||||
t-att-value="this.gapValue(gap_index)"
|
||||
t-on-input="(ev) => this.onGapInput(gap_index, ev)"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamTFNG — True / False / Not Given selector
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamTFNG">
|
||||
<div class="ec-tfng-options">
|
||||
<t t-foreach="choices" t-as="choice" t-key="choice.key">
|
||||
<div t-att-class="'ec-tfng-btn' + (props.answer === choice.key ? ' ec-tfng-btn--selected' : '')"
|
||||
t-on-click="() => this.select(choice.key)">
|
||||
<t t-esc="choice.label"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamAudioRecorder — Record audio (speaking tasks)
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamAudioRecorder">
|
||||
<div class="ec-audio-recorder">
|
||||
<t t-if="state.error">
|
||||
<div class="ec-audio-error" t-esc="state.error"/>
|
||||
</t>
|
||||
|
||||
<t t-if="!state.recording and !state.recordedUrl">
|
||||
<button class="ec-record-btn ec-record-btn--start"
|
||||
t-on-click="startRecording">
|
||||
🎙
|
||||
</button>
|
||||
<div class="ec-audio-hint">Click to start recording</div>
|
||||
</t>
|
||||
|
||||
<t t-if="state.recording">
|
||||
<div class="ec-audio-duration"
|
||||
t-esc="formatDuration(state.duration)"/>
|
||||
<button class="ec-record-btn ec-record-btn--stop"
|
||||
t-on-click="stopRecording">
|
||||
■
|
||||
</button>
|
||||
<div class="ec-audio-hint">Recording... Click to stop</div>
|
||||
</t>
|
||||
|
||||
<t t-if="state.recordedUrl and !state.recording">
|
||||
<div class="ec-audio-duration"
|
||||
t-esc="formatDuration(state.duration)"/>
|
||||
<audio class="ec-audio-playback"
|
||||
controls=""
|
||||
t-att-src="state.recordedUrl"/>
|
||||
<button class="ec-record-btn ec-record-btn--start"
|
||||
t-on-click="startRecording">
|
||||
🎙
|
||||
</button>
|
||||
<div class="ec-audio-hint">Click to re-record</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamRichEditor — Writing task editor
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamRichEditor">
|
||||
<div class="ec-rich-editor">
|
||||
<textarea class="ec-editor-textarea"
|
||||
t-ref="editor"
|
||||
t-att-value="props.answer or ''"
|
||||
t-on-input="onInput"
|
||||
placeholder="Write your response here..."/>
|
||||
<div class="ec-editor-footer">
|
||||
<span t-att-class="'ec-word-count'
|
||||
+ (state.wordCount < minWords ? ' ec-word-count--low' : '')
|
||||
+ (state.wordCount >= minWords and state.wordCount <= maxWords ? ' ec-word-count--ok' : '')
|
||||
+ (state.wordCount > maxWords ? ' ec-word-count--over' : '')">
|
||||
<t t-esc="state.wordCount"/> words
|
||||
</span>
|
||||
<span class="ec-word-range">
|
||||
Target: <t t-esc="minWords"/>–<t t-esc="maxWords"/> words
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamReadingPassage — Scrollable passage display
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamReadingPassage">
|
||||
<div class="ec-passage-panel">
|
||||
<div class="ec-passage-title" t-esc="passageTitle"/>
|
||||
<div class="ec-passage-body" t-esc="passageContent"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamNumericalInput — Number input with tolerance
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamNumericalInput">
|
||||
<div>
|
||||
<div class="ec-numerical-group">
|
||||
<input type="number"
|
||||
class="ec-numerical-input"
|
||||
step="any"
|
||||
t-att-value="props.answer or ''"
|
||||
t-on-input="onInput"
|
||||
placeholder="Enter value"/>
|
||||
<span t-if="unit" class="ec-numerical-unit" t-esc="unit"/>
|
||||
</div>
|
||||
<div t-if="tolerance" class="ec-numerical-tolerance">
|
||||
Accepted tolerance: ± <t t-esc="tolerance"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════
|
||||
ExamCodeEditor — Code editing textarea
|
||||
═══════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<t t-name="encoach_exam_session.ExamCodeEditor">
|
||||
<div class="ec-code-editor">
|
||||
<div class="ec-code-lang" t-esc="language"/>
|
||||
<textarea class="ec-code-textarea"
|
||||
t-ref="codeEditor"
|
||||
t-att-value="props.answer or starterCode"
|
||||
t-on-input="onInput"
|
||||
placeholder="Write your code here..."
|
||||
spellcheck="false"/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
Reference in New Issue
Block a user