feat: initial backend codebase — EnCoach v3

Complete Odoo 19 backend with 25 custom addons:
- encoach_core: user/entity/role management
- encoach_api: REST API + JWT auth
- encoach_ai: OpenAI integration, AI settings, generation
- encoach_ai_course: AI-powered English & IELTS course generation
- encoach_exam_template/session: exam creation, structures, sessions
- encoach_scoring: AI auto-grading + manual approval
- encoach_vector: pgvector RAG integration
- encoach_adaptive: adaptive learning engine
- encoach_placement: placement testing
- encoach_taxonomy/resources: content taxonomy & resource management
- Plus 14 more modules for courses, branding, portal, etc.

Includes docs: user guide, generation report, developer workflow.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-11 15:44:20 +04:00
commit 982d4bca30
371 changed files with 35211 additions and 0 deletions

View File

@@ -0,0 +1 @@
from . import controllers

View File

@@ -0,0 +1,34 @@
{
'name': 'EnCoach Student Portal',
'version': '19.0.1.0',
'category': 'Education/Website',
'summary': 'Student-facing portal pages with QWeb templates and OWL components',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': [
'website',
'portal',
'encoach_core',
'encoach_signup',
'encoach_placement',
'encoach_scoring',
'encoach_course_gen',
'encoach_exam_template',
],
'data': [
'security/ir.model.access.csv',
'views/templates.xml',
'views/portal_menus.xml',
],
'assets': {
'web.assets_frontend': [
'encoach_portal/static/src/css/portal.css',
'encoach_portal/static/src/js/password_strength.js',
'encoach_portal/static/src/js/onboarding_wizard.js',
'encoach_portal/static/src/xml/onboarding_wizard.xml',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1 @@
from . import portal

View File

@@ -0,0 +1,225 @@
import json
import logging
from odoo import http, fields
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
_logger = logging.getLogger(__name__)
class EncoachPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'course_count' in counters:
values['course_count'] = request.env['op.course'].sudo().search_count([])
return values
@http.route('/register', type='http', auth='public', website=True)
def register_page(self, **kw):
captcha_key = request.env['ir.config_parameter'].sudo().get_param(
'encoach.captcha_site_key', '',
)
return request.render('encoach_portal.register', {
'captcha_site_key': captcha_key,
})
@http.route('/verify-email', type='http', auth='public', website=True)
def verify_email_page(self, **kw):
return request.render('encoach_portal.verify_email', {
'email': kw.get('email', ''),
})
@http.route('/onboarding', type='http', auth='user', website=True)
def onboarding_page(self, **kw):
user = request.env.user
profile = request.env['encoach.student.profile'].sudo().search(
[('user_id', '=', user.id)], limit=1,
)
return request.render('encoach_portal.onboarding', {
'user': user,
'profile': profile,
})
# ------------------------------------------------------------------
# JSON-RPC: /encoach/onboarding/goals (called by OWL wizard)
# ------------------------------------------------------------------
@http.route('/encoach/onboarding/goals', type='json', auth='user')
def onboarding_goals_rpc(self, **kw):
Template = request.env['encoach.exam.template'].sudo()
templates = Template.search([('active', '=', True)])
seen = set()
goals = []
icon_map = {
'ielts': 'fa-language', 'general_english': 'fa-comments',
'general english': 'fa-comments', 'academic': 'fa-graduation-cap',
'math': 'fa-calculator', 'mathematics': 'fa-calculator',
'it': 'fa-laptop', 'information technology': 'fa-laptop',
}
for tpl in templates:
subj = tpl.subject_id
if subj and subj.name and subj.name.lower() not in seen:
seen.add(subj.name.lower())
goal_id = subj.name.lower().replace(' ', '_')
goals.append({
'id': goal_id,
'name': subj.name,
'icon': icon_map.get(goal_id, icon_map.get(subj.name.lower(), 'fa-book')),
})
if not goals:
goals = [
{'id': 'ielts', 'name': 'IELTS Preparation', 'icon': 'fa-language'},
{'id': 'general_english', 'name': 'General English', 'icon': 'fa-comments'},
{'id': 'math', 'name': 'Mathematics', 'icon': 'fa-calculator'},
{'id': 'it', 'name': 'Information Technology', 'icon': 'fa-laptop'},
]
return {'goals': goals}
# ------------------------------------------------------------------
# JSON-RPC: /encoach/onboarding/save (called by OWL wizard)
# ------------------------------------------------------------------
@http.route('/encoach/onboarding/save', type='json', auth='user')
def onboarding_save_rpc(self, **kw):
user = request.env.user
goal = kw.get('goal')
target_level = kw.get('target_level')
study_preference = kw.get('study_preference')
skip_placement = kw.get('skip_placement', False)
learning_style = kw.get('learning_style')
hours_per_week = kw.get('hours_per_week', 0)
placement_decision = 'skip' if skip_placement else 'take_now'
profile_vals = {
'user_id': user.id,
'learning_goal': goal,
'study_mode': study_preference,
'hours_per_week': hours_per_week,
'placement_decision': placement_decision,
}
if learning_style:
if isinstance(learning_style, list):
profile_vals['learning_style'] = json.dumps(learning_style)
elif isinstance(learning_style, str):
profile_vals['learning_style'] = json.dumps([learning_style])
if skip_placement:
profile_vals['cefr_level'] = 'b1'
Profile = request.env['encoach.student.profile'].sudo()
profile = Profile.search([('user_id', '=', user.id)], limit=1)
if profile:
profile.write(profile_vals)
else:
profile = Profile.create(profile_vals)
user.sudo().write({
'account_status': 'activated',
'first_login': False,
})
return {'ok': True, 'profile_id': profile.id, 'placement_decision': placement_decision}
@http.route('/my/dashboard', type='http', auth='user', website=True)
def student_dashboard(self, **kw):
user = request.env.user
# Entity students must complete placement test first
if getattr(user, 'account_source', '') == 'entity_bulk_upload':
completed_session = request.env['encoach.cat.session'].sudo().search([
('student_id', '=', user.id),
('status', '=', 'completed'),
], limit=1)
if not completed_session:
return request.redirect('/my/placement')
profile = request.env['encoach.student.profile'].sudo().search(
[('user_id', '=', user.id)], limit=1,
)
courses = request.env['op.course'].sudo().search([], limit=10)
attempts = request.env['encoach.student.attempt'].sudo().search(
[('student_id', '=', user.id)], limit=5, order='started_at desc',
)
return request.render('encoach_portal.student_dashboard', {
'user': user,
'profile': profile,
'courses': courses,
'attempts': attempts,
})
@http.route('/my/placement', type='http', auth='user', website=True)
def placement_page(self, **kw):
user = request.env.user
session = request.env['encoach.cat.session'].sudo().search(
[('student_id', '=', user.id)], limit=1, order='id desc',
)
return request.render('encoach_portal.placement_briefing', {
'session': session,
'user': user,
})
@http.route('/my/placement/results', type='http', auth='user', website=True)
def placement_results_page(self, **kw):
user = request.env.user
session = request.env['encoach.cat.session'].sudo().search(
[('student_id', '=', user.id), ('status', '=', 'completed')],
limit=1, order='id desc',
)
abilities = request.env['encoach.student.ability.model'].sudo().search(
[('student_id', '=', user.id)],
)
return request.render('encoach_portal.placement_results', {
'session': session,
'abilities': abilities,
})
@http.route('/my/course/<int:course_id>', type='http', auth='user', website=True)
def course_detail(self, course_id, **kw):
course = request.env['op.course'].sudo().browse(course_id)
if not course.exists():
return request.not_found()
modules = request.env['encoach.course.module'].sudo().search(
[('course_id', '=', course_id)], order='sequence',
)
return request.render('encoach_portal.course_detail', {
'course': course,
'modules': modules,
})
@http.route('/my/exam/<int:attempt_id>/results', type='http', auth='user', website=True)
def exam_results(self, attempt_id, **kw):
attempt = request.env['encoach.student.attempt'].sudo().browse(attempt_id)
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
return request.not_found()
if attempt.status not in ('released', 'scored'):
return request.render('encoach_portal.results_pending', {
'attempt': attempt,
})
scores = request.env['encoach.score'].sudo().search(
[('attempt_id', '=', attempt_id)],
)
feedback = request.env['encoach.feedback'].sudo().search(
[('attempt_id', '=', attempt_id)],
)
return request.render('encoach_portal.exam_results', {
'attempt': attempt,
'scores': scores,
'feedback': feedback,
})
@http.route('/my/profile', type='http', auth='user', website=True)
def profile_page(self, **kw):
user = request.env.user
profile = request.env['encoach.student.profile'].sudo().search(
[('user_id', '=', user.id)], limit=1,
)
return request.render('encoach_portal.profile', {
'user': user,
'profile': profile,
})
@http.route('/verify/<string:hash_code>', type='http', auth='public', website=True)
def verification_page(self, hash_code, **kw):
return request.render('encoach_portal.score_verification', {
'hash_code': hash_code,
})

View File

@@ -0,0 +1 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink

View File

@@ -0,0 +1,139 @@
/* ============================================================
EnCoach Portal Custom Styles
============================================================ */
:root {
--ec-primary: #4361ee;
--ec-primary-soft: rgba(67, 97, 238, .08);
--ec-success: #06d6a0;
--ec-warning: #ffd166;
--ec-radius: 1rem;
}
/* ---- General ---- */
.ec-register-section,
.ec-verify-section,
.ec-onboarding-section,
.ec-dashboard-section,
.ec-placement-section,
.ec-results-section,
.ec-course-section,
.ec-exam-results-section,
.ec-pending-section,
.ec-profile-section,
.ec-verification-section {
min-height: calc(100vh - 200px);
}
/* ---- OTP Boxes ---- */
.ec-otp-box {
width: 48px;
height: 56px;
font-size: 1.5rem;
caret-color: var(--ec-primary);
}
.ec-otp-box:focus {
border-color: var(--ec-primary);
box-shadow: 0 0 0 .2rem var(--ec-primary-soft);
}
/* ---- Action Cards (dashboard) ---- */
.ec-action-card {
transition: transform .15s ease, box-shadow .15s ease;
}
.ec-action-card:hover {
transform: translateY(-3px);
box-shadow: 0 .5rem 1.5rem rgba(0, 0, 0, .08) !important;
}
/* ---- Course Cards ---- */
.ec-course-card {
transition: transform .15s ease, box-shadow .15s ease;
}
.ec-course-card:hover {
transform: translateY(-2px);
box-shadow: 0 .5rem 1.2rem rgba(0, 0, 0, .07) !important;
}
/* ---- Module List (locked state) ---- */
.ec-module-locked {
opacity: .55;
pointer-events: none;
}
/* ---- Wizard ---- */
.ec-wizard {
max-width: 720px;
margin: 0 auto;
}
.ec-wizard-step.active small {
color: var(--ec-primary);
font-weight: 600;
}
.ec-wizard-step.completed small {
color: var(--ec-success);
}
.ec-selectable-card {
cursor: pointer;
transition: border-color .15s ease, box-shadow .15s ease, transform .1s ease;
}
.ec-selectable-card:hover {
border-color: var(--ec-primary) !important;
transform: translateY(-1px);
}
/* ---- Score Highlights ---- */
.ec-score-highlight {
transition: transform .15s ease;
}
.ec-score-highlight:hover {
transform: scale(1.03);
}
/* ---- Password Strength ---- */
.ec-password-strength .progress-bar {
transition: width .3s ease, background-color .3s ease;
}
/* ---- Chart Container ---- */
.ec-chart-container {
position: relative;
}
/* ---- CEFR Badge ---- */
.ec-cefr-badge {
font-family: "Inter", system-ui, sans-serif;
letter-spacing: .05em;
}
/* ---- Responsive Tweaks ---- */
@media (max-width: 576px) {
.ec-otp-box {
width: 40px;
height: 48px;
font-size: 1.25rem;
}
.ec-wizard-step small {
font-size: .65rem;
}
}

View File

@@ -0,0 +1,318 @@
/** @odoo-module **/
import { Component, useState, useRef, onMounted, xml } from "@odoo/owl";
async function rpc(url, params) {
const resp = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", method: "call", id: 1, params: params || {} }),
});
const data = await resp.json();
if (data.error) throw new Error(data.error.data?.message || "RPC error");
return data.result;
}
const STEPS = [
{ key: "goal", title: "Learning Goal", icon: "fa-bullseye" },
{ key: "level", title: "Target Level", icon: "fa-signal" },
{ key: "prefs", title: "Study Preferences", icon: "fa-sliders" },
{ key: "placement", title: "Placement Test", icon: "fa-tasks" },
];
const FALLBACK_GOALS = [
{ id: "ielts", label: "IELTS Preparation", icon: "fa-language", desc: "Prepare for your IELTS exam" },
{ id: "general", label: "General English", icon: "fa-comments", desc: "Improve everyday English" },
{ id: "academic", label: "Academic English", icon: "fa-graduation-cap", desc: "University-level English" },
{ id: "math", label: "Mathematics", icon: "fa-calculator", desc: "Strengthen maths skills" },
{ id: "it", label: "IT Skills", icon: "fa-laptop", desc: "Computer and IT proficiency" },
];
const ICON_MAP = {
"ielts": "fa-language",
"general_english": "fa-comments",
"general": "fa-comments",
"academic": "fa-graduation-cap",
"math": "fa-calculator",
"mathematics": "fa-calculator",
"it": "fa-laptop",
"information_technology": "fa-laptop",
};
const LEVELS = [
{ id: "A1", label: "A1 \u2013 Beginner" },
{ id: "A2", label: "A2 \u2013 Elementary" },
{ id: "B1", label: "B1 \u2013 Intermediate" },
{ id: "B2", label: "B2 \u2013 Upper Intermediate" },
{ id: "C1", label: "C1 \u2013 Advanced" },
{ id: "C2", label: "C2 \u2013 Proficiency" },
{ id: "band_5", label: "IELTS Band 5.0" },
{ id: "band_6", label: "IELTS Band 6.0" },
{ id: "band_7", label: "IELTS Band 7.0+" },
];
const STUDY_PREFS = [
{ id: "self_paced", label: "Self-Paced", icon: "fa-user", desc: "Learn at your own speed" },
{ id: "structured", label: "Structured", icon: "fa-calendar", desc: "Follow a weekly schedule" },
{ id: "intensive", label: "Intensive", icon: "fa-bolt", desc: "Fast-track learning" },
];
export class OnboardingWizard extends Component {
static template = xml`
<div class="ec-wizard" t-ref="root">
<div class="ec-wizard-progress mb-4">
<div class="d-flex justify-content-between mb-2">
<t t-foreach="steps" t-as="step" t-key="step.key">
<div t-attf-class="ec-wizard-step text-center flex-fill #{ step_index &lt;= state.currentStep ? 'active' : '' } #{ step_index &lt; state.currentStep ? 'completed' : '' }">
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-1 #{ step_index &lt;= state.currentStep ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:36px;height:36px;">
<i t-attf-class="fa #{ step_index &lt; state.currentStep ? 'fa-check' : step.icon }"/>
</span>
<small class="d-block" t-esc="step.title"/>
</div>
</t>
</div>
<div class="progress rounded-pill" style="height:6px;">
<div class="progress-bar bg-primary rounded-pill" t-attf-style="width: #{ progressPercent }%;"/>
</div>
</div>
<div t-if="state.currentStep === 0" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">What would you like to learn?</h4>
<p class="text-muted text-center mb-4">Choose your primary learning goal</p>
<div class="row g-3">
<t t-foreach="goals" t-as="goal" t-key="goal.id">
<div class="col-md-6">
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedGoal === goal.id ? 'border-primary shadow-sm' : 'border-light' }"
t-on-click="onClickGoal" t-att-data-id="goal.id" role="button">
<div class="card-body p-3 d-flex align-items-center">
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-3 me-3 #{ state.selectedGoal === goal.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:44px;height:44px;">
<i t-attf-class="fa #{ goal.icon }"/>
</span>
<div>
<h6 class="fw-bold mb-0" t-esc="goal.label"/>
<small class="text-muted" t-esc="goal.desc"/>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
<div t-if="state.currentStep === 1" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">What level are you aiming for?</h4>
<p class="text-muted text-center mb-4">Select your target proficiency</p>
<div class="row g-2 justify-content-center">
<t t-foreach="levels" t-as="lvl" t-key="lvl.id">
<div class="col-sm-6 col-md-4">
<div t-attf-class="card border-2 rounded-3 ec-selectable-card #{ state.selectedLevel === lvl.id ? 'border-primary shadow-sm' : 'border-light' }"
t-on-click="onClickLevel" t-att-data-id="lvl.id" role="button">
<div class="card-body p-3 text-center">
<span class="fw-semibold" t-esc="lvl.label"/>
</div>
</div>
</div>
</t>
</div>
</div>
<div t-if="state.currentStep === 2" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">How do you prefer to study?</h4>
<p class="text-muted text-center mb-4">Pick the style that suits you best</p>
<div class="row g-3 justify-content-center">
<t t-foreach="studyPrefs" t-as="pref" t-key="pref.id">
<div class="col-md-4">
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedPref === pref.id ? 'border-primary shadow-sm' : 'border-light' }"
t-on-click="onClickPref" t-att-data-id="pref.id" role="button">
<div class="card-body p-4 text-center">
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-2 #{ state.selectedPref === pref.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:52px;height:52px;">
<i t-attf-class="fa fa-lg #{ pref.icon }"/>
</span>
<h6 class="fw-bold mb-1" t-esc="pref.label"/>
<small class="text-muted" t-esc="pref.desc"/>
</div>
</div>
</div>
</t>
</div>
</div>
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
<p class="text-muted text-center mb-4">We recommend a quick placement test to personalise your learning path</p>
<div class="card border-0 bg-light rounded-4 mb-4">
<div class="card-body p-4">
<div class="row g-3">
<div class="col-sm-4 text-center">
<small class="text-muted d-block mb-1">Goal</small>
<span class="fw-bold" t-esc="state.selectedGoal"/>
</div>
<div class="col-sm-4 text-center">
<small class="text-muted d-block mb-1">Target Level</small>
<span class="fw-bold" t-esc="state.selectedLevel"/>
</div>
<div class="col-sm-4 text-center">
<small class="text-muted d-block mb-1">Study Style</small>
<span class="fw-bold" t-esc="state.selectedPref"/>
</div>
</div>
</div>
</div>
<div class="card border-2 border-primary rounded-4 mb-3">
<div class="card-body p-4 text-center">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
<i class="fa fa-tasks fa-lg"/>
</span>
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
<p class="text-muted mb-0">20-30 minutes \u2014 covers Grammar, Vocabulary, Reading, and Speaking</p>
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
</div>
</div>
<t t-if="state.error">
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
</t>
</div>
<div class="d-flex justify-content-between mt-4">
<button t-attf-class="btn btn-outline-secondary rounded-3 px-4 #{ isFirstStep ? 'invisible' : '' }"
t-on-click="prevStep">
<i class="fa fa-arrow-left me-2"/>Back
</button>
<t t-if="!isLastStep">
<button class="btn btn-primary rounded-3 px-4"
t-att-disabled="!canProceed"
t-on-click="nextStep">
Next<i class="fa fa-arrow-right ms-2"/>
</button>
</t>
<t t-else="">
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary rounded-3 px-4"
t-att-disabled="state.saving"
t-on-click="skipPlacement">
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/></t>
<t t-else="">Skip for now</t>
</button>
<button class="btn btn-primary rounded-3 px-4"
t-att-disabled="state.saving"
t-on-click="takePlacement">
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/>Saving\u2026</t>
<t t-else=""><i class="fa fa-play me-2"/>Take Placement Test</t>
</button>
</div>
</t>
</div>
</div>`;
static props = {};
setup() {
this.rootRef = useRef("root");
this.steps = STEPS;
this.goals = FALLBACK_GOALS;
this.levels = LEVELS;
this.studyPrefs = STUDY_PREFS;
this.state = useState({
currentStep: 0,
selectedGoal: null,
selectedLevel: null,
selectedPref: null,
saving: false,
error: null,
goalsLoaded: false,
});
onMounted(async () => {
const el = document.getElementById("ec_onboarding_wizard");
if (el) {
this.userId = el.dataset.userId;
this.profileId = el.dataset.profileId;
}
await this._loadGoals();
});
}
async _loadGoals() {
try {
const resp = await rpc("/encoach/onboarding/goals");
if (resp && resp.goals && resp.goals.length) {
this.goals = resp.goals.map((g) => ({
id: g.id,
label: g.name || g.label || g.id,
icon: g.icon || ICON_MAP[g.id] || "fa-book",
desc: g.desc || g.description || "",
}));
}
} catch (e) {
// Fallback to hardcoded goals on error
}
this.state.goalsLoaded = true;
}
get isFirstStep() { return this.state.currentStep === 0; }
get isLastStep() { return this.state.currentStep === STEPS.length - 1; }
get canProceed() {
switch (this.state.currentStep) {
case 0: return !!this.state.selectedGoal;
case 1: return !!this.state.selectedLevel;
case 2: return !!this.state.selectedPref;
case 3: return true;
default: return false;
}
}
get progressPercent() {
return Math.round(((this.state.currentStep + 1) / STEPS.length) * 100);
}
onClickGoal(ev) {
const el = ev.target.closest("[data-id]");
if (el) this.state.selectedGoal = el.dataset.id;
}
onClickLevel(ev) {
const el = ev.target.closest("[data-id]");
if (el) this.state.selectedLevel = el.dataset.id;
}
onClickPref(ev) {
const el = ev.target.closest("[data-id]");
if (el) this.state.selectedPref = el.dataset.id;
}
nextStep() { if (this.canProceed && !this.isLastStep) this.state.currentStep++; }
prevStep() { if (!this.isFirstStep) this.state.currentStep--; }
async submit(skipPlacement) {
this.state.saving = true;
this.state.error = null;
try {
await rpc("/encoach/onboarding/save", {
goal: this.state.selectedGoal,
target_level: this.state.selectedLevel,
study_preference: this.state.selectedPref,
skip_placement: !!skipPlacement,
});
window.location.href = skipPlacement ? "/my/dashboard" : "/my/placement";
} catch (e) {
this.state.error = "Something went wrong. Please try again.";
this.state.saving = false;
}
}
takePlacement() { this.submit(false); }
skipPlacement() { this.submit(true); }
}
function mountOnboardingWizard() {
const target = document.getElementById("ec_onboarding_wizard");
if (!target) return;
const { mount } = owl;
mount(OnboardingWizard, target);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mountOnboardingWizard);
} else {
mountOnboardingWizard();
}

