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:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
commit 907a5c0e92
331 changed files with 23511 additions and 0 deletions

View File

@@ -0,0 +1,36 @@
.o_content_pool .cp-split-panel {
display: flex;
gap: 1rem;
min-height: 65vh;
}
.o_content_pool .cp-question-list {
flex: 0 0 60%;
min-width: 0;
}
.o_content_pool .cp-selected-panel {
flex: 0 0 calc(40% - 1rem);
min-width: 0;
}
.o_content_pool .cp-scroll {
max-height: 55vh;
overflow-y: auto;
}
.o_content_pool .cp-scroll-selected {
max-height: 45vh;
overflow-y: auto;
}
.o_content_pool .cursor-pointer {
cursor: pointer;
}
.o_content_pool .table-hover tbody tr:hover {
background-color: rgba(13, 110, 253, 0.04);
}
.o_content_pool .table-hover tbody tr.table-active {
background-color: rgba(13, 110, 253, 0.08);
}
.o_content_pool .form-check-input {
cursor: pointer;
}
.o_content_pool .card {
border-radius: 0.5rem;
}

View File

@@ -0,0 +1,149 @@
/** @odoo-module */
import { Component, onWillStart, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
class ContentPoolBrowser extends Component {
static template = "encoach_exam_template.ContentPool";
static components = { Layout };
static props = ["*"];
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.state = useState({
loading: true,
filters: { skill: "", difficulty: "", status: "active", search: "" },
questions: [],
selectedQuestions: [],
totalCount: 0,
page: 1,
pageSize: 20,
});
onWillStart(() => this.loadQuestions());
}
async loadQuestions() {
this.state.loading = true;
const domain = this._buildDomain();
try {
const [questions, count] = await Promise.all([
this.orm.searchRead(
"encoach.question",
domain,
["skill", "question_type", "stem", "difficulty", "marks", "irt_b", "subject_id", "topic_id", "status"],
{
limit: this.state.pageSize,
offset: (this.state.page - 1) * this.state.pageSize,
order: "skill, difficulty",
},
),
this.orm.searchCount("encoach.question", domain),
]);
this.state.questions = questions;
this.state.totalCount = count;
} catch (e) {
console.error("Content pool load error:", e);
} finally {
this.state.loading = false;
}
}
_buildDomain() {
const domain = [];
const f = this.state.filters;
if (f.status) domain.push(["status", "=", f.status]);
if (f.skill) domain.push(["skill", "=", f.skill]);
if (f.difficulty) domain.push(["difficulty", "=", f.difficulty]);
if (f.search) domain.push(["stem", "ilike", f.search]);
return domain;
}
onFilterChange(field, ev) {
this.state.filters[field] = ev.target.value;
this.state.page = 1;
this.loadQuestions();
}
onSearchInput(ev) {
this.state.filters.search = ev.target.value;
}
onSearchKeydown(ev) {
if (ev.key === "Enter") {
this.state.page = 1;
this.loadQuestions();
}
}
applySearch() {
this.state.page = 1;
this.loadQuestions();
}
toggleQuestion(question) {
const idx = this.state.selectedQuestions.findIndex(q => q.id === question.id);
if (idx >= 0) {
this.state.selectedQuestions.splice(idx, 1);
} else {
this.state.selectedQuestions.push({ ...question });
}
}
isSelected(questionId) {
return this.state.selectedQuestions.some(q => q.id === questionId);
}
removeSelected(questionId) {
const idx = this.state.selectedQuestions.findIndex(q => q.id === questionId);
if (idx >= 0) this.state.selectedQuestions.splice(idx, 1);
}
clearSelection() {
this.state.selectedQuestions = [];
}
nextPage() {
if (this.state.page * this.state.pageSize < this.state.totalCount) {
this.state.page++;
this.loadQuestions();
}
}
prevPage() {
if (this.state.page > 1) {
this.state.page--;
this.loadQuestions();
}
}
truncate(text, len) {
if (!text) return "";
return text.length > len ? text.substring(0, len) + "..." : text;
}
skillLabel(val) {
const map = { listening: "Listening", reading: "Reading", writing: "Writing", speaking: "Speaking", grammar: "Grammar", vocabulary: "Vocabulary" };
return map[val] || val;
}
difficultyClass(val) {
return { easy: "bg-success", medium: "bg-warning text-dark", hard: "bg-danger" }[val] || "bg-secondary";
}
get totalPages() {
return Math.max(1, Math.ceil(this.state.totalCount / this.state.pageSize));
}
get selectedCount() {
return this.state.selectedQuestions.length;
}
get selectedMarks() {
return this.state.selectedQuestions.reduce((s, q) => s + (q.marks || 1), 0);
}
}
registry.category("actions").add("encoach_content_pool", ContentPoolBrowser);