View File

@@ -0,0 +1,41 @@
(function () {
"use strict";
function computeStrength(password) {
let score = 0;
if (password.length >= 8) score++;
if (/[A-Z]/.test(password)) score++;
if (/[0-9]/.test(password)) score++;
if (/[^A-Za-z0-9]/.test(password)) score++;
return score;
}
const LEVELS = [
{ label: "", width: "0%", color: "" },
{ label: "Weak", width: "25%", color: "bg-danger" },
{ label: "Fair", width: "50%", color: "bg-warning" },
{ label: "Good", width: "75%", color: "bg-info" },
{ label: "Strong", width: "100%", color: "bg-success" },
];
function initPasswordStrength() {
const input = document.getElementById("reg_password");
const bar = document.getElementById("password_strength_bar");
const text = document.getElementById("password_strength_text");
if (!input || !bar || !text) return;
input.addEventListener("input", function () {
const score = computeStrength(this.value);
const level = LEVELS[score];
bar.style.width = level.width;
bar.className = "progress-bar " + level.color;
text.textContent = level.label;
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initPasswordStrength);
} else {
initPasswordStrength();
}
})();

View File

@@ -0,0 +1,168 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_portal.OnboardingWizard">
<div class="ec-wizard" t-ref="root">
<!-- Progress Bar -->
<div class="ec-wizard-progress mb-4">
<div class="d-flex justify-content-between mb-2">
<t t-foreach="steps" t-as="step" t-key="step.key">
<div t-attf-class="ec-wizard-step text-center flex-fill #{step_index &lt;= state.currentStep ? 'active' : ''} #{step_index &lt; state.currentStep ? 'completed' : ''}">
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-1 #{step_index &lt;= state.currentStep ? 'bg-primary text-white' : 'bg-light text-muted'}" style="width:36px;height:36px;">
<i t-attf-class="fa #{step_index &lt; state.currentStep ? 'fa-check' : step.icon}"/>
</span>
<small class="d-block" t-esc="step.title"/>
</div>
</t>
</div>
<div class="progress rounded-pill" style="height:6px;">
<div class="progress-bar bg-primary rounded-pill" t-attf-style="width: #{progressPercent}%;"/>
</div>
</div>
<!-- Step 1: Learning Goal -->
<div t-if="state.currentStep === 0" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">What would you like to learn?</h4>
<p class="text-muted text-center mb-4">Choose your primary learning goal</p>
<div class="row g-3">
<t t-foreach="goals" t-as="goal" t-key="goal.id">
<div class="col-md-6">
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{state.selectedGoal === goal.id ? 'border-primary shadow-sm' : 'border-light'}"
t-on-click="() => this.selectGoal(goal.id)" role="button">
<div class="card-body p-3 d-flex align-items-center">
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-3 me-3 #{state.selectedGoal === goal.id ? 'bg-primary text-white' : 'bg-light text-muted'}" style="width:44px;height:44px;">
<i t-attf-class="fa #{goal.icon}"/>
</span>
<div>
<h6 class="fw-bold mb-0" t-esc="goal.label"/>
<small class="text-muted" t-esc="goal.desc"/>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
<!-- Step 2: Target Level -->
<div t-if="state.currentStep === 1" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">What level are you aiming for?</h4>
<p class="text-muted text-center mb-4">Select your target proficiency</p>
<div class="row g-2 justify-content-center">
<t t-foreach="levels" t-as="lvl" t-key="lvl.id">
<div class="col-sm-6 col-md-4">
<div t-attf-class="card border-2 rounded-3 ec-selectable-card #{state.selectedLevel === lvl.id ? 'border-primary shadow-sm' : 'border-light'}"
t-on-click="() => this.selectLevel(lvl.id)" role="button">
<div class="card-body p-3 text-center">
<span class="fw-semibold" t-esc="lvl.label"/>
</div>
</div>
</div>
</t>
</div>
</div>
<!-- Step 3: Study Preferences -->
<div t-if="state.currentStep === 2" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">How do you prefer to study?</h4>
<p class="text-muted text-center mb-4">Pick the style that suits you best</p>
<div class="row g-3 justify-content-center">
<t t-foreach="studyPrefs" t-as="pref" t-key="pref.id">
<div class="col-md-4">
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{state.selectedPref === pref.id ? 'border-primary shadow-sm' : 'border-light'}"
t-on-click="() => this.selectPref(pref.id)" role="button">
<div class="card-body p-4 text-center">
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-2 #{state.selectedPref === pref.id ? 'bg-primary text-white' : 'bg-light text-muted'}" style="width:52px;height:52px;">
<i t-attf-class="fa fa-lg #{pref.icon}"/>
</span>
<h6 class="fw-bold mb-1" t-esc="pref.label"/>
<small class="text-muted" t-esc="pref.desc"/>
</div>
</div>
</div>
</t>
</div>
</div>
<!-- Step 4: Placement Test -->
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
<p class="text-muted text-center mb-4">We recommend a quick placement test to personalise your learning path</p>
<div class="card border-0 bg-light rounded-4 mb-4">
<div class="card-body p-4">
<div class="row g-3">
<div class="col-sm-4 text-center">
<small class="text-muted d-block mb-1">Goal</small>
<span class="fw-bold" t-esc="state.selectedGoal"/>
</div>
<div class="col-sm-4 text-center">
<small class="text-muted d-block mb-1">Target Level</small>
<span class="fw-bold" t-esc="state.selectedLevel"/>
</div>
<div class="col-sm-4 text-center">
<small class="text-muted d-block mb-1">Study Style</small>
<span class="fw-bold" t-esc="state.selectedPref"/>
</div>
</div>
</div>
</div>
<div class="card border-2 border-primary rounded-4 mb-3">
<div class="card-body p-4 text-center">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
<i class="fa fa-tasks fa-lg"/>
</span>
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
<p class="text-muted mb-0">20-30 minutes — covers Grammar, Vocabulary, Reading, and Speaking</p>
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
</div>
</div>
<t t-if="state.error">
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
</t>
</div>
<!-- Navigation Buttons -->
<div class="d-flex justify-content-between mt-4">
<button t-attf-class="btn btn-outline-secondary rounded-3 px-4 #{isFirstStep ? 'invisible' : ''}"
t-on-click="prevStep">
<i class="fa fa-arrow-left me-2"/>Back
</button>
<t t-if="!isLastStep">
<button class="btn btn-primary rounded-3 px-4"
t-att-disabled="!canProceed"
t-on-click="nextStep">
Next<i class="fa fa-arrow-right ms-2"/>
</button>
</t>
<t t-else="">
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary rounded-3 px-4"
t-att-disabled="state.saving"
t-on-click="skipPlacement">
<t t-if="state.saving">
<span class="spinner-border spinner-border-sm me-2"/>
</t>
<t t-else="">Skip for now</t>
</button>
<button class="btn btn-primary rounded-3 px-4"
t-att-disabled="state.saving"
t-on-click="takePlacement">
<t t-if="state.saving">
<span class="spinner-border spinner-border-sm me-2"/>Saving…
</t>
<t t-else="">
<i class="fa fa-play me-2"/>Take Placement Test
</t>
</button>
</div>
</t>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Top-level website menu for student portal -->
<record id="menu_encoach_portal_root" model="website.menu">
<field name="name">My Learning</field>
<field name="url">/my/dashboard</field>
<field name="parent_id" ref="website.main_menu"/>
<field name="sequence">50</field>
</record>
<record id="menu_encoach_dashboard" model="website.menu">
<field name="name">Dashboard</field>
<field name="url">/my/dashboard</field>
<field name="parent_id" ref="menu_encoach_portal_root"/>
<field name="sequence">10</field>
</record>
<record id="menu_encoach_placement" model="website.menu">
<field name="name">Placement Test</field>
<field name="url">/my/placement</field>
<field name="parent_id" ref="menu_encoach_portal_root"/>
<field name="sequence">20</field>
</record>
<record id="menu_encoach_profile" model="website.menu">
<field name="name">My Profile</field>
<field name="url">/my/profile</field>
<field name="parent_id" ref="menu_encoach_portal_root"/>
<field name="sequence">30</field>
</record>
</odoo>

View File

@@ -0,0 +1,858 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- ===================================================================
REGISTER PAGE
=================================================================== -->
<template id="register" name="EnCoach Register">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-register-section py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-5 col-md-7">
<div class="card shadow-sm border-0 rounded-4">
<div class="card-body p-4 p-md-5">
<div class="text-center mb-4">
<h2 class="fw-bold text-primary mb-1">Create Account</h2>
<p class="text-muted">Start your learning journey with EnCoach</p>
</div>
<form action="/register" method="post" class="ec-register-form">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label for="reg_name" class="form-label fw-semibold">Full Name</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="fa fa-user text-muted"/></span>
<input type="text" id="reg_name" name="name" class="form-control border-start-0" placeholder="Enter your full name" required="required"/>
</div>
</div>
<div class="mb-3">
<label for="reg_email" class="form-label fw-semibold">Email Address</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="fa fa-envelope text-muted"/></span>
<input type="email" id="reg_email" name="email" class="form-control border-start-0" placeholder="you@example.com" required="required"/>
</div>
</div>
<div class="mb-3">
<label for="reg_password" class="form-label fw-semibold">Password</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="fa fa-lock text-muted"/></span>
<input type="password" id="reg_password" name="password" class="form-control border-start-0" placeholder="Min. 8 characters" required="required" minlength="8"/>
</div>
<div class="ec-password-strength mt-2">
<div class="progress" style="height: 4px;">
<div id="password_strength_bar" class="progress-bar" role="progressbar" style="width: 0%;"/>
</div>
<small id="password_strength_text" class="text-muted"/>
</div>
</div>
<div class="mb-4">
<label for="reg_confirm" class="form-label fw-semibold">Confirm Password</label>
<div class="input-group">
<span class="input-group-text bg-light border-end-0"><i class="fa fa-lock text-muted"/></span>
<input type="password" id="reg_confirm" name="confirm_password" class="form-control border-start-0" placeholder="Re-enter password" required="required"/>
</div>
</div>
<t t-if="captcha_site_key">
<div class="mb-4 d-flex justify-content-center">
<div class="ec-captcha-placeholder border rounded-3 p-3 text-center bg-light w-100">
<small class="text-muted"><i class="fa fa-shield me-1"/>CAPTCHA verification</small>
<div t-att-data-sitekey="captcha_site_key" class="g-recaptcha mt-2" data-callback="onCaptchaSuccess"/>
</div>
</div>
</t>
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold rounded-3">
<i class="fa fa-user-plus me-2"/>Create Account
</button>
</form>
<div class="text-center mt-4">
<span class="text-muted">Already have an account?</span>
<a href="/web/login" class="fw-semibold text-decoration-none ms-1">Sign In</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
VERIFY EMAIL PAGE
=================================================================== -->
<template id="verify_email" name="EnCoach Verify Email">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-verify-section py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-5 col-md-7">
<div class="card shadow-sm border-0 rounded-4">
<div class="card-body p-4 p-md-5 text-center">
<div class="ec-verify-icon mb-4">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10" style="width:72px;height:72px;">
<i class="fa fa-envelope-open fa-2x text-primary"/>
</span>
</div>
<h2 class="fw-bold mb-2">Check Your Email</h2>
<p class="text-muted mb-1">We sent a verification code to</p>
<p class="fw-semibold mb-4" t-esc="email"/>
<form action="/verify-email" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="email" t-att-value="email"/>
<div class="ec-otp-inputs d-flex justify-content-center gap-2 mb-4">
<input type="text" name="otp_1" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
<input type="text" name="otp_2" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
<input type="text" name="otp_3" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
<input type="text" name="otp_4" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
<input type="text" name="otp_5" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
<input type="text" name="otp_6" maxlength="1" class="form-control text-center fw-bold fs-4 rounded-3 ec-otp-box" required="required"/>
</div>
<button type="submit" class="btn btn-primary w-100 py-2 fw-semibold rounded-3 mb-3">
Verify Email
</button>
</form>
<p class="text-muted mb-0">
Didn't receive the code?
<a href="#" class="fw-semibold text-decoration-none ec-resend-btn">Resend</a>
</p>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
ONBOARDING PAGE (OWL Wizard mount point)
=================================================================== -->
<template id="onboarding" name="EnCoach Onboarding">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-onboarding-section py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8 col-md-10">
<div class="text-center mb-4">
<h2 class="fw-bold">Welcome, <t t-esc="user.name"/>!</h2>
<p class="text-muted">Let's personalise your learning experience</p>
</div>
<!-- OWL wizard mounts here -->
<div id="ec_onboarding_wizard"
t-att-data-user-id="user.id"
t-att-data-profile-id="profile.id if profile else ''"/>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
STUDENT DASHBOARD
=================================================================== -->
<template id="student_dashboard" name="EnCoach Student Dashboard">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-dashboard-section py-5">
<div class="container">
<!-- Welcome Header -->
<div class="row align-items-center mb-5">
<div class="col">
<h2 class="fw-bold mb-1">Welcome back, <t t-esc="user.name"/>!</h2>
<p class="text-muted mb-0">
<t t-if="profile and profile.cefr_level">
Your current level:
<span class="badge rounded-pill bg-primary fs-6 ms-1" t-esc="profile.cefr_level"/>
</t>
<t t-else="">
<span class="text-warning"><i class="fa fa-exclamation-circle me-1"/>Take a placement test to find your level</span>
</t>
</p>
</div>
<div class="col-auto">
<a href="/my/placement" class="btn btn-outline-primary rounded-3">
<i class="fa fa-compass me-1"/>Placement Test
</a>
</div>
</div>
<!-- Quick Actions -->
<div class="row g-3 mb-5">
<div class="col-md-4">
<a href="/my/placement" class="card border-0 shadow-sm rounded-4 h-100 text-decoration-none ec-action-card">
<div class="card-body d-flex align-items-center p-4">
<span class="d-inline-flex align-items-center justify-content-center rounded-3 bg-primary bg-opacity-10 me-3" style="width:48px;height:48px;">
<i class="fa fa-play text-primary"/>
</span>
<div>
<h6 class="fw-bold mb-0">Start Placement</h6>
<small class="text-muted">Find your level</small>
</div>
</div>
</a>
</div>
<div class="col-md-4">
<a href="#courses_section" class="card border-0 shadow-sm rounded-4 h-100 text-decoration-none ec-action-card">
<div class="card-body d-flex align-items-center p-4">
<span class="d-inline-flex align-items-center justify-content-center rounded-3 bg-success bg-opacity-10 me-3" style="width:48px;height:48px;">
<i class="fa fa-book text-success"/>
</span>
<div>
<h6 class="fw-bold mb-0">Resume Course</h6>
<small class="text-muted">Continue learning</small>
</div>
</div>
</a>
</div>
<div class="col-md-4">
<a href="#results_section" class="card border-0 shadow-sm rounded-4 h-100 text-decoration-none ec-action-card">
<div class="card-body d-flex align-items-center p-4">
<span class="d-inline-flex align-items-center justify-content-center rounded-3 bg-info bg-opacity-10 me-3" style="width:48px;height:48px;">
<i class="fa fa-bar-chart text-info"/>
</span>
<div>
<h6 class="fw-bold mb-0">View Results</h6>
<small class="text-muted">Track progress</small>
</div>
</div>
</a>
</div>
</div>
<!-- Courses Grid -->
<div id="courses_section" class="mb-5">
<h4 class="fw-bold mb-3"><i class="fa fa-graduation-cap me-2 text-primary"/>My Courses</h4>
<t t-if="courses">
<div class="row g-4">
<t t-foreach="courses" t-as="course">
<div class="col-md-6 col-lg-4">
<div class="card border-0 shadow-sm rounded-4 h-100 ec-course-card">
<div class="card-body p-4">
<h5 class="fw-bold mb-2" t-esc="course.name"/>
<p class="text-muted small mb-3" t-esc="course.code or 'No description'"/>
<div class="progress rounded-pill mb-2" style="height: 8px;">
<div class="progress-bar bg-primary rounded-pill" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100"/>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<small class="text-muted">0% complete</small>
<a t-attf-href="/my/course/#{course.id}" class="btn btn-sm btn-outline-primary rounded-3">Continue</a>
</div>
</div>
</div>
</div>
</t>
</div>
</t>
<t t-else="">
<div class="card border-0 bg-light rounded-4 p-4 text-center">
<p class="text-muted mb-0"><i class="fa fa-info-circle me-1"/>No courses enrolled yet. Complete your placement test to get started.</p>
</div>
</t>
</div>
<!-- Recent Results -->
<div id="results_section">
<h4 class="fw-bold mb-3"><i class="fa fa-trophy me-2 text-warning"/>Recent Exam Results</h4>
<t t-if="attempts">
<div class="table-responsive">
<table class="table table-hover align-middle bg-white rounded-4 overflow-hidden shadow-sm">
<thead class="table-light">
<tr>
<th>Exam</th>
<th>Date</th>
<th>Status</th>
<th class="text-end">Action</th>
</tr>
</thead>
<tbody>
<t t-foreach="attempts" t-as="a">
<tr>
<td class="fw-semibold" t-esc="a.display_name"/>
<td class="text-muted" t-esc="a.started_at" t-options='{"widget": "date"}'/>
<td>
<span t-attf-class="badge rounded-pill #{('bg-success' if a.status in ('released','scored') else 'bg-secondary')}" t-esc="a.status"/>
</td>
<td class="text-end">
<a t-attf-href="/my/exam/#{a.id}/results" class="btn btn-sm btn-outline-primary rounded-3">View</a>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
<t t-else="">
<div class="card border-0 bg-light rounded-4 p-4 text-center">
<p class="text-muted mb-0"><i class="fa fa-info-circle me-1"/>No exam results yet.</p>
</div>
</t>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
PLACEMENT BRIEFING
=================================================================== -->
<template id="placement_briefing" name="EnCoach Placement Briefing">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-placement-section py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-7 col-md-9">
<div class="card border-0 shadow-sm rounded-4">
<div class="card-body p-4 p-md-5">
<div class="text-center mb-4">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 mb-3" style="width:80px;height:80px;">
<i class="fa fa-flask fa-2x text-primary"/>
</span>
<h2 class="fw-bold">Placement Test</h2>
<p class="text-muted">Discover your current proficiency level</p>
</div>
<div class="ec-test-overview mb-4">
<h5 class="fw-bold mb-3">What this test covers</h5>
<div class="row g-3">
<div class="col-6">
<div class="d-flex align-items-start">
<i class="fa fa-check-circle text-success mt-1 me-2"/>
<span>Reading Comprehension</span>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-start">
<i class="fa fa-check-circle text-success mt-1 me-2"/>
<span>Listening Skills</span>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-start">
<i class="fa fa-check-circle text-success mt-1 me-2"/>
<span>Grammar &amp; Vocabulary</span>
</div>
</div>
<div class="col-6">
<div class="d-flex align-items-start">
<i class="fa fa-check-circle text-success mt-1 me-2"/>
<span>Problem Solving</span>
</div>
</div>
</div>
</div>
<div class="ec-test-info bg-light rounded-3 p-3 mb-4">
<div class="row text-center">
<div class="col-4">
<div class="fw-bold text-primary fs-5">~25</div>
<small class="text-muted">Minutes</small>
</div>
<div class="col-4">
<div class="fw-bold text-primary fs-5">Adaptive</div>
<small class="text-muted">Difficulty</small>
</div>
<div class="col-4">
<div class="fw-bold text-primary fs-5">CEFR</div>
<small class="text-muted">A1 C2</small>
</div>
</div>
</div>
<h5 class="fw-bold mb-3">Before you begin</h5>
<ul class="list-unstyled mb-4">
<li class="d-flex align-items-start mb-2">
<span class="badge bg-primary bg-opacity-10 text-primary rounded-circle me-2 mt-1" style="width:24px;height:24px;line-height:24px;font-size:.75rem;">1</span>
<span>Find a quiet place with a stable internet connection</span>
</li>
<li class="d-flex align-items-start mb-2">
<span class="badge bg-primary bg-opacity-10 text-primary rounded-circle me-2 mt-1" style="width:24px;height:24px;line-height:24px;font-size:.75rem;">2</span>
<span>The test adapts to your answers — do your best on every question</span>
</li>
<li class="d-flex align-items-start mb-2">
<span class="badge bg-primary bg-opacity-10 text-primary rounded-circle me-2 mt-1" style="width:24px;height:24px;line-height:24px;font-size:.75rem;">3</span>
<span>You cannot pause or restart once you begin</span>
</li>
<li class="d-flex align-items-start">
<span class="badge bg-primary bg-opacity-10 text-primary rounded-circle me-2 mt-1" style="width:24px;height:24px;line-height:24px;font-size:.75rem;">4</span>
<span>Results are available immediately upon completion</span>
</li>
</ul>
<div class="text-center">
<a href="/my/placement/start" class="btn btn-primary btn-lg px-5 py-2 rounded-3 fw-semibold">
<i class="fa fa-play me-2"/>Begin Test
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
PLACEMENT RESULTS
=================================================================== -->
<template id="placement_results" name="EnCoach Placement Results">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-results-section py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8 col-md-10">
<!-- CEFR Level Badge -->
<div class="text-center mb-5">
<h2 class="fw-bold mb-3">Your Placement Results</h2>
<t t-if="session">
<div class="ec-cefr-badge d-inline-flex align-items-center justify-content-center rounded-circle bg-primary text-white shadow" style="width:120px;height:120px;">
<span class="fs-1 fw-bold" t-esc="session.final_cefr or '—'"/>
</div>
<p class="mt-3 text-muted">CEFR Level</p>
</t>
<t t-else="">
<div class="alert alert-warning rounded-3">No completed placement session found.</div>
</t>
</div>
<!-- Skill Radar Chart (OWL mount point) -->
<t t-if="abilities">
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Skill Overview</h5>
<div id="ec_skill_radar_chart" class="ec-chart-container" style="height:300px;"
t-att-data-abilities="json.dumps([{'skill': a.skill_id.name, 'theta': a.theta} for a in abilities])"/>
</div>
</div>
<!-- Per-Skill Breakdown -->
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Skill Breakdown</h5>
<t t-foreach="abilities" t-as="ability">
<div class="d-flex align-items-center mb-3">
<div class="me-3" style="min-width:120px;">
<span class="fw-semibold" t-esc="ability.skill_id.name or 'Skill'"/>
</div>
<div class="flex-grow-1">
<div class="progress rounded-pill" style="height:10px;">
<div class="progress-bar bg-primary rounded-pill" role="progressbar"
t-attf-style="width: #{min(max(int((ability.theta + 3) / 6 * 100), 0), 100)}%;"
t-att-aria-valuenow="ability.theta"/>
</div>
</div>
<div class="ms-3 text-end" style="min-width:60px;">
<span class="fw-semibold" t-esc="'%.2f' % ability.theta"/>
</div>
</div>
</t>
</div>
</div>
</t>
<!-- Recommended Path -->
<div class="card border-0 shadow-sm rounded-4">
<div class="card-body p-4 text-center">
<h5 class="fw-bold mb-2">Recommended Learning Path</h5>
<p class="text-muted mb-3">Based on your results, we've tailored a course path for you.</p>
<a href="/my/dashboard" class="btn btn-primary rounded-3 px-4">
<i class="fa fa-arrow-right me-2"/>Go to Dashboard
</a>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
COURSE DETAIL
=================================================================== -->
<template id="course_detail" name="EnCoach Course Detail">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-course-section py-5">
<div class="container">
<!-- Course Header -->
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4 p-md-5">
<div class="row align-items-center">
<div class="col-md-8">
<h2 class="fw-bold mb-2" t-esc="course.name"/>
<p class="text-muted mb-3" t-esc="course.code or 'No description available.'"/>
</div>
<div class="col-md-4 text-md-end">
<div class="ec-progress-ring text-center">
<div class="fs-2 fw-bold text-primary">0%</div>
<small class="text-muted">Complete</small>
</div>
</div>
</div>
<div class="progress rounded-pill mt-3" style="height:10px;">
<div class="progress-bar bg-primary rounded-pill" role="progressbar" style="width:0%;"/>
</div>
</div>
</div>
<!-- Module List -->
<h4 class="fw-bold mb-3"><i class="fa fa-list-ul me-2 text-primary"/>Modules</h4>
<t t-if="modules">
<div class="ec-module-list">
<t t-foreach="modules" t-as="mod">
<div t-attf-class="card border-0 shadow-sm rounded-4 mb-3 #{('ec-module-locked' if mod.status == 'locked' else '')}">
<div class="card-body p-4">
<div class="d-flex align-items-center">
<div class="me-3">
<t t-if="mod.status == 'completed'">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-success text-white" style="width:40px;height:40px;">
<i class="fa fa-check"/>
</span>
</t>
<t t-elif="mod.status == 'locked'">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-secondary bg-opacity-25 text-secondary" style="width:40px;height:40px;">
<i class="fa fa-lock"/>
</span>
</t>
<t t-else="">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary" style="width:40px;height:40px;">
<i class="fa fa-play"/>
</span>
</t>
</div>
<div class="flex-grow-1">
<h6 class="fw-bold mb-1" t-esc="mod.name"/>
<small class="text-muted" t-esc="mod.cefr_target or ''"/>
</div>
<div class="ms-3">
<t t-if="mod.status != 'locked'">
<span class="badge rounded-pill bg-primary bg-opacity-10 text-primary" t-esc="mod.status or 'available'"/>
</t>
</div>
</div>
</div>
</div>
</t>
</div>
</t>
<t t-else="">
<div class="card border-0 bg-light rounded-4 p-4 text-center">
<p class="text-muted mb-0">No modules available yet for this course.</p>
</div>
</t>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
EXAM RESULTS
=================================================================== -->
<template id="exam_results" name="EnCoach Exam Results">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-exam-results-section py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8 col-md-10">
<!-- Score Summary Card -->
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4 p-md-5 text-center">
<h2 class="fw-bold mb-3">Exam Results</h2>
<div class="row g-4 mb-4">
<div class="col-sm-4">
<div class="ec-score-highlight bg-primary bg-opacity-10 rounded-4 p-3">
<div class="fs-2 fw-bold text-primary" t-esc="'%.1f' % attempt.overall_band if attempt.overall_band else '—'"/>
<small class="text-muted d-block">Overall Band</small>
</div>
</div>
<div class="col-sm-4">
<div class="ec-score-highlight bg-success bg-opacity-10 rounded-4 p-3">
<div class="fs-2 fw-bold text-success" t-esc="attempt.cefr_level or '—'"/>
<small class="text-muted d-block">CEFR Level</small>
</div>
</div>
<div class="col-sm-4">
<div class="ec-score-highlight bg-info bg-opacity-10 rounded-4 p-3">
<div class="fs-2 fw-bold text-info" t-esc="attempt.status or '—'"/>
<small class="text-muted d-block">Status</small>
</div>
</div>
</div>
</div>
</div>
<!-- Skill Scores -->
<t t-if="scores">
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Skill Scores</h5>
<t t-foreach="scores" t-as="score">
<div class="mb-3">
<div class="d-flex justify-content-between mb-1">
<span class="fw-semibold" t-esc="score.skill_name or score.display_name"/>
<span class="fw-bold text-primary" t-esc="'%.1f' % score.value if score.value else '—'"/>
</div>
<div class="progress rounded-pill" style="height:10px;">
<div class="progress-bar bg-primary rounded-pill" role="progressbar"
t-attf-style="width: #{min(int(score.value / 9.0 * 100), 100) if score.value else 0}%;"/>
</div>
</div>
</t>
</div>
</div>
</t>
<!-- Feedback -->
<t t-if="feedback">
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3"><i class="fa fa-comments me-2 text-warning"/>Feedback</h5>
<t t-foreach="feedback" t-as="fb">
<div class="ec-feedback-item bg-light rounded-3 p-3 mb-2">
<strong t-esc="fb.skill_name or 'General'"/>
<p class="mb-0 mt-1 text-muted" t-esc="fb.text or fb.comment or ''"/>
</div>
</t>
</div>
</div>
</t>
<!-- Actions -->
<div class="d-flex flex-wrap gap-3 justify-content-center">
<a t-attf-href="/my/exam/#{attempt.id}/pdf" class="btn btn-outline-primary rounded-3 px-4">
<i class="fa fa-download me-2"/>Download PDF
</a>
<t t-if="attempt.verification_hash">
<a t-attf-href="/verify/#{attempt.verification_hash}" class="btn btn-outline-secondary rounded-3 px-4" target="_blank">
<i class="fa fa-qrcode me-2"/>Verify Score
</a>
</t>
<a href="/my/dashboard" class="btn btn-primary rounded-3 px-4">
<i class="fa fa-arrow-left me-2"/>Back to Dashboard
</a>
</div>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
RESULTS PENDING
=================================================================== -->
<template id="results_pending" name="EnCoach Results Pending">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-pending-section py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8 text-center">
<div class="card border-0 shadow-sm rounded-4">
<div class="card-body p-5">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-warning bg-opacity-10 mb-4" style="width:80px;height:80px;">
<i class="fa fa-clock-o fa-2x text-warning"/>
</span>
<h3 class="fw-bold mb-2">Results Not Yet Released</h3>
<p class="text-muted mb-4">
Your exam (<strong t-esc="attempt.display_name"/>) is still being processed.
Please check back later.
</p>
<div class="d-flex gap-3 justify-content-center">
<a href="/my/dashboard" class="btn btn-primary rounded-3 px-4">
<i class="fa fa-arrow-left me-2"/>Dashboard
</a>
<button class="btn btn-outline-secondary rounded-3 px-4" onclick="location.reload();">
<i class="fa fa-refresh me-2"/>Refresh
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
PROFILE PAGE
=================================================================== -->
<template id="profile" name="EnCoach Profile">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-profile-section py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-7 col-md-9">
<h2 class="fw-bold mb-4">My Profile</h2>
<!-- Profile Form -->
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Personal Information</h5>
<form action="/my/profile" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="row g-3">
<div class="col-md-6">
<label for="prof_name" class="form-label fw-semibold">Full Name</label>
<input type="text" id="prof_name" name="name" class="form-control rounded-3" t-att-value="user.name" required="required"/>
</div>
<div class="col-md-6">
<label for="prof_email" class="form-label fw-semibold">Email</label>
<input type="email" id="prof_email" class="form-control rounded-3 bg-light" t-att-value="user.email" readonly="readonly"/>
</div>
<div class="col-md-6">
<label for="prof_phone" class="form-label fw-semibold">Phone</label>
<input type="tel" id="prof_phone" name="phone" class="form-control rounded-3" t-att-value="user.phone or ''"/>
</div>
<div class="col-md-6">
<label for="prof_prefs" class="form-label fw-semibold">Study Preferences</label>
<select id="prof_prefs" name="study_preference" class="form-select rounded-3">
<option value="self_paced" t-att-selected="profile and profile.study_mode == 'self_paced'">Self-paced</option>
<option value="structured" t-att-selected="profile and profile.study_mode == 'structured'">Structured</option>
<option value="intensive" t-att-selected="profile and profile.study_mode == 'intensive'">Intensive</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-primary rounded-3 mt-4 px-4">
<i class="fa fa-save me-2"/>Save Changes
</button>
</form>
</div>
</div>
<!-- Change Password -->
<div class="card border-0 shadow-sm rounded-4 mb-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Change Password</h5>
<form action="/my/profile/password" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="mb-3">
<label for="current_pw" class="form-label fw-semibold">Current Password</label>
<input type="password" id="current_pw" name="current_password" class="form-control rounded-3" required="required"/>
</div>
<div class="mb-3">
<label for="new_pw" class="form-label fw-semibold">New Password</label>
<input type="password" id="new_pw" name="new_password" class="form-control rounded-3" required="required" minlength="8"/>
</div>
<div class="mb-3">
<label for="confirm_pw" class="form-label fw-semibold">Confirm New Password</label>
<input type="password" id="confirm_pw" name="confirm_new_password" class="form-control rounded-3" required="required"/>
</div>
<button type="submit" class="btn btn-outline-primary rounded-3 px-4">
<i class="fa fa-key me-2"/>Update Password
</button>
</form>
</div>
</div>
<!-- Entity Info -->
<t t-if="profile and profile.entity_id">
<div class="card border-0 shadow-sm rounded-4">
<div class="card-body p-4">
<h5 class="fw-bold mb-3">Organisation</h5>
<div class="d-flex align-items-center">
<span class="d-inline-flex align-items-center justify-content-center rounded-3 bg-primary bg-opacity-10 me-3" style="width:48px;height:48px;">
<i class="fa fa-building text-primary"/>
</span>
<div>
<h6 class="fw-bold mb-0" t-esc="profile.entity_id.name"/>
<small class="text-muted" t-esc="profile.entity_id.name or ''"/>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
<!-- ===================================================================
SCORE VERIFICATION (PUBLIC)
=================================================================== -->
<template id="score_verification" name="EnCoach Score Verification">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="ec-verification-section py-5">
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-6 col-md-8">
<div class="card border-0 shadow-sm rounded-4">
<div class="card-body p-4 p-md-5 text-center">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-success bg-opacity-10 mb-4" style="width:80px;height:80px;">
<i class="fa fa-shield fa-2x text-success"/>
</span>
<h2 class="fw-bold mb-2">Score Verification</h2>
<p class="text-muted mb-4">Verify the authenticity of an EnCoach score report</p>
<div class="bg-light rounded-3 p-3 mb-4">
<small class="text-muted d-block mb-1">Verification Code</small>
<code class="fs-5" t-esc="hash_code"/>
</div>
<!-- Verification result loaded dynamically via JS/RPC -->
<div id="ec_verification_result">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Verifying…</span>
</div>
<p class="text-muted mt-2">Looking up results…</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
</t>
</template>
</odoo>