View File

@@ -0,0 +1,207 @@
/** @odoo-module */
import { Component, onWillStart, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { Layout } from "@web/search/layout";
class ExamValidationReport extends Component {
static template = "encoach_exam_template.ExamValidation";
static components = { Layout };
static props = ["*"];
setup() {
this.orm = useService("orm");
this.action = useService("action");
this.state = useState({
loading: true,
examId: null,
exam: null,
checks: [],
overallValid: false,
passCount: 0,
failCount: 0,
warnCount: 0,
expandedSections: {},
});
onWillStart(async () => {
const ctx = this.props.action?.context || {};
this.state.examId = ctx.active_id || ctx.exam_id || null;
if (this.state.examId) {
await this.runValidation();
} else {
await this.loadExamList();
}
});
}
async loadExamList() {
try {
this.state.examList = await this.orm.searchRead(
"encoach.exam.template",
[],
["name", "code", "type", "total_time_min"],
{ limit: 50, order: "name" },
);
} catch (e) {
console.error("Failed to load exam list:", e);
} finally {
this.state.loading = false;
}
}
async selectExam(examId) {
this.state.examId = examId;
this.state.loading = true;
await this.runValidation();
}
async runValidation() {
this.state.loading = true;
try {
const exams = await this.orm.searchRead(
"encoach.exam.template",
[["id", "=", this.state.examId]],
["name", "code", "type", "total_time_min", "pass_threshold", "structure", "subject_id"],
);
this.state.exam = exams.length ? exams[0] : null;
if (!this.state.exam) {
this.state.loading = false;
return;
}
const questions = await this.orm.searchRead(
"encoach.question",
[],
["skill", "difficulty", "marks", "status"],
);
this.state.checks = this._runChecks(this.state.exam, questions);
this.state.passCount = this.state.checks.filter(c => c.status === "pass").length;
this.state.failCount = this.state.checks.filter(c => c.status === "fail").length;
this.state.warnCount = this.state.checks.filter(c => c.status === "warn").length;
this.state.overallValid = this.state.failCount === 0;
} catch (e) {
console.error("Validation error:", e);
} finally {
this.state.loading = false;
}
}
_runChecks(exam, questions) {
const checks = [];
const active = questions.filter(q => q.status === "active");
const skills = ["listening", "reading", "writing", "speaking", "grammar", "vocabulary"];
checks.push({
id: "name",
category: "Template",
label: "Exam name is set",
status: exam.name ? "pass" : "fail",
detail: exam.name || "Missing exam name",
});
checks.push({
id: "time",
category: "Template",
label: "Time limit is configured",
status: exam.total_time_min > 0 ? "pass" : "fail",
detail: exam.total_time_min ? `${exam.total_time_min} minutes` : "No time limit set",
});
checks.push({
id: "threshold",
category: "Template",
label: "Pass threshold defined",
status: exam.pass_threshold > 0 ? "pass" : "warn",
detail: exam.pass_threshold ? `${exam.pass_threshold}%` : "No threshold — all scores will pass",
});
const poolSize = active.length;
checks.push({
id: "pool_size",
category: "Content Pool",
label: "Active questions available (min 10)",
status: poolSize >= 10 ? "pass" : poolSize >= 5 ? "warn" : "fail",
detail: `${poolSize} active questions in the pool`,
});
for (const skill of skills) {
const count = active.filter(q => q.skill === skill).length;
if (count > 0) {
checks.push({
id: `skill_${skill}`,
category: "Skill Coverage",
label: `${skill.charAt(0).toUpperCase() + skill.slice(1)} questions available`,
status: count >= 3 ? "pass" : "warn",
detail: `${count} active questions for ${skill}`,
});
}
}
const difficulties = ["easy", "medium", "hard"];
for (const d of difficulties) {
const count = active.filter(q => q.difficulty === d).length;
checks.push({
id: `diff_${d}`,
category: "Difficulty Balance",
label: `${d.charAt(0).toUpperCase() + d.slice(1)} questions present`,
status: count > 0 ? "pass" : "warn",
detail: `${count} ${d} questions`,
});
}
const totalMarks = active.reduce((s, q) => s + (q.marks || 1), 0);
checks.push({
id: "marks",
category: "Marks",
label: "Sufficient total marks in pool",
status: totalMarks >= 20 ? "pass" : totalMarks >= 10 ? "warn" : "fail",
detail: `${totalMarks} total marks available`,
});
return checks;
}
toggleSection(category) {
this.state.expandedSections[category] = !this.state.expandedSections[category];
}
isSectionExpanded(category) {
return this.state.expandedSections[category] !== false;
}
checksForCategory(category) {
return this.state.checks.filter(c => c.category === category);
}
get categories() {
const seen = new Set();
return this.state.checks.reduce((arr, c) => {
if (!seen.has(c.category)) {
seen.add(c.category);
arr.push(c.category);
}
return arr;
}, []);
}
categoryStatus(category) {
const items = this.checksForCategory(category);
if (items.some(c => c.status === "fail")) return "fail";
if (items.some(c => c.status === "warn")) return "warn";
return "pass";
}
goBack() {
this.state.examId = null;
this.state.exam = null;
this.state.checks = [];
this.state.loading = true;
this.loadExamList();
}
}
registry.category("actions").add("encoach_exam_validation", ExamValidationReport);

View File

@@ -0,0 +1,224 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_exam_template.ContentPool">
<Layout display="{ controlPanel: {} }">
<div class="o_content_pool">
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-3">
<h2 class="mb-0 me-3">Content Pool</h2>
<span class="badge bg-primary fs-6" t-if="state.totalCount">
<t t-esc="state.totalCount"/> questions
</span>
</div>
<!-- Filter Bar -->
<div class="card shadow-sm mb-3">
<div class="card-body py-2">
<div class="row g-2 align-items-end">
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">Skill</label>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onFilterChange('skill', ev)">
<option value="">All Skills</option>
<option value="listening">Listening</option>
<option value="reading">Reading</option>
<option value="writing">Writing</option>
<option value="speaking">Speaking</option>
<option value="grammar">Grammar</option>
<option value="vocabulary">Vocabulary</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small fw-bold mb-1">Difficulty</label>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onFilterChange('difficulty', ev)">
<option value="">All</option>
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small fw-bold mb-1">Status</label>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onFilterChange('status', ev)">
<option value="">All</option>
<option value="active" selected="selected">Active</option>
<option value="draft">Draft</option>
<option value="retired">Retired</option>
<option value="flagged">Flagged</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small fw-bold mb-1">Search</label>
<div class="input-group input-group-sm">
<input type="text" class="form-control"
placeholder="Search question text..."
t-on-input="onSearchInput"
t-on-keydown="onSearchKeydown"/>
<button class="btn btn-outline-primary" t-on-click="applySearch">
<i class="fa fa-search"/>
</button>
</div>
</div>
<div class="col-md-1 text-end">
<button class="btn btn-sm btn-outline-secondary" t-on-click="() => { this.state.filters = { skill: '', difficulty: '', status: 'active', search: '' }; this.state.page = 1; this.loadQuestions(); }">
<i class="fa fa-refresh"/>
</button>
</div>
</div>
</div>
</div>
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
<p class="mt-2 text-muted">Loading questions...</p>
</div>
</t>
<t t-else="">
<div class="cp-split-panel">
<!-- Left: Question List -->
<div class="cp-question-list">
<div class="card shadow-sm h-100">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0">Available Questions</h6>
<small class="text-muted">
Page <t t-esc="state.page"/> of <t t-esc="totalPages"/>
</small>
</div>
<div class="card-body p-0 cp-scroll">
<table class="table table-hover table-sm mb-0">
<thead class="table-light sticky-top">
<tr>
<th style="width:40px"/>
<th>Question</th>
<th style="width:90px">Skill</th>
<th style="width:80px">Difficulty</th>
<th style="width:55px">Marks</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.questions" t-as="q" t-key="q.id">
<tr t-att-class="{ 'table-active': isSelected(q.id) }"
class="cursor-pointer"
t-on-click="() => this.toggleQuestion(q)">
<td class="text-center">
<input type="checkbox"
class="form-check-input"
t-att-checked="isSelected(q.id)"
t-on-click.stop="() => this.toggleQuestion(q)"/>
</td>
<td>
<span t-esc="truncate(q.stem, 80)"/>
<br/>
<small class="text-muted" t-esc="q.question_type"/>
</td>
<td>
<span class="badge bg-primary bg-opacity-75" t-esc="skillLabel(q.skill)"/>
</td>
<td>
<span class="badge" t-att-class="difficultyClass(q.difficulty)" t-esc="q.difficulty"/>
</td>
<td class="text-center fw-bold" t-esc="q.marks"/>
</tr>
</t>
<t t-if="!state.questions.length">
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="fa fa-inbox fa-2x mb-2 d-block"/>
No questions match your filters
</td>
</tr>
</t>
</tbody>
</table>
</div>
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
<small class="text-muted">
Showing <t t-esc="Math.min((state.page - 1) * state.pageSize + 1, state.totalCount)"/>-<t t-esc="Math.min(state.page * state.pageSize, state.totalCount)"/> of <t t-esc="state.totalCount"/>
</small>
<div>
<button class="btn btn-sm btn-outline-secondary me-1"
t-att-disabled="state.page &lt;= 1"
t-on-click="prevPage">
<i class="fa fa-chevron-left"/>
</button>
<button class="btn btn-sm btn-outline-secondary"
t-att-disabled="state.page >= totalPages"
t-on-click="nextPage">
<i class="fa fa-chevron-right"/>
</button>
</div>
</div>
</div>
</div>
<!-- Right: Selected Questions -->
<div class="cp-selected-panel">
<div class="card shadow-sm h-100 border-primary">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="fa fa-check-square me-1"/>
Selected (<t t-esc="selectedCount"/>)
</h6>
<button class="btn btn-sm btn-outline-light"
t-on-click="clearSelection"
t-if="selectedCount > 0">
Clear All
</button>
</div>
<div class="card-body p-3">
<!-- Summary -->
<div class="row g-2 mb-3" t-if="selectedCount > 0">
<div class="col-6">
<div class="bg-light rounded p-2 text-center">
<div class="fs-4 fw-bold text-primary" t-esc="selectedCount"/>
<small class="text-muted">Questions</small>
</div>
</div>
<div class="col-6">
<div class="bg-light rounded p-2 text-center">
<div class="fs-4 fw-bold text-success" t-esc="selectedMarks"/>
<small class="text-muted">Total Marks</small>
</div>
</div>
</div>
<!-- Selected Items -->
<div class="cp-scroll-selected">
<t t-foreach="state.selectedQuestions" t-as="sq" t-key="sq.id">
<div class="d-flex align-items-start border rounded p-2 mb-2 bg-white">
<div class="flex-grow-1 me-2">
<small class="d-block" t-esc="truncate(sq.stem, 60)"/>
<span class="badge bg-primary bg-opacity-50 me-1" style="font-size:0.65rem" t-esc="skillLabel(sq.skill)"/>
<span class="badge" t-att-class="difficultyClass(sq.difficulty)" style="font-size:0.65rem" t-esc="sq.difficulty"/>
<span class="badge bg-dark bg-opacity-50" style="font-size:0.65rem">
<t t-esc="sq.marks"/> mk
</span>
</div>
<button class="btn btn-sm btn-outline-danger flex-shrink-0"
t-on-click.stop="() => this.removeSelected(sq.id)"
title="Remove">
<i class="fa fa-times"/>
</button>
</div>
</t>
<t t-if="selectedCount === 0">
<div class="text-center text-muted py-4">
<i class="fa fa-hand-pointer-o fa-2x mb-2 d-block"/>
<small>Click questions to select them</small>
</div>
</t>
</div>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
</Layout>
</t>
</templates>

View File

@@ -0,0 +1,132 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_exam_template.ExamValidation">
<Layout display="{ controlPanel: {} }">
<div class="o_exam_validation">
<div class="container-fluid py-3">
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
<p class="mt-2 text-muted">Running validation...</p>
</div>
</t>
<!-- Exam selector (when no exam_id in context) -->
<t t-elif="!state.examId and state.examList">
<h2 class="mb-3">Exam Validation Report</h2>
<p class="text-muted mb-4">Select an exam template to validate.</p>
<div class="row g-3">
<t t-foreach="state.examList" t-as="ex" t-key="ex.id">
<div class="col-lg-4 col-md-6">
<div class="card shadow-sm h-100 cursor-pointer ev-exam-card"
t-on-click="() => this.selectExam(ex.id)">
<div class="card-body">
<h6 class="card-title" t-esc="ex.name"/>
<span class="badge bg-secondary me-1" t-if="ex.code" t-esc="ex.code"/>
<span class="badge bg-info" t-esc="ex.type"/>
<div class="mt-2 text-muted small" t-if="ex.total_time_min">
<i class="fa fa-clock-o me-1"/>
<t t-esc="ex.total_time_min"/> min
</div>
</div>
</div>
</div>
</t>
</div>
</t>
<!-- Validation Results -->
<t t-elif="state.exam">
<div class="d-flex align-items-center mb-4">
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
t-if="!this.props.action?.context?.active_id">
<i class="fa fa-arrow-left me-1"/> Back
</button>
<div>
<h2 class="mb-0">
Validation: <t t-esc="state.exam.name"/>
</h2>
<small class="text-muted" t-if="state.exam.code">
Code: <t t-esc="state.exam.code"/>
</small>
</div>
</div>
<!-- Overall Status Banner -->
<div class="alert d-flex align-items-center mb-4"
t-att-class="{ 'alert-success': state.overallValid, 'alert-danger': !state.overallValid }">
<i class="fa fa-3x me-3"
t-att-class="{ 'fa-check-circle': state.overallValid, 'fa-exclamation-triangle': !state.overallValid }"/>
<div>
<h5 class="mb-1" t-if="state.overallValid">All Checks Passed</h5>
<h5 class="mb-1" t-else="">Validation Issues Found</h5>
<span>
<span class="badge bg-success me-1"><t t-esc="state.passCount"/> passed</span>
<span class="badge bg-danger me-1" t-if="state.failCount"><t t-esc="state.failCount"/> failed</span>
<span class="badge bg-warning text-dark" t-if="state.warnCount"><t t-esc="state.warnCount"/> warnings</span>
</span>
</div>
</div>
<!-- Check Categories -->
<t t-foreach="categories" t-as="cat" t-key="cat">
<div class="card shadow-sm mb-3">
<div class="card-header bg-white d-flex justify-content-between align-items-center cursor-pointer py-2"
t-on-click="() => this.toggleSection(cat)">
<div class="d-flex align-items-center">
<i class="fa me-2"
t-att-class="{
'fa-check-circle text-success': categoryStatus(cat) === 'pass',
'fa-exclamation-circle text-danger': categoryStatus(cat) === 'fail',
'fa-exclamation-triangle text-warning': categoryStatus(cat) === 'warn'
}"/>
<h6 class="mb-0" t-esc="cat"/>
</div>
<i class="fa"
t-att-class="{ 'fa-chevron-down': isSectionExpanded(cat), 'fa-chevron-right': !isSectionExpanded(cat) }"/>
</div>
<div class="card-body p-0" t-if="isSectionExpanded(cat)">
<table class="table table-sm mb-0">
<tbody>
<t t-foreach="checksForCategory(cat)" t-as="check" t-key="check.id">
<tr>
<td style="width:40px" class="text-center">
<i class="fa"
t-att-class="{
'fa-check text-success': check.status === 'pass',
'fa-times text-danger': check.status === 'fail',
'fa-exclamation text-warning': check.status === 'warn'
}"/>
</td>
<td>
<span t-esc="check.label"/>
</td>
<td class="text-muted small text-end" t-esc="check.detail"/>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</t>
<!-- Action Buttons -->
<div class="d-flex gap-2 mt-4">
<button class="btn btn-primary" t-on-click="() => this.runValidation()">
<i class="fa fa-refresh me-1"/> Re-run Validation
</button>
</div>
</t>
<t t-elif="!state.examId">
<div class="text-center py-5 text-muted">
<i class="fa fa-clipboard fa-3x mb-3 d-block"/>
<p>No exam template found. Open this from an exam template record.</p>
</div>
</t>
</div>
</div>
</Layout>
</t>
</templates>