feat: EnCoach V2 — complete OWL refactor with 15 new modules

Full architectural refactor from React to Odoo OWL:

- 15 new Odoo modules: signup, placement, exam_template, scoring,
  course_gen, entity_onboard, ai_course, quality_gate,
  ielts_validation, pdf_report, verification, math, it, portal,
  exam_session
- 6 modified modules: core (new user fields), exam (template types),
  adaptive (events/paths/settings), branding (white-label),
  resources (CEFR/AI fields), taxonomy (Math+IT hierarchies)
- ~79 new REST API endpoints across all controller packages
- ~50 admin backend views (form/list/kanban/search/menu)
- 5 custom OWL components: dashboard, content pool, exam validation,
  gap analysis, adaptive timeline
- POS-inspired full-screen exam session with 9 question renderers
- Student portal with 12 website pages (QWeb + OWL)
- AI/ML services: IRT 3PL CAT engine, CEFR mapper, quality gates,
  IELTS validator, SymPy math scorer, speaking evaluator, adaptive engine
- Seed data: IELTS/TOEFL/STEP/IC3 templates, 30+ sample questions,
  English/Math/IT taxonomy trees with 50+ topics
- Updated requirements.txt with new dependencies

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-07 01:53:06 +04:00
parent a4b9ab62cf
commit 6c93c5d600
233 changed files with 23446 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import services

View File

@@ -0,0 +1,25 @@
{
'name': 'EnCoach Adaptive Learning',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Proficiency tracking, learning plans, diagnostics, content cache',
'author': 'EnCoach',
'depends': ['encoach_core', 'encoach_taxonomy'],
'data': [
'security/ir.model.access.csv',
'views/adaptive_event_views.xml',
'views/adaptive_path_views.xml',
'views/adaptive_settings_views.xml',
'views/signal_timeline_action.xml',
'views/adaptive_menus.xml',
],
'assets': {
'web.assets_backend': [
'encoach_adaptive/static/src/js/signal_timeline.js',
'encoach_adaptive/static/src/xml/signal_timeline.xml',
'encoach_adaptive/static/src/css/signal_timeline.css',
],
},
'installable': True,
'license': 'LGPL-3',
}

View File

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

View File

@@ -0,0 +1,244 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
class EncoachAdaptiveController(http.Controller):
# ------------------------------------------------------------------
# GET /api/adaptive/dashboard
# ------------------------------------------------------------------
@http.route('/api/adaptive/dashboard', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def dashboard(self, **kw):
try:
Event = request.env['encoach.adaptive.event'].sudo()
Path = request.env['encoach.adaptive.path'].sudo()
from odoo.fields import Datetime as DT
today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0)
total_students = len(Path.search([]).mapped('student_id'))
active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c))
signals_today = Event.search_count([
('event_type', '=', 'signal'),
('created_at', '>=', today_start),
])
recent_decisions = []
decisions = Event.search(
[('event_type', '=', 'decision')],
limit=10, order='created_at desc',
)
for d in decisions:
recent_decisions.append({
'id': d.id,
'student_id': d.student_id.id,
'student_name': d.student_id.name or '',
'decision': d.decision or '',
'created_at': d.created_at,
})
return _json_response({
'total_students': total_students,
'active_courses': active_courses,
'avg_progress': 0.0,
'signals_today': signals_today,
'recent_decisions': recent_decisions,
})
except Exception as e:
_logger.exception('adaptive dashboard failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/students
# ------------------------------------------------------------------
@http.route('/api/adaptive/students', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def students(self, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Path = request.env['encoach.adaptive.path'].sudo()
Event = request.env['encoach.adaptive.event'].sudo()
paths, total = _paginate(Path, [], page, size, order='id desc')
items = []
for p in paths:
last_signal = Event.search(
[('student_id', '=', p.student_id.id), ('event_type', '=', 'signal')],
limit=1, order='created_at desc',
)
module_queue = []
try:
module_queue = json.loads(p.module_queue or '[]')
except (json.JSONDecodeError, TypeError):
pass
total_modules = len(module_queue) if module_queue else 1
completed = sum(
1 for m in module_queue
if isinstance(m, dict) and m.get('done')
)
progress_pct = round(completed / total_modules * 100, 1) if total_modules else 0.0
current_module = ''
if module_queue and completed < len(module_queue):
entry = module_queue[completed]
if isinstance(entry, dict):
current_module = entry.get('name', '')
elif isinstance(entry, str):
current_module = entry
items.append({
'student_id': p.student_id.id,
'name': p.student_id.name or '',
'course': p.course_id.name if p.course_id else '',
'current_module': current_module,
'progress_pct': progress_pct,
'last_signal': {
'signal_name': last_signal.signal_name or '',
'signal_value': last_signal.signal_value,
'created_at': last_signal.created_at,
} if last_signal else None,
})
return _json_response({
'items': items,
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('adaptive students list failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/student/<int:student_id>/signals
# ------------------------------------------------------------------
@http.route('/api/adaptive/student/<int:student_id>/signals', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def student_signals(self, student_id, **kw):
try:
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Event = request.env['encoach.adaptive.event'].sudo()
domain = [('student_id', '=', student_id)]
events, total = _paginate(Event, domain, page, size, order='created_at desc')
items = []
for ev in events:
items.append({
'id': ev.id,
'event_type': ev.event_type,
'signal_name': ev.signal_name or '',
'signal_value': ev.signal_value,
'decision': ev.decision or '',
'created_at': ev.created_at,
})
return _json_response({
'items': items,
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('student signals failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/adaptive/settings
# ------------------------------------------------------------------
@http.route('/api/adaptive/settings', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_settings(self, **kw):
try:
user = request.env.user.sudo()
Settings = request.env['encoach.adaptive.settings'].sudo()
settings = Settings.search([('teacher_id', '=', user.id)], limit=1)
if not settings:
return _json_response({
'step_up_threshold': 0.85,
'step_down_threshold': 0.50,
'micro_lesson_trigger': 2,
'module_skip_threshold': 0.95,
'no_progress_alert_days': 3,
'max_retries': 3,
'is_default': True,
})
return _json_response({
'id': settings.id,
'step_up_threshold': settings.step_up_threshold,
'step_down_threshold': settings.step_down_threshold,
'micro_lesson_trigger': settings.micro_lesson_trigger,
'module_skip_threshold': settings.module_skip_threshold,
'no_progress_alert_days': settings.no_progress_alert_days,
'max_retries': settings.max_retries,
'is_default': False,
})
except Exception as e:
_logger.exception('get adaptive settings failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/adaptive/settings
# ------------------------------------------------------------------
@http.route('/api/adaptive/settings', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update_settings(self, **kw):
try:
body = _get_json_body()
user = request.env.user.sudo()
Settings = request.env['encoach.adaptive.settings'].sudo()
settings = Settings.search([('teacher_id', '=', user.id)], limit=1)
allowed_fields = [
'step_up_threshold', 'step_down_threshold', 'micro_lesson_trigger',
'module_skip_threshold', 'no_progress_alert_days', 'max_retries',
]
vals = {k: body[k] for k in allowed_fields if k in body}
if not settings:
vals['teacher_id'] = user.id
entity = user.entity_ids[:1]
if entity:
vals['entity_id'] = entity.id
settings = Settings.create(vals)
else:
settings.write(vals)
return _json_response({
'id': settings.id,
'step_up_threshold': settings.step_up_threshold,
'step_down_threshold': settings.step_down_threshold,
'micro_lesson_trigger': settings.micro_lesson_trigger,
'module_skip_threshold': settings.module_skip_threshold,
'no_progress_alert_days': settings.no_progress_alert_days,
'max_retries': settings.max_retries,
})
except Exception as e:
_logger.exception('update adaptive settings failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,8 @@
from . import proficiency
from . import learning_plan
from . import learning_plan_item
from . import diagnostic_session
from . import content_cache
from . import adaptive_event
from . import adaptive_path
from . import adaptive_settings

View File

@@ -0,0 +1,19 @@
from odoo import models, fields
class EncoachAdaptiveEvent(models.Model):
_name = 'encoach.adaptive.event'
_description = 'Adaptive Learning Event'
_order = 'created_at desc'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
course_id = fields.Many2one('op.course', ondelete='set null')
event_type = fields.Selection([
('signal', 'Signal'),
('decision', 'Decision'),
], required=True)
signal_name = fields.Char(size=100)
signal_value = fields.Float()
decision = fields.Char(size=200)
context = fields.Text()
created_at = fields.Datetime(default=fields.Datetime.now)

View File

@@ -0,0 +1,16 @@
from odoo import models, fields
class EncoachAdaptivePath(models.Model):
_name = 'encoach.adaptive.path'
_description = 'Adaptive Learning Path'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
course_id = fields.Many2one('op.course', ondelete='set null')
module_queue = fields.Text()
source = fields.Selection([
('placement', 'Placement'),
('exam', 'Exam'),
('ai_generated', 'AI Generated'),
])
next_generation_brief = fields.Text()

View File

@@ -0,0 +1,15 @@
from odoo import models, fields
class EncoachAdaptiveSettings(models.Model):
_name = 'encoach.adaptive.settings'
_description = 'Adaptive Engine Settings'
teacher_id = fields.Many2one('res.users', ondelete='cascade')
entity_id = fields.Many2one('encoach.entity', ondelete='cascade')
step_up_threshold = fields.Float(default=0.85)
step_down_threshold = fields.Float(default=0.50)
micro_lesson_trigger = fields.Integer(default=2)
module_skip_threshold = fields.Float(default=0.95)
no_progress_alert_days = fields.Integer(default=3)
max_retries = fields.Integer(default=3)

View File

@@ -0,0 +1,18 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_proficiency_student,encoach.proficiency.student,model_encoach_proficiency,encoach_core.group_encoach_student,1,0,0,0
access_proficiency_teacher,encoach.proficiency.teacher,model_encoach_proficiency,encoach_core.group_encoach_teacher,1,1,1,0
access_proficiency_admin,encoach.proficiency.admin,model_encoach_proficiency,encoach_core.group_encoach_admin,1,1,1,1
access_learning_plan_student,encoach.learning.plan.student,model_encoach_learning_plan,encoach_core.group_encoach_student,1,1,0,0
access_learning_plan_teacher,encoach.learning.plan.teacher,model_encoach_learning_plan,encoach_core.group_encoach_teacher,1,1,1,0
access_learning_plan_admin,encoach.learning.plan.admin,model_encoach_learning_plan,encoach_core.group_encoach_admin,1,1,1,1
access_plan_item_student,encoach.learning.plan.item.student,model_encoach_learning_plan_item,encoach_core.group_encoach_student,1,1,0,0
access_plan_item_teacher,encoach.learning.plan.item.teacher,model_encoach_learning_plan_item,encoach_core.group_encoach_teacher,1,1,1,0
access_plan_item_admin,encoach.learning.plan.item.admin,model_encoach_learning_plan_item,encoach_core.group_encoach_admin,1,1,1,1
access_diagnostic_student,encoach.diagnostic.session.student,model_encoach_diagnostic_session,encoach_core.group_encoach_student,1,1,1,0
access_diagnostic_teacher,encoach.diagnostic.session.teacher,model_encoach_diagnostic_session,encoach_core.group_encoach_teacher,1,1,1,0
access_diagnostic_admin,encoach.diagnostic.session.admin,model_encoach_diagnostic_session,encoach_core.group_encoach_admin,1,1,1,1
access_content_cache_student,encoach.content.cache.student,model_encoach_content_cache,encoach_core.group_encoach_student,1,0,0,0
access_content_cache_admin,encoach.content.cache.admin,model_encoach_content_cache,encoach_core.group_encoach_admin,1,1,1,1
access_adaptive_event_user,encoach.adaptive.event.user,model_encoach_adaptive_event,base.group_user,1,1,1,1
access_adaptive_path_user,encoach.adaptive.path.user,model_encoach_adaptive_path,base.group_user,1,1,1,1
access_adaptive_settings_user,encoach.adaptive.settings.user,model_encoach_adaptive_settings,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_proficiency_student encoach.proficiency.student model_encoach_proficiency encoach_core.group_encoach_student 1 0 0 0
3 access_proficiency_teacher encoach.proficiency.teacher model_encoach_proficiency encoach_core.group_encoach_teacher 1 1 1 0
4 access_proficiency_admin encoach.proficiency.admin model_encoach_proficiency encoach_core.group_encoach_admin 1 1 1 1
5 access_learning_plan_student encoach.learning.plan.student model_encoach_learning_plan encoach_core.group_encoach_student 1 1 0 0
6 access_learning_plan_teacher encoach.learning.plan.teacher model_encoach_learning_plan encoach_core.group_encoach_teacher 1 1 1 0
7 access_learning_plan_admin encoach.learning.plan.admin model_encoach_learning_plan encoach_core.group_encoach_admin 1 1 1 1
8 access_plan_item_student encoach.learning.plan.item.student model_encoach_learning_plan_item encoach_core.group_encoach_student 1 1 0 0
9 access_plan_item_teacher encoach.learning.plan.item.teacher model_encoach_learning_plan_item encoach_core.group_encoach_teacher 1 1 1 0
10 access_plan_item_admin encoach.learning.plan.item.admin model_encoach_learning_plan_item encoach_core.group_encoach_admin 1 1 1 1
11 access_diagnostic_student encoach.diagnostic.session.student model_encoach_diagnostic_session encoach_core.group_encoach_student 1 1 1 0
12 access_diagnostic_teacher encoach.diagnostic.session.teacher model_encoach_diagnostic_session encoach_core.group_encoach_teacher 1 1 1 0
13 access_diagnostic_admin encoach.diagnostic.session.admin model_encoach_diagnostic_session encoach_core.group_encoach_admin 1 1 1 1
14 access_content_cache_student encoach.content.cache.student model_encoach_content_cache encoach_core.group_encoach_student 1 0 0 0
15 access_content_cache_admin encoach.content.cache.admin model_encoach_content_cache encoach_core.group_encoach_admin 1 1 1 1
16 access_adaptive_event_user encoach.adaptive.event.user model_encoach_adaptive_event base.group_user 1 1 1 1
17 access_adaptive_path_user encoach.adaptive.path.user model_encoach_adaptive_path base.group_user 1 1 1 1
18 access_adaptive_settings_user encoach.adaptive.settings.user model_encoach_adaptive_settings base.group_user 1 1 1 1

View File

@@ -0,0 +1 @@
from .adaptive_engine import AdaptiveEngine

View File

@@ -0,0 +1,149 @@
import logging
import json
_logger = logging.getLogger(__name__)
class AdaptiveEngine:
"""4-phase adaptive learning engine.
Phase 1: Module-level up/down stepping
Phase 2: Micro-lesson injection
Phase 3: Module skipping
Phase 4: No-progress alerts
"""
DEFAULT_SETTINGS = {
'step_up_threshold': 0.85,
'step_down_threshold': 0.50,
'micro_lesson_trigger': 2,
'module_skip_threshold': 0.95,
'no_progress_alert_days': 3,
'max_retries': 3,
}
@staticmethod
def get_settings(env, teacher_id=None, entity_id=None):
"""Get adaptive settings for teacher/entity or defaults."""
Settings = env['encoach.adaptive.settings'].sudo()
settings = None
if teacher_id:
settings = Settings.search([('teacher_id', '=', teacher_id)], limit=1)
if not settings and entity_id:
settings = Settings.search([('entity_id', '=', entity_id), ('teacher_id', '=', False)], limit=1)
if settings:
return {
'step_up_threshold': settings.step_up_threshold,
'step_down_threshold': settings.step_down_threshold,
'micro_lesson_trigger': settings.micro_lesson_trigger,
'module_skip_threshold': settings.module_skip_threshold,
'no_progress_alert_days': settings.no_progress_alert_days,
'max_retries': settings.max_retries,
}
return dict(AdaptiveEngine.DEFAULT_SETTINGS)
@staticmethod
def process_checkpoint(env, student_id, course_id, module_id, score, settings=None):
"""Process a module checkpoint and make adaptive decisions."""
if not settings:
settings = AdaptiveEngine.DEFAULT_SETTINGS
Event = env['encoach.adaptive.event'].sudo()
Module = env['encoach.course.module'].sudo()
module = Module.browse(module_id)
decision = None
signals = []
signals.append({
'signal_name': 'checkpoint_score',
'signal_value': score,
})
if score >= settings['step_up_threshold']:
decision = 'step_up'
module.write({'status': 'completed'})
next_module = Module.search([
('course_id', '=', course_id),
('sequence', '>', module.sequence),
('status', '=', 'locked'),
], limit=1, order='sequence')
if next_module:
next_module.write({'status': 'available'})
elif score < settings['step_down_threshold']:
decision = 'step_down'
else:
decision = 'continue'
module.write({'status': 'completed'})
next_module = Module.search([
('course_id', '=', course_id),
('sequence', '>', module.sequence),
('status', '=', 'locked'),
], limit=1, order='sequence')
if next_module:
next_module.write({'status': 'available'})
if score >= settings['module_skip_threshold']:
skip_modules = Module.search([
('course_id', '=', course_id),
('sequence', '>', module.sequence),
('status', '=', 'locked'),
], limit=2, order='sequence')
for sm in skip_modules:
sm.write({'status': 'skipped'})
if skip_modules:
decision = 'skip_ahead'
signals.append({'signal_name': 'module_skip', 'signal_value': len(skip_modules)})
for sig in signals:
Event.create({
'student_id': student_id,
'course_id': course_id,
'event_type': 'signal',
'signal_name': sig['signal_name'],
'signal_value': sig['signal_value'],
})
Event.create({
'student_id': student_id,
'course_id': course_id,
'event_type': 'decision',
'decision': decision,
'context': json.dumps({'module_id': module_id, 'score': score}),
})
return {
'decision': decision,
'score': score,
'signals': signals,
}
@staticmethod
def check_no_progress(env, student_id, course_id, settings=None):
"""Phase 4: Check if student has stalled."""
if not settings:
settings = AdaptiveEngine.DEFAULT_SETTINGS
from datetime import datetime, timedelta
cutoff = datetime.now() - timedelta(days=settings['no_progress_alert_days'])
Event = env['encoach.adaptive.event'].sudo()
recent_events = Event.search_count([
('student_id', '=', student_id),
('course_id', '=', course_id),
('created_at', '>=', cutoff.strftime('%Y-%m-%d %H:%M:%S')),
])
if recent_events == 0:
Event.create({
'student_id': student_id,
'course_id': course_id,
'event_type': 'signal',
'signal_name': 'no_progress_alert',
'signal_value': settings['no_progress_alert_days'],
})
return True
return False

View File

@@ -0,0 +1,54 @@
.o_signal_timeline .st-timeline {
position: relative;
padding-left: 48px;
}
.o_signal_timeline .st-timeline::before {
content: "";
position: absolute;
left: 19px;
top: 0;
bottom: 0;
width: 2px;
background: #dee2e6;
}
.o_signal_timeline .st-timeline-item {
position: relative;
margin-bottom: 1rem;
}
.o_signal_timeline .st-timeline-marker {
position: absolute;
left: -48px;
top: 8px;
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 0.85rem;
z-index: 1;
border: 3px solid #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.o_signal_timeline .st-timeline-content {
padding-left: 4px;
}
.o_signal_timeline .st-timeline-content .card {
border-radius: 0.5rem;
transition: box-shadow 0.15s;
}
.o_signal_timeline .st-timeline-content .card:hover {
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08) !important;
}
.o_signal_timeline .st-student-card {
transition: transform 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.o_signal_timeline .st-student-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.o_signal_timeline .cursor-pointer {
cursor: pointer;
}

View File

@@ -0,0 +1,153 @@
/** @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 SignalTimeline extends Component {
static template = "encoach_adaptive.SignalTimeline";
static components = { Layout };
static props = ["*"];
setup() {
this.rpc = useService("rpc");
this.action = useService("action");
this.state = useState({
loading: true,
studentId: null,
student: null,
events: [],
filteredEvents: [],
filters: { eventType: "", dateFrom: "", dateTo: "" },
totalCount: 0,
});
onWillStart(async () => {
const ctx = this.props.action?.context || {};
this.state.studentId = ctx.student_id || ctx.active_id || null;
if (this.state.studentId) {
await this.loadTimeline();
} else {
await this.loadStudentList();
}
});
}
async loadStudentList() {
try {
this.state.studentList = await this.rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_read",
args: [[["account_source", "!=", false]], ["name", "email"]],
kwargs: { limit: 50, order: "name" },
});
} catch (e) {
console.error("Failed to load students:", e);
} finally {
this.state.loading = false;
}
}
async selectStudent(studentId) {
this.state.studentId = studentId;
this.state.loading = true;
await this.loadTimeline();
}
async loadTimeline() {
this.state.loading = true;
try {
const domain = [["student_id", "=", this.state.studentId]];
if (this.state.filters.eventType) {
domain.push(["event_type", "=", this.state.filters.eventType]);
}
if (this.state.filters.dateFrom) {
domain.push(["created_at", ">=", this.state.filters.dateFrom]);
}
if (this.state.filters.dateTo) {
domain.push(["created_at", "<=", this.state.filters.dateTo + " 23:59:59"]);
}
const [events, students] = await Promise.all([
this.rpc("/web/dataset/call_kw", {
model: "encoach.adaptive.event",
method: "search_read",
args: [domain, ["student_id", "course_id", "event_type", "signal_name", "signal_value", "decision", "context", "created_at"]],
kwargs: { limit: 100, order: "created_at desc" },
}),
this.rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_read",
args: [[["id", "=", this.state.studentId]], ["name", "email"]],
kwargs: {},
}),
]);
this.state.student = students.length ? students[0] : null;
this.state.events = events;
this.state.filteredEvents = events;
this.state.totalCount = events.length;
} catch (e) {
console.error("Timeline load error:", e);
} finally {
this.state.loading = false;
}
}
onFilterChange(field, ev) {
this.state.filters[field] = ev.target.value;
if (this.state.studentId) {
this.loadTimeline();
}
}
clearFilters() {
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
if (this.state.studentId) {
this.loadTimeline();
}
}
eventIcon(eventType) {
return eventType === "signal" ? "fa-bolt" : "fa-gavel";
}
eventColor(eventType) {
return eventType === "signal" ? "primary" : "success";
}
formatDate(dt) {
if (!dt) return "";
return dt.replace("T", " ").substring(0, 19);
}
parseContext(ctx) {
if (!ctx) return null;
try {
return typeof ctx === "string" ? JSON.parse(ctx) : ctx;
} catch {
return null;
}
}
get signalCount() {
return this.state.filteredEvents.filter(e => e.event_type === "signal").length;
}
get decisionCount() {
return this.state.filteredEvents.filter(e => e.event_type === "decision").length;
}
goBack() {
this.state.studentId = null;
this.state.student = null;
this.state.events = [];
this.state.filteredEvents = [];
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
this.state.loading = true;
this.loadStudentList();
}
}
registry.category("actions").add("encoach_signal_timeline", SignalTimeline);

View File

@@ -0,0 +1,177 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_adaptive.SignalTimeline">
<Layout display="{ controlPanel: {} }">
<div class="o_signal_timeline">
<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">Loading timeline...</p>
</div>
</t>
<!-- Student Selector -->
<t t-elif="!state.studentId and state.studentList">
<h2 class="mb-3">Adaptive Signal Timeline</h2>
<p class="text-muted mb-4">Select a student to view their adaptive learning events.</p>
<div class="row g-3">
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
<div class="col-lg-3 col-md-4 col-sm-6">
<div class="card shadow-sm h-100 cursor-pointer st-student-card"
t-on-click="() => this.selectStudent(st.id)">
<div class="card-body text-center">
<div class="rounded-circle bg-info bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
<i class="fa fa-user text-info"/>
</div>
<h6 class="card-title mb-0" t-esc="st.name"/>
<small class="text-muted" t-esc="st.email"/>
</div>
</div>
</div>
</t>
</div>
</t>
<!-- Timeline View -->
<t t-elif="state.student">
<div class="d-flex align-items-center mb-3">
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
t-if="!this.props.action?.context?.student_id">
<i class="fa fa-arrow-left me-1"/> Back
</button>
<div>
<h2 class="mb-0">Adaptive Timeline</h2>
<small class="text-muted">
Student: <strong t-esc="state.student.name"/>
</small>
</div>
</div>
<!-- Filters -->
<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">Event Type</label>
<select class="form-select form-select-sm"
t-on-change="(ev) => this.onFilterChange('eventType', ev)">
<option value="">All Events</option>
<option value="signal">Signals</option>
<option value="decision">Decisions</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">From Date</label>
<input type="date" class="form-control form-control-sm"
t-on-change="(ev) => this.onFilterChange('dateFrom', ev)"/>
</div>
<div class="col-md-3">
<label class="form-label small fw-bold mb-1">To Date</label>
<input type="date" class="form-control form-control-sm"
t-on-change="(ev) => this.onFilterChange('dateTo', ev)"/>
</div>
<div class="col-md-3 text-end">
<button class="btn btn-sm btn-outline-secondary" t-on-click="clearFilters">
<i class="fa fa-refresh me-1"/> Reset
</button>
</div>
</div>
</div>
</div>
<!-- Summary Badges -->
<div class="d-flex gap-3 mb-4">
<div class="bg-light rounded px-3 py-2 d-flex align-items-center">
<i class="fa fa-list me-2 text-secondary"/>
<strong class="me-1" t-esc="state.totalCount"/> events
</div>
<div class="bg-primary bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
<i class="fa fa-bolt me-2 text-primary"/>
<strong class="me-1" t-esc="signalCount"/> signals
</div>
<div class="bg-success bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
<i class="fa fa-gavel me-2 text-success"/>
<strong class="me-1" t-esc="decisionCount"/> decisions
</div>
</div>
<!-- Timeline -->
<div class="st-timeline">
<t t-foreach="state.filteredEvents" t-as="ev" t-key="ev.id">
<div class="st-timeline-item">
<div class="st-timeline-marker"
t-att-class="'bg-' + eventColor(ev.event_type)">
<i class="fa" t-att-class="eventIcon(ev.event_type)"/>
</div>
<div class="st-timeline-content">
<div class="card shadow-sm">
<div class="card-body py-2 px-3">
<div class="d-flex justify-content-between align-items-start mb-1">
<div>
<span class="badge me-1"
t-att-class="'bg-' + eventColor(ev.event_type)"
t-esc="ev.event_type"/>
<strong t-if="ev.signal_name" t-esc="ev.signal_name"/>
<strong t-elif="ev.decision">Decision</strong>
</div>
<small class="text-muted" t-esc="formatDate(ev.created_at)"/>
</div>
<!-- Signal details -->
<div t-if="ev.event_type === 'signal'" class="mt-1">
<span class="text-muted small">Value: </span>
<span class="fw-bold" t-esc="ev.signal_value"/>
<t t-if="ev.course_id">
<span class="text-muted small ms-3">Course: </span>
<span t-esc="ev.course_id[1]"/>
</t>
</div>
<!-- Decision details -->
<div t-if="ev.event_type === 'decision'" class="mt-1">
<span class="small" t-esc="ev.decision"/>
<t t-if="ev.course_id">
<span class="text-muted small ms-3">Course: </span>
<span t-esc="ev.course_id[1]"/>
</t>
</div>
<!-- Context (if any) -->
<div t-if="parseContext(ev.context)" class="mt-1">
<small class="text-muted">
<i class="fa fa-info-circle me-1"/>
<t t-foreach="Object.entries(parseContext(ev.context) || {})" t-as="entry" t-key="entry[0]">
<span class="me-2">
<span class="fw-bold" t-esc="entry[0]"/>: <t t-esc="entry[1]"/>
</span>
</t>
</small>
</div>
</div>
</div>
</div>
</div>
</t>
<t t-if="!state.filteredEvents.length">
<div class="text-center py-5 text-muted">
<i class="fa fa-clock-o fa-3x mb-2 d-block"/>
<p>No adaptive events found for this student.</p>
</div>
</t>
</div>
</t>
<t t-elif="!state.studentId">
<div class="text-center py-5 text-muted">
<i class="fa fa-clock-o fa-3x mb-3 d-block"/>
<p>No student selected. Open from a student record or select one above.</p>
</div>
</t>
</div>
</div>
</Layout>
</t>
</templates>

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_adaptive_event_form" model="ir.ui.view">
<field name="name">encoach.adaptive.event.form</field>
<field name="model">encoach.adaptive.event</field>
<field name="arch" type="xml">
<form string="Adaptive Event">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="course_id"/>
<field name="event_type"/>
</group>
<group>
<field name="signal_name"/>
<field name="signal_value"/>
<field name="decision"/>
<field name="created_at"/>
</group>
</group>
<group>
<field name="context"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_adaptive_event_list" model="ir.ui.view">
<field name="name">encoach.adaptive.event.list</field>
<field name="model">encoach.adaptive.event</field>
<field name="arch" type="xml">
<list string="Adaptive Events">
<field name="student_id"/>
<field name="event_type" widget="badge"/>
<field name="signal_name"/>
<field name="signal_value"/>
<field name="decision"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="view_adaptive_event_search" model="ir.ui.view">
<field name="name">encoach.adaptive.event.search</field>
<field name="model">encoach.adaptive.event</field>
<field name="arch" type="xml">
<search string="Search Adaptive Events">
<field name="student_id"/>
<separator/>
<filter string="Signals" name="signal" domain="[('event_type', '=', 'signal')]"/>
<filter string="Decisions" name="decision" domain="[('event_type', '=', 'decision')]"/>
<separator/>
<group expand="0" string="Group By">
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_adaptive_event" model="ir.actions.act_window">
<field name="name">Adaptive Events</field>
<field name="res_model">encoach.adaptive.event</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_adaptive_events"
name="Adaptive Events"
parent="encoach_core.menu_encoach_config"
action="encoach_adaptive.action_adaptive_event"
sequence="60"/>
<menuitem id="menu_adaptive_paths"
name="Adaptive Paths"
parent="encoach_core.menu_encoach_config"
action="encoach_adaptive.action_adaptive_path"
sequence="70"/>
<menuitem id="menu_adaptive_settings"
name="Adaptive Settings"
parent="encoach_core.menu_encoach_config"
action="encoach_adaptive.action_adaptive_settings"
sequence="80"/>
</odoo>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_adaptive_path_form" model="ir.ui.view">
<field name="name">encoach.adaptive.path.form</field>
<field name="model">encoach.adaptive.path</field>
<field name="arch" type="xml">
<form string="Adaptive Path">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="course_id"/>
<field name="source"/>
</group>
</group>
<group>
<field name="module_queue"/>
</group>
<group>
<field name="next_generation_brief"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_adaptive_path_list" model="ir.ui.view">
<field name="name">encoach.adaptive.path.list</field>
<field name="model">encoach.adaptive.path</field>
<field name="arch" type="xml">
<list string="Adaptive Paths">
<field name="student_id"/>
<field name="course_id"/>
<field name="source"/>
</list>
</field>
</record>
<record id="action_adaptive_path" model="ir.actions.act_window">
<field name="name">Adaptive Paths</field>
<field name="res_model">encoach.adaptive.path</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_adaptive_settings_form" model="ir.ui.view">
<field name="name">encoach.adaptive.settings.form</field>
<field name="model">encoach.adaptive.settings</field>
<field name="arch" type="xml">
<form string="Adaptive Settings">
<sheet>
<group>
<group>
<field name="teacher_id"/>
<field name="entity_id"/>
</group>
<group>
<field name="step_up_threshold"/>
<field name="step_down_threshold"/>
</group>
</group>
<group>
<group>
<field name="micro_lesson_trigger"/>
<field name="module_skip_threshold"/>
</group>
<group>
<field name="no_progress_alert_days"/>
<field name="max_retries"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_adaptive_settings_list" model="ir.ui.view">
<field name="name">encoach.adaptive.settings.list</field>
<field name="model">encoach.adaptive.settings</field>
<field name="arch" type="xml">
<list string="Adaptive Settings">
<field name="teacher_id"/>
<field name="entity_id"/>
<field name="step_up_threshold"/>
<field name="step_down_threshold"/>
</list>
</field>
</record>
<record id="action_adaptive_settings" model="ir.actions.act_window">
<field name="name">Adaptive Settings</field>
<field name="res_model">encoach.adaptive.settings</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_signal_timeline" model="ir.actions.client">
<field name="name">Signal Timeline</field>
<field name="tag">encoach_signal_timeline</field>
</record>
<menuitem id="menu_signal_timeline"
name="Signal Timeline"
parent="encoach_core.menu_encoach_config"
action="action_signal_timeline"
sequence="55"/>
</odoo>

View File

@@ -0,0 +1,3 @@
from . import models
from . import services
from . import controllers

View File

@@ -0,0 +1,18 @@
{
'name': 'EnCoach AI Course',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'AI content generation pipelines for General English and IELTS courses',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen'],
'data': [
'security/ir.model.access.csv',
'views/ai_generation_log_views.xml',
'views/ai_ielts_log_views.xml',
'views/ai_course_menus.xml',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,264 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
class EncoachAiCourseController(http.Controller):
# ------------------------------------------------------------------
# POST /api/ai-course/english/create
# ------------------------------------------------------------------
@http.route('/api/ai-course/english/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_english(self, **kw):
try:
body = _get_json_body()
cefr_level = body.get('cefr_level')
if not cefr_level:
return _error_response('cefr_level is required', 400)
user = request.env.user.sudo()
gap_profile_id = body.get('gap_profile_id')
GapProfile = request.env['encoach.gap.profile'].sudo()
if gap_profile_id:
gap_profile = GapProfile.browse(int(gap_profile_id))
if not gap_profile.exists():
return _error_response('Gap profile not found', 404)
else:
gap_profile = GapProfile.search(
[('student_id', '=', user.id)],
order='created_at desc', limit=1,
)
if not gap_profile:
gap_profile = GapProfile.create({
'student_id': user.id,
'source_type': 'exam',
'skill_gaps': json.dumps([]),
'topic_weaknesses': json.dumps([]),
})
brief = {
'skill_gaps': json.loads(gap_profile.skill_gaps or '[]'),
'topic_weaknesses': json.loads(gap_profile.topic_weaknesses or '[]'),
'cefr_level': cefr_level,
}
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.create({
'student_id': user.id,
'course_type': 'general_english',
'status': 'pending_review',
'brief': json.dumps(brief),
'attempts': 1,
})
return _json_response({
'log_id': log.id,
'status': log.status,
'brief': brief,
}, 201)
except Exception as e:
_logger.exception('create_english failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/ielts/create
# ------------------------------------------------------------------
@http.route('/api/ai-course/ielts/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_ielts(self, **kw):
try:
body = _get_json_body()
skill = body.get('skill')
target_band = body.get('target_band')
if not skill:
return _error_response('skill is required', 400)
if not target_band:
return _error_response('target_band is required', 400)
valid_skills = ('listening', 'reading', 'writing', 'speaking')
if skill not in valid_skills:
return _error_response(
f'skill must be one of: {", ".join(valid_skills)}', 400,
)
brief = {
'skill': skill,
'target_band': float(target_band),
'custom_instructions': body.get('brief', ''),
}
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
log = Log.create({
'skill': skill,
'status': 'format_check',
'brief': json.dumps(brief),
'attempts': 1,
})
return _json_response({
'log_id': log.id,
'status': log.status,
'skill': log.skill,
}, 201)
except Exception as e:
_logger.exception('create_ielts failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/<int:course_id>/quality
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/quality', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def quality(self, course_id, **kw):
try:
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
return _json_response({
'status': log.status,
'readability_score': 0.0,
'cefr_alignment': log.status in ('quality_check', 'approved'),
'grammar_issues': [],
'attempts': log.attempts,
})
except Exception as e:
_logger.exception('quality check failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/<int:course_id>/approve
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve(self, course_id, **kw):
try:
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
if log.status == 'approved':
return _error_response('Already approved', 400)
log.write({
'status': 'approved',
'approved_by': request.env.user.id,
})
return _json_response({'approved': True})
except Exception as e:
_logger.exception('approve failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/ai-course/<int:course_id>/reject
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject(self, course_id, **kw):
try:
body = _get_json_body()
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
reason = body.get('reason', '')
can_retry = log.attempts < 3
if can_retry:
log.write({
'status': 'generating',
'attempts': log.attempts + 1,
'error_log': reason or log.error_log,
})
else:
log.write({
'status': 'rejected',
'error_log': reason or log.error_log,
})
return _json_response({
'rejected': True,
'can_retry': can_retry,
})
except Exception as e:
_logger.exception('reject failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/ai-course/<int:course_id>/validation
# ------------------------------------------------------------------
@http.route('/api/ai-course/<int:course_id>/validation', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def validation(self, course_id, **kw):
try:
IeltsLog = request.env['encoach.ai.ielts.generation.log'].sudo()
ielts_log = IeltsLog.browse(course_id)
if ielts_log.exists():
fmt_result = {}
band_result = {}
try:
fmt_result = json.loads(ielts_log.format_check_result or '{}')
except (json.JSONDecodeError, TypeError):
pass
try:
band_result = json.loads(ielts_log.band_check_result or '{}')
except (json.JSONDecodeError, TypeError):
pass
overall_passed = bool(
ielts_log.format_check_result and ielts_log.band_check_result
)
return _json_response({
'type': 'ielts',
'validation_results': {
'format_check_result': fmt_result,
'band_check_result': band_result,
},
'overall_passed': overall_passed,
})
Log = request.env['encoach.ai.generation.log'].sudo()
log = Log.browse(course_id)
if not log.exists():
return _error_response('Generation log not found', 404)
validation_results = {
'status': log.status,
'quality_passed': log.status in ('approved', 'pending_review'),
'attempts': log.attempts,
}
return _json_response({
'type': 'english',
'validation_results': validation_results,
'overall_passed': log.status == 'approved',
})
except Exception as e:
_logger.exception('validation check failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,2 @@
from . import ai_generation_log
from . import ai_ielts_generation_log

View File

@@ -0,0 +1,25 @@
from odoo import models, fields
class EncoachAiGenerationLog(models.Model):
_name = 'encoach.ai.generation.log'
_description = 'AI Generation Log'
student_id = fields.Many2one('res.users', ondelete='set null')
course_type = fields.Selection([
('general_english', 'General English'),
('ielts', 'IELTS'),
], required=True)
brief = fields.Text()
attempts = fields.Integer(default=0)
final_resource_id = fields.Many2one('encoach.resource', ondelete='set null')
approved_by = fields.Many2one('res.users', ondelete='set null')
status = fields.Selection([
('generating', 'Generating'),
('quality_check', 'Quality Check'),
('pending_review', 'Pending Review'),
('approved', 'Approved'),
('rejected', 'Rejected'),
], default='generating', required=True)
created_at = fields.Datetime(default=fields.Datetime.now)
error_log = fields.Text()

View File

@@ -0,0 +1,26 @@
from odoo import models, fields
class EncoachAiIeltsGenerationLog(models.Model):
_name = 'encoach.ai.ielts.generation.log'
_description = 'AI IELTS Generation Log'
skill = fields.Selection([
('listening', 'Listening'),
('reading', 'Reading'),
('writing', 'Writing'),
('speaking', 'Speaking'),
], required=True)
brief = fields.Text()
format_check_result = fields.Text()
band_check_result = fields.Text()
examiner_id = fields.Many2one('res.users', ondelete='set null')
status = fields.Selection([
('generating', 'Generating'),
('format_check', 'Format Check'),
('examiner_review', 'Examiner Review'),
('approved', 'Approved'),
('rejected', 'Rejected'),
], default='generating', required=True)
attempts = fields.Integer(default=0)
created_at = fields.Datetime(default=fields.Datetime.now)

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_ai_generation_log_user,encoach.ai.generation.log.user,model_encoach_ai_generation_log,base.group_user,1,1,1,1
access_encoach_ai_ielts_generation_log_user,encoach.ai.ielts.generation.log.user,model_encoach_ai_ielts_generation_log,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_ai_generation_log_user encoach.ai.generation.log.user model_encoach_ai_generation_log base.group_user 1 1 1 1
3 access_encoach_ai_ielts_generation_log_user encoach.ai.ielts.generation.log.user model_encoach_ai_ielts_generation_log base.group_user 1 1 1 1

View File

@@ -0,0 +1,2 @@
from .english_pipeline import EnglishPipeline
from .ielts_pipeline import IeltsPipeline

View File

@@ -0,0 +1,109 @@
import json
import logging
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
_logger = logging.getLogger(__name__)
class EnglishPipeline:
"""AI content generation pipeline for General English courses."""
def __init__(self, env):
self.env = env
self.ai = OpenAIService(env)
def generate_content(self, env, gap_profile, cefr_level):
"""Generate General English content based on gap analysis.
Args:
env: Odoo environment.
gap_profile: dict with 'skill_gaps' list describing weak areas.
cefr_level: target CEFR level string (e.g. 'B1').
Returns:
dict with generated content.
"""
skill_gaps = gap_profile.get('skill_gaps', [])
gaps_description = ', '.join(skill_gaps) if skill_gaps else 'general skills'
messages = [
{'role': 'system', 'content': (
'You are an expert English language curriculum designer. '
'Return JSON with keys: "title", "objectives", "units" '
'(each unit has "topic", "grammar_focus", "vocabulary", '
'"reading_text", "exercises").'
)},
{'role': 'user', 'content': (
f'Generate a General English course at CEFR {cefr_level} level. '
f'Focus on these gap areas: {gaps_description}. '
f'Include practical, real-world content appropriate for the level.'
)},
]
try:
content = self.ai.chat_json(messages, temperature=0.8)
except Exception as e:
_logger.error("English content generation failed: %s", e)
env['encoach.ai.generation.log'].create({
'course_type': 'general_english',
'brief': json.dumps(gap_profile),
'status': 'rejected',
'error_log': str(e),
})
return {'error': str(e)}
env['encoach.ai.generation.log'].create({
'course_type': 'general_english',
'brief': json.dumps(gap_profile),
'status': 'quality_check',
'attempts': 1,
})
return content
def quality_check(self, env, content, cefr_level):
"""Run quality gate checks on generated content.
Args:
env: Odoo environment.
content: dict of generated content to validate.
cefr_level: target CEFR level for alignment check.
Returns:
dict with 'passed' bool and 'issues' list.
"""
issues = []
if not content.get('units'):
issues.append('No units generated')
else:
for i, unit in enumerate(content['units'], 1):
if not unit.get('exercises'):
issues.append(f'Unit {i} has no exercises')
if not unit.get('reading_text'):
issues.append(f'Unit {i} has no reading text')
if not content.get('objectives'):
issues.append('No learning objectives defined')
messages = [
{'role': 'system', 'content': (
'You are a CEFR alignment expert. Return JSON with keys: '
'"aligned" (bool) and "issues" (list of strings).'
)},
{'role': 'user', 'content': (
f'Check if this content is aligned with CEFR {cefr_level}: '
f'{json.dumps(content)}'
)},
]
try:
alignment = self.ai.chat_json(messages, temperature=0.3)
if not alignment.get('aligned'):
issues.extend(alignment.get('issues', ['CEFR misalignment detected']))
except Exception as e:
_logger.warning("CEFR alignment check failed: %s", e)
issues.append(f'CEFR alignment check error: {e}')
return {'passed': len(issues) == 0, 'issues': issues}

View File

@@ -0,0 +1,155 @@
import json
import logging
from odoo.addons.encoach_ai.services.openai_service import OpenAIService
_logger = logging.getLogger(__name__)
SKILL_PROMPTS = {
'listening': (
'Generate an IELTS Listening section. Return JSON with keys: '
'"script", "speakers", "duration_estimate", "questions".'
),
'reading': (
'Generate an IELTS Reading passage with questions. Return JSON with keys: '
'"passage", "word_count", "questions", "question_types".'
),
'writing': (
'Generate an IELTS Writing task. Return JSON with keys: '
'"task_type", "prompt", "requirements", "sample_band_descriptors".'
),
'speaking': (
'Generate an IELTS Speaking part. Return JSON with keys: '
'"part", "questions", "cue_card" (if part 2), "follow_up_questions".'
),
}
IELTS_FORMAT_RULES = {
'listening': {'required_keys': ['script', 'questions'], 'min_questions': 10},
'reading': {'required_keys': ['passage', 'questions'], 'min_word_count': 500},
'writing': {'required_keys': ['prompt', 'requirements']},
'speaking': {'required_keys': ['questions']},
}
class IeltsPipeline:
"""AI content generation pipeline for IELTS skill-specific content."""
def __init__(self, env):
self.env = env
self.ai = OpenAIService(env)
def generate_content(self, env, skill, brief):
"""Generate IELTS skill-specific content.
Args:
env: Odoo environment.
skill: one of 'listening', 'reading', 'writing', 'speaking'.
brief: dict with generation parameters.
Returns:
dict with generated content.
"""
system_prompt = SKILL_PROMPTS.get(skill, SKILL_PROMPTS['reading'])
brief_text = json.dumps(brief) if isinstance(brief, dict) else str(brief)
messages = [
{'role': 'system', 'content': (
f'You are an expert IELTS content creator. {system_prompt}'
)},
{'role': 'user', 'content': (
f'Generate IELTS {skill} content based on this brief: {brief_text}'
)},
]
try:
content = self.ai.chat_json(messages, temperature=0.8)
except Exception as e:
_logger.error("IELTS %s content generation failed: %s", skill, e)
return {'error': str(e)}
env['encoach.ai.ielts.generation.log'].create({
'skill': skill,
'brief': brief_text,
'status': 'format_check',
'attempts': 1,
})
return content
def format_check(self, env, content, skill):
"""Validate IELTS format compliance for a given skill.
Args:
env: Odoo environment.
content: dict of generated content.
skill: IELTS skill type.
Returns:
dict with 'passed' bool and 'errors' list.
"""
errors = []
rules = IELTS_FORMAT_RULES.get(skill, {})
for key in rules.get('required_keys', []):
if key not in content or not content[key]:
errors.append(f'Missing required key: {key}')
if skill == 'listening':
questions = content.get('questions', [])
min_q = rules.get('min_questions', 10)
if len(questions) < min_q:
errors.append(
f'Listening requires at least {min_q} questions, '
f'got {len(questions)}'
)
if skill == 'reading':
passage = content.get('passage', '')
min_wc = rules.get('min_word_count', 500)
word_count = len(passage.split()) if passage else 0
if word_count < min_wc:
errors.append(
f'Reading passage must be at least {min_wc} words, '
f'got {word_count}'
)
if skill == 'speaking' and not content.get('questions'):
errors.append('Speaking section requires at least one question')
return {'passed': len(errors) == 0, 'errors': errors}
def band_calibration(self, env, content, target_band):
"""Check content aligns with target IELTS band level.
Args:
env: Odoo environment.
content: dict of generated content.
target_band: float target band score (e.g. 6.5).
Returns:
dict with 'passed' bool and 'issues' list.
"""
messages = [
{'role': 'system', 'content': (
'You are an IELTS band calibration expert. Evaluate whether '
'the provided content matches the target band level. '
'Return JSON with keys: "calibrated" (bool), "estimated_band" (float), '
'"issues" (list of strings).'
)},
{'role': 'user', 'content': (
f'Target band: {target_band}. '
f'Content: {json.dumps(content)}'
)},
]
try:
result = self.ai.chat_json(messages, temperature=0.3)
except Exception as e:
_logger.error("Band calibration failed: %s", e)
return {'passed': False, 'issues': [f'Band calibration error: {e}']}
return {
'passed': result.get('calibrated', False),
'issues': result.get('issues', []),
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_ai_generation_logs"
name="AI Generation Logs"
parent="encoach_core.menu_encoach_courses"
action="encoach_ai_course.action_ai_generation_log"
sequence="30"/>
<menuitem id="menu_ai_ielts_logs"
name="IELTS Generation Logs"
parent="encoach_core.menu_encoach_courses"
action="encoach_ai_course.action_ai_ielts_log"
sequence="40"/>
</odoo>

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_ai_generation_log_form" model="ir.ui.view">
<field name="name">encoach.ai.generation.log.form</field>
<field name="model">encoach.ai.generation.log</field>
<field name="arch" type="xml">
<form string="AI Generation Log">
<header>
<field name="status" widget="statusbar" statusbar_visible="generating,quality_check,pending_review,approved"/>
</header>
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="course_type"/>
<field name="attempts"/>
</group>
<group>
<field name="final_resource_id"/>
<field name="approved_by"/>
<field name="created_at"/>
</group>
</group>
<notebook>
<page string="Brief" name="brief">
<field name="brief"/>
</page>
<page string="Error Log" name="error_log">
<field name="error_log"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_ai_generation_log_list" model="ir.ui.view">
<field name="name">encoach.ai.generation.log.list</field>
<field name="model">encoach.ai.generation.log</field>
<field name="arch" type="xml">
<list string="AI Generation Logs">
<field name="course_type"/>
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'pending_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
<field name="attempts"/>
<field name="student_id"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="view_ai_generation_log_search" model="ir.ui.view">
<field name="name">encoach.ai.generation.log.search</field>
<field name="model">encoach.ai.generation.log</field>
<field name="arch" type="xml">
<search string="Search AI Generation Logs">
<field name="student_id"/>
<separator/>
<filter string="Generating" name="generating" domain="[('status', '=', 'generating')]"/>
<filter string="Quality Check" name="quality_check" domain="[('status', '=', 'quality_check')]"/>
<filter string="Pending Review" name="pending_review" domain="[('status', '=', 'pending_review')]"/>
<filter string="Approved" name="approved" domain="[('status', '=', 'approved')]"/>
<filter string="Rejected" name="rejected" domain="[('status', '=', 'rejected')]"/>
<separator/>
<filter string="General English" name="general_english" domain="[('course_type', '=', 'general_english')]"/>
<filter string="IELTS" name="ielts" domain="[('course_type', '=', 'ielts')]"/>
</search>
</field>
</record>
<record id="action_ai_generation_log" model="ir.actions.act_window">
<field name="name">AI Generation Logs</field>
<field name="res_model">encoach.ai.generation.log</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_ai_ielts_log_form" model="ir.ui.view">
<field name="name">encoach.ai.ielts.generation.log.form</field>
<field name="model">encoach.ai.ielts.generation.log</field>
<field name="arch" type="xml">
<form string="IELTS Generation Log">
<header>
<field name="status" widget="statusbar" statusbar_visible="generating,format_check,examiner_review,approved"/>
</header>
<sheet>
<group>
<group>
<field name="skill"/>
<field name="attempts"/>
<field name="examiner_id"/>
</group>
<group>
<field name="created_at"/>
</group>
</group>
<notebook>
<page string="Brief" name="brief">
<field name="brief"/>
</page>
<page string="Format Check Result" name="format_check">
<field name="format_check_result"/>
</page>
<page string="Band Check Result" name="band_check">
<field name="band_check_result"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_ai_ielts_log_list" model="ir.ui.view">
<field name="name">encoach.ai.ielts.generation.log.list</field>
<field name="model">encoach.ai.ielts.generation.log</field>
<field name="arch" type="xml">
<list string="IELTS Generation Logs">
<field name="skill"/>
<field name="status" widget="badge" decoration-info="status == 'generating'" decoration-warning="status == 'examiner_review'" decoration-success="status == 'approved'" decoration-danger="status == 'rejected'"/>
<field name="attempts"/>
<field name="examiner_id"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="action_ai_ielts_log" model="ir.actions.act_window">
<field name="name">IELTS Generation Logs</field>
<field name="res_model">encoach.ai.ielts.generation.log</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,15 @@
{
'name': 'EnCoach Branding',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Whitelabeling and custom branding per entity',
'author': 'EnCoach',
'depends': ['encoach_core'],
'data': [
'security/ir.model.access.csv',
'views/branding_views.xml',
'views/branding_menus.xml',
],
'installable': True,
'license': 'LGPL-3',
}

View File

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

View File

@@ -0,0 +1,225 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate,
validate_token,
)
_logger = logging.getLogger(__name__)
class EncoachBrandingController(http.Controller):
# ------------------------------------------------------------------
# GET /api/entity/<int:entity_id>/level-mapping
# ------------------------------------------------------------------
@http.route('/api/entity/<int:entity_id>/level-mapping', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_level_mapping(self, entity_id, **kw):
try:
Entity = request.env['encoach.entity'].sudo()
entity = Entity.browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
Mapping = request.env['encoach.entity.level.mapping'].sudo()
mappings = Mapping.search(
[('entity_id', '=', entity_id)],
order='min_score asc',
)
items = []
for m in mappings:
items.append({
'id': m.id,
'entity_id': m.entity_id.id,
'min_score': m.min_score,
'max_score': m.max_score,
'internal_level_name': m.internal_level_name,
'cefr_equivalent': m.cefr_equivalent or '',
})
return _json_response({'mappings': items})
except Exception as e:
_logger.exception('get_level_mapping failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/entity/<int:entity_id>/level-mapping
# ------------------------------------------------------------------
@http.route('/api/entity/<int:entity_id>/level-mapping', type='http',
auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_level_mapping(self, entity_id, **kw):
try:
body = _get_json_body()
mappings_data = body.get('mappings', [])
if not mappings_data:
return _error_response('mappings list is required', 400)
Entity = request.env['encoach.entity'].sudo()
entity = Entity.browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
Mapping = request.env['encoach.entity.level.mapping'].sudo()
Mapping.search([('entity_id', '=', entity_id)]).unlink()
new_mappings = []
for md in mappings_data:
if not md.get('internal_level_name'):
return _error_response('internal_level_name is required for each mapping', 400)
rec = Mapping.create({
'entity_id': entity_id,
'min_score': float(md.get('min_score', 0)),
'max_score': float(md.get('max_score', 0)),
'internal_level_name': md['internal_level_name'],
'cefr_equivalent': md.get('cefr_equivalent') or False,
})
new_mappings.append({
'id': rec.id,
'entity_id': rec.entity_id.id,
'min_score': rec.min_score,
'max_score': rec.max_score,
'internal_level_name': rec.internal_level_name,
'cefr_equivalent': rec.cefr_equivalent or '',
})
return _json_response({'mappings': new_mappings})
except Exception as e:
_logger.exception('update_level_mapping failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/entity/<int:entity_id>/branding
# ------------------------------------------------------------------
@http.route('/api/entity/<int:entity_id>/branding', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_entity_branding(self, entity_id, **kw):
try:
Branding = request.env['encoach.branding'].sudo()
branding = Branding.search([('entity_id', '=', entity_id)], limit=1)
if not branding:
return _error_response('Branding not found for this entity', 404)
return _json_response(branding.to_api_dict())
except Exception as e:
_logger.exception('get_entity_branding failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/entity/<int:entity_id>/branding
# ------------------------------------------------------------------
@http.route('/api/entity/<int:entity_id>/branding', type='http',
auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_entity_branding(self, entity_id, **kw):
try:
body = _get_json_body()
Entity = request.env['encoach.entity'].sudo()
entity = Entity.browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
Branding = request.env['encoach.branding'].sudo()
branding = Branding.search([('entity_id', '=', entity_id)], limit=1)
allowed = [
'primary_color', 'secondary_color', 'background_color',
'app_name', 'login_title', 'login_description',
'white_label_domain', 'custom_css',
]
vals = {k: body[k] for k in allowed if k in body}
if not branding:
vals['entity_id'] = entity_id
branding = Branding.create(vals)
else:
branding.write(vals)
return _json_response(branding.to_api_dict())
except Exception as e:
_logger.exception('update_entity_branding failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/entity/branding (combined public + authenticated)
# GET /api/entity/branding/public
#
# If ?domain=<subdomain> is present → public lookup, no auth needed.
# Otherwise → JWT required, return user entity branding.
# ------------------------------------------------------------------
@http.route(
['/api/entity/branding', '/api/entity/branding/public'],
type='http', auth='none', methods=['GET'], csrf=False,
)
def get_branding(self, **kw):
try:
domain_param = kw.get('domain')
if domain_param:
Branding = request.env(su=True)['encoach.branding']
branding = Branding.search(
[('white_label_domain', '=', domain_param)], limit=1,
)
if not branding:
return _error_response('Branding not found for this domain', 404)
return _json_response({
'app_name': branding.app_name or 'EnCoach',
'primary_color': branding.primary_color or '#4F46E5',
'secondary_color': branding.secondary_color or '#7C3AED',
'background_color': branding.background_color or '#FFFFFF',
'has_logo': bool(branding.logo),
'logo_url': branding.logo_url or '',
'login_title': branding.login_title or '',
'login_description': branding.login_description or '',
'custom_css': branding.custom_css or '',
})
auth_header = request.httprequest.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return _error_response('Missing or invalid Authorization header', 401)
token = auth_header[7:]
uid = validate_token(token)
if not uid:
return _error_response('Invalid or expired token', 401)
request.update_env(user=uid)
user = request.env.user.sudo()
entity = user.entity_ids[:1]
if not entity:
return _error_response('User has no associated entity', 404)
Branding = request.env['encoach.branding'].sudo()
branding = Branding.search([('entity_id', '=', entity.id)], limit=1)
if not branding:
return _json_response({
'entity_id': entity.id,
'entity_name': entity.name,
'app_name': 'EnCoach',
'primary_color': entity.primary_color or '#4F46E5',
'secondary_color': entity.secondary_color or '#7C3AED',
'background_color': entity.background_color or '#FFFFFF',
'has_logo': bool(entity.logo),
'logo_url': entity.logo_url or '',
'login_title': entity.login_title or '',
'login_description': entity.login_description or '',
})
return _json_response(branding.to_api_dict())
except Exception as e:
_logger.exception('get_branding failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,40 @@
from odoo import models, fields
class EncoachBranding(models.Model):
_name = 'encoach.branding'
_description = 'Entity Branding'
entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade')
primary_color = fields.Char(default='#4F46E5')
secondary_color = fields.Char(default='#7C3AED')
background_color = fields.Char(default='#FFFFFF')
logo = fields.Binary(attachment=True)
logo_url = fields.Char(string='Logo URL')
favicon = fields.Binary(attachment=True)
app_name = fields.Char(default='EnCoach')
white_label_domain = fields.Char(string='Subdomain')
login_title = fields.Char(default='Welcome to EnCoach')
login_description = fields.Text()
custom_css = fields.Text()
_entity_unique = models.Constraint('UNIQUE(entity_id)', 'Branding record must be unique per entity.')
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'entity_id': self.entity_id.id,
'entity_name': self.entity_id.name,
'primary_color': self.primary_color,
'secondary_color': self.secondary_color,
'background_color': self.background_color or '#FFFFFF',
'has_logo': bool(self.logo),
'logo_url': self.logo_url or '',
'has_favicon': bool(self.favicon),
'app_name': self.app_name,
'white_label_domain': self.white_label_domain or '',
'login_title': self.login_title or '',
'login_description': self.login_description or '',
'custom_css': self.custom_css or '',
}

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_branding"
name="White-Label Branding"
parent="encoach_core.menu_encoach_entity"
action="encoach_branding.action_branding"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_branding_form" model="ir.ui.view">
<field name="name">encoach.branding.form</field>
<field name="model">encoach.branding</field>
<field name="arch" type="xml">
<form string="Branding">
<sheet>
<group>
<group>
<field name="entity_id"/>
<field name="app_name"/>
<field name="white_label_domain"/>
</group>
<group>
<field name="logo" widget="image" class="oe_avatar"/>
<field name="logo_url"/>
<field name="favicon" widget="image" class="oe_avatar"/>
</group>
</group>
<group string="Colors">
<group>
<field name="primary_color" widget="color"/>
<field name="secondary_color" widget="color"/>
<field name="background_color" widget="color"/>
</group>
</group>
<group string="Login Page">
<field name="login_title"/>
<field name="login_description"/>
</group>
<group string="Custom CSS">
<field name="custom_css"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_branding_list" model="ir.ui.view">
<field name="name">encoach.branding.list</field>
<field name="model">encoach.branding</field>
<field name="arch" type="xml">
<list string="Branding">
<field name="entity_id"/>
<field name="app_name"/>
<field name="primary_color"/>
<field name="secondary_color"/>
<field name="white_label_domain"/>
</list>
</field>
</record>
<record id="action_branding" model="ir.actions.act_window">
<field name="name">White-Label Branding</field>
<field name="res_model">encoach.branding</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,26 @@
{
'name': 'EnCoach Core',
'version': '19.0.1.1',
'category': 'Education',
'summary': 'Core models for EnCoach: users, entities, roles, permissions',
'author': 'EnCoach',
'depends': ['base', 'mail', 'openeducat_core'],
'data': [
'security/encoach_security.xml',
'security/ir.model.access.csv',
'data/encoach_permissions.xml',
'views/entity_level_mapping_views.xml',
'views/encoach_menus.xml',
'views/dashboard_action.xml',
],
'assets': {
'web.assets_backend': [
'encoach_core/static/src/js/dashboard.js',
'encoach_core/static/src/xml/dashboard.xml',
'encoach_core/static/src/css/dashboard.css',
],
},
'installable': True,
'application': True,
'license': 'LGPL-3',
}

View File

@@ -0,0 +1,6 @@
from . import encoach_user
from . import encoach_entity
from . import encoach_role
from . import encoach_permission
from . import encoach_invite_code
from . import entity_level_mapping

View File

@@ -0,0 +1,62 @@
from odoo import models, fields, api
class EncoachEntity(models.Model):
_name = 'encoach.entity'
_description = 'EnCoach Entity'
_inherit = ['mail.thread']
name = fields.Char(required=True, tracking=True)
code = fields.Char(required=True, tracking=True)
type = fields.Selection([
('corporate', 'Corporate'),
('university', 'University'),
('school', 'School'),
('government', 'Government'),
('freelance', 'Freelance'),
], tracking=True)
logo = fields.Binary(attachment=True)
logo_url = fields.Char(string='Logo URL')
primary_color = fields.Char(default='#4F46E5')
secondary_color = fields.Char(default='#7C3AED')
background_color = fields.Char(default='#FFFFFF')
white_label_domain = fields.Char(string='White Label Domain')
login_title = fields.Char()
login_description = fields.Text()
favicon = fields.Binary(attachment=True)
results_release_mode = fields.Selection([
('auto', 'Auto'),
('manual_approval', 'Manual Approval'),
], default='auto')
active = fields.Boolean(default=True)
user_ids = fields.Many2many('res.users', 'encoach_entity_user_rel', 'entity_id', 'user_id', string='Users')
role_ids = fields.One2many('encoach.role', 'entity_id', string='Roles')
user_count = fields.Integer(compute='_compute_user_count', store=True)
_code_unique = models.Constraint('UNIQUE(code)', 'Entity code must be unique.')
@api.depends('user_ids')
def _compute_user_count(self):
for rec in self:
rec.user_count = len(rec.user_ids)
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'code': self.code,
'type': self.type or '',
'logo': bool(self.logo),
'logo_url': self.logo_url or '',
'primary_color': self.primary_color or '#4F46E5',
'secondary_color': self.secondary_color or '#7C3AED',
'background_color': self.background_color or '#FFFFFF',
'white_label_domain': self.white_label_domain or '',
'login_title': self.login_title or '',
'login_description': self.login_description or '',
'has_favicon': bool(self.favicon),
'results_release_mode': self.results_release_mode or 'auto',
'active': self.active,
'user_count': self.user_count,
}

View File

@@ -0,0 +1,86 @@
from odoo import models, fields, api
USER_TYPE_SELECTION = [
('student', 'Student'),
('teacher', 'Teacher'),
('admin', 'Admin'),
('corporate', 'Corporate'),
('mastercorporate', 'Master Corporate'),
('agent', 'Agent'),
('developer', 'Developer'),
]
class EncoachUser(models.Model):
_inherit = 'res.users'
user_type = fields.Selection(USER_TYPE_SELECTION, string='User Type')
is_verified = fields.Boolean(default=False)
entity_ids = fields.Many2many('encoach.entity', 'encoach_entity_user_rel', 'user_id', 'entity_id', string='Entities')
phone = fields.Char()
birth_date = fields.Date()
gender = fields.Selection([('m', 'Male'), ('f', 'Female'), ('o', 'Other')])
op_student_id = fields.Many2one('op.student', string='OpenEduCat Student')
op_faculty_id = fields.Many2one('op.faculty', string='OpenEduCat Faculty')
encoach_avatar = fields.Binary(attachment=True, string='EnCoach Avatar')
last_login = fields.Datetime()
role_ids = fields.Many2many(
'encoach.role', 'encoach_role_user_rel', 'user_id', 'role_id', string='Roles',
)
permission_direct_ids = fields.Many2many(
'encoach.permission', 'encoach_user_permission_rel', 'user_id', 'permission_id',
string='Direct Permissions',
)
first_login = fields.Boolean(default=True)
account_source = fields.Selection([
('self_registered', 'Self Registered'),
('entity_bulk_upload', 'Entity Bulk Upload'),
])
account_status = fields.Selection([
('unactivated', 'Unactivated'),
('activated', 'Activated'),
('suspended', 'Suspended'),
], default='unactivated')
def get_all_permissions(self):
"""Return the union of direct permissions and permissions from all assigned roles."""
self.ensure_one()
role_permissions = self.role_ids.mapped('permission_ids')
return self.permission_direct_ids | role_permissions
def _api_user_type(self):
"""Frontend routes require a non-empty role; many users have no encoach.user_type set yet."""
self.ensure_one()
if self.user_type:
return self.user_type
if self.op_faculty_id:
return 'teacher'
if self.op_student_id:
return 'student'
if self.has_group('base.group_system'):
return 'admin'
return ''
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'email': self.email or '',
'login': self.login or '',
'user_type': self._api_user_type(),
'is_verified': self.is_verified,
'phone': self.phone or '',
'birth_date': self.birth_date.isoformat() if self.birth_date else None,
'gender': self.gender or '',
'avatar': bool(self.encoach_avatar),
'entities': [
{'id': e.id, 'name': e.name, 'logo': bool(e.logo)}
for e in self.entity_ids
],
'last_login': self.last_login.isoformat() if self.last_login else None,
'first_login': self.first_login,
'account_source': self.account_source or '',
'account_status': self.account_status or 'unactivated',
}

View File

@@ -0,0 +1,15 @@
from odoo import models, fields
class EncoachEntityLevelMapping(models.Model):
_name = 'encoach.entity.level.mapping'
_description = 'Entity Level Mapping'
entity_id = fields.Many2one('encoach.entity', required=True, ondelete='cascade', index=True)
min_score = fields.Float(required=True)
max_score = fields.Float(required=True)
internal_level_name = fields.Char(size=100, required=True)
cefr_equivalent = fields.Selection([
('pre_a1', 'Pre-A1'), ('a1', 'A1'), ('a2', 'A2'),
('b1', 'B1'), ('b2', 'B2'), ('c1', 'C1'), ('c2', 'C2'),
])

View File

@@ -0,0 +1,16 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_entity_student,encoach.entity.student,model_encoach_entity,group_encoach_student,1,0,0,0
access_encoach_entity_teacher,encoach.entity.teacher,model_encoach_entity,group_encoach_teacher,1,1,0,0
access_encoach_entity_admin,encoach.entity.admin,model_encoach_entity,group_encoach_admin,1,1,1,1
access_encoach_role_student,encoach.role.student,model_encoach_role,group_encoach_student,1,0,0,0
access_encoach_role_teacher,encoach.role.teacher,model_encoach_role,group_encoach_teacher,1,1,0,0
access_encoach_role_admin,encoach.role.admin,model_encoach_role,group_encoach_admin,1,1,1,1
access_encoach_permission_student,encoach.permission.student,model_encoach_permission,group_encoach_student,1,0,0,0
access_encoach_permission_teacher,encoach.permission.teacher,model_encoach_permission,group_encoach_teacher,1,0,0,0
access_encoach_permission_admin,encoach.permission.admin,model_encoach_permission,group_encoach_admin,1,1,1,1
access_encoach_invite_code_student,encoach.invite.code.student,model_encoach_invite_code,group_encoach_student,1,0,0,0
access_encoach_invite_code_teacher,encoach.invite.code.teacher,model_encoach_invite_code,group_encoach_teacher,1,1,0,0
access_encoach_invite_code_admin,encoach.invite.code.admin,model_encoach_invite_code,group_encoach_admin,1,1,1,1
access_encoach_level_mapping_student,encoach.entity.level.mapping.student,model_encoach_entity_level_mapping,group_encoach_student,1,0,0,0
access_encoach_level_mapping_teacher,encoach.entity.level.mapping.teacher,model_encoach_entity_level_mapping,group_encoach_teacher,1,1,0,0
access_encoach_level_mapping_admin,encoach.entity.level.mapping.admin,model_encoach_entity_level_mapping,group_encoach_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_entity_student encoach.entity.student model_encoach_entity group_encoach_student 1 0 0 0
3 access_encoach_entity_teacher encoach.entity.teacher model_encoach_entity group_encoach_teacher 1 1 0 0
4 access_encoach_entity_admin encoach.entity.admin model_encoach_entity group_encoach_admin 1 1 1 1
5 access_encoach_role_student encoach.role.student model_encoach_role group_encoach_student 1 0 0 0
6 access_encoach_role_teacher encoach.role.teacher model_encoach_role group_encoach_teacher 1 1 0 0
7 access_encoach_role_admin encoach.role.admin model_encoach_role group_encoach_admin 1 1 1 1
8 access_encoach_permission_student encoach.permission.student model_encoach_permission group_encoach_student 1 0 0 0
9 access_encoach_permission_teacher encoach.permission.teacher model_encoach_permission group_encoach_teacher 1 0 0 0
10 access_encoach_permission_admin encoach.permission.admin model_encoach_permission group_encoach_admin 1 1 1 1
11 access_encoach_invite_code_student encoach.invite.code.student model_encoach_invite_code group_encoach_student 1 0 0 0
12 access_encoach_invite_code_teacher encoach.invite.code.teacher model_encoach_invite_code group_encoach_teacher 1 1 0 0
13 access_encoach_invite_code_admin encoach.invite.code.admin model_encoach_invite_code group_encoach_admin 1 1 1 1
14 access_encoach_level_mapping_student encoach.entity.level.mapping.student model_encoach_entity_level_mapping group_encoach_student 1 0 0 0
15 access_encoach_level_mapping_teacher encoach.entity.level.mapping.teacher model_encoach_entity_level_mapping group_encoach_teacher 1 1 0 0
16 access_encoach_level_mapping_admin encoach.entity.level.mapping.admin model_encoach_entity_level_mapping group_encoach_admin 1 1 1 1

View File

@@ -0,0 +1,16 @@
.o_encoach_dashboard .card {
transition: transform 0.15s, box-shadow 0.15s;
border-radius: 0.5rem;
}
.o_encoach_dashboard .card.cursor-pointer:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.o_encoach_dashboard h2 {
color: #1f2937;
font-weight: 700;
}
.o_encoach_dashboard .badge {
text-transform: capitalize;
font-size: 0.75rem;
}

View File

@@ -0,0 +1,97 @@
/** @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 EncoachDashboard extends Component {
static template = "encoach_core.Dashboard";
static components = { Layout };
static props = ["*"];
setup() {
this.rpc = useService("rpc");
this.action = useService("action");
this.state = useState({
loading: true,
stats: {
total_students: 0,
total_teachers: 0,
total_courses: 0,
total_exams: 0,
active_sessions: 0,
pending_grading: 0,
pending_approval: 0,
total_entities: 0,
recent_signups: 0,
avg_score: 0,
},
recentAttempts: [],
courseProgress: [],
});
onWillStart(async () => {
await this.loadDashboardData();
});
}
async loadDashboardData() {
try {
const [students, teachers, courses, exams, sessions, pendingGrading, pendingApproval, entities] = await Promise.all([
this.rpc("/web/dataset/call_kw", { model: "res.users", method: "search_count", args: [[["account_source", "!=", false]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "res.users", method: "search_count", args: [[["user_type", "=", "teacher"]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "op.course", method: "search_count", args: [[]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.exam.custom", method: "search_count", args: [[]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.student.attempt", method: "search_count", args: [[["status", "=", "in_progress"]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.student.attempt", method: "search_count", args: [[["status", "=", "scoring"]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.student.attempt", method: "search_count", args: [[["status", "=", "pending_approval"]]], kwargs: {} }),
this.rpc("/web/dataset/call_kw", { model: "encoach.entity", method: "search_count", args: [[]], kwargs: {} }),
]);
this.state.stats = {
total_students: students,
total_teachers: teachers,
total_courses: courses,
total_exams: exams,
active_sessions: sessions,
pending_grading: pendingGrading,
pending_approval: pendingApproval,
total_entities: entities,
};
const attempts = await this.rpc("/web/dataset/call_kw", {
model: "encoach.student.attempt",
method: "search_read",
args: [[], ["student_id", "exam_id", "status", "overall_band", "started_at"]],
kwargs: { limit: 10, order: "started_at desc" },
});
this.state.recentAttempts = attempts;
} catch (e) {
console.error("Dashboard load error:", e);
} finally {
this.state.loading = false;
}
}
openAction(model, name, domain = []) {
this.action.doAction({
type: "ir.actions.act_window",
name: name,
res_model: model,
views: [[false, "list"], [false, "form"]],
domain: domain,
});
}
openStudents() { this.openAction("res.users", "Students", [["account_source", "!=", false]]); }
openTeachers() { this.openAction("res.users", "Teachers", [["user_type", "=", "teacher"]]); }
openCourses() { this.openAction("op.course", "Courses"); }
openExams() { this.openAction("encoach.exam.custom", "Exams"); }
openGradingQueue() { this.openAction("encoach.student.attempt", "Grading Queue", [["status", "=", "scoring"]]); }
openPendingApproval() { this.openAction("encoach.student.attempt", "Score Approval", [["status", "=", "pending_approval"]]); }
openEntities() { this.openAction("encoach.entity", "Entities"); }
}
registry.category("actions").add("encoach_dashboard", EncoachDashboard);

View File

@@ -0,0 +1,162 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_core.Dashboard">
<Layout display="{ controlPanel: {} }">
<div class="o_encoach_dashboard">
<div class="container-fluid py-4">
<h2 class="mb-4">EnCoach Dashboard</h2>
<t t-if="state.loading">
<div class="text-center py-5">
<i class="fa fa-spinner fa-spin fa-3x"/>
<p class="mt-2">Loading dashboard...</p>
</div>
</t>
<t t-else="">
<!-- KPI Cards -->
<div class="row g-3 mb-4">
<div class="col-lg-3 col-md-6">
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openStudents">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-primary bg-opacity-10 p-3 me-3">
<i class="fa fa-users fa-2x text-primary"/>
</div>
<div>
<h3 class="mb-0" t-esc="state.stats.total_students"/>
<small class="text-muted">Students</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openTeachers">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-success bg-opacity-10 p-3 me-3">
<i class="fa fa-chalkboard-teacher fa-2x text-success"/>
</div>
<div>
<h3 class="mb-0" t-esc="state.stats.total_teachers"/>
<small class="text-muted">Teachers</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openCourses">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-info bg-opacity-10 p-3 me-3">
<i class="fa fa-book fa-2x text-info"/>
</div>
<div>
<h3 class="mb-0" t-esc="state.stats.total_courses"/>
<small class="text-muted">Courses</small>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-3 col-md-6">
<div class="card shadow-sm h-100 cursor-pointer" t-on-click="openExams">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="rounded-circle bg-warning bg-opacity-10 p-3 me-3">
<i class="fa fa-file-text fa-2x text-warning"/>
</div>
<div>
<h3 class="mb-0" t-esc="state.stats.total_exams"/>
<small class="text-muted">Exams</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Action Cards -->
<div class="row g-3 mb-4">
<div class="col-lg-4 col-md-6">
<div class="card shadow-sm border-start border-danger border-4 cursor-pointer" t-on-click="openGradingQueue">
<div class="card-body">
<h5>Grading Queue</h5>
<h2 class="text-danger" t-esc="state.stats.pending_grading"/>
<small class="text-muted">Exams awaiting grading</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card shadow-sm border-start border-warning border-4 cursor-pointer" t-on-click="openPendingApproval">
<div class="card-body">
<h5>Pending Approval</h5>
<h2 class="text-warning" t-esc="state.stats.pending_approval"/>
<small class="text-muted">Scores awaiting release</small>
</div>
</div>
</div>
<div class="col-lg-4 col-md-6">
<div class="card shadow-sm border-start border-info border-4 cursor-pointer" t-on-click="openEntities">
<div class="card-body">
<h5>Entities</h5>
<h2 class="text-info" t-esc="state.stats.total_entities"/>
<small class="text-muted">Registered organizations</small>
</div>
</div>
</div>
</div>
<!-- Recent Attempts Table -->
<div class="card shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0">Recent Exam Attempts</h5>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Student</th>
<th>Exam</th>
<th>Status</th>
<th>Band</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<t t-foreach="state.recentAttempts" t-as="attempt" t-key="attempt.id">
<tr>
<td t-esc="attempt.student_id[1]"/>
<td t-esc="attempt.exam_id ? attempt.exam_id[1] : '-'"/>
<td>
<span class="badge"
t-att-class="{
'bg-success': attempt.status === 'released',
'bg-warning': attempt.status === 'pending_approval',
'bg-info': attempt.status === 'scoring',
'bg-secondary': attempt.status === 'in_progress',
'bg-primary': attempt.status === 'scored'
}"
t-esc="attempt.status"/>
</td>
<td t-esc="attempt.overall_band || '-'"/>
<td t-esc="attempt.started_at"/>
</tr>
</t>
<t t-if="!state.recentAttempts.length">
<tr>
<td colspan="5" class="text-center text-muted py-4">No recent attempts</td>
</tr>
</t>
</tbody>
</table>
</div>
</div>
</t>
</div>
</div>
</Layout>
</t>
</templates>

View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_encoach_dashboard" model="ir.actions.client">
<field name="name">EnCoach Dashboard</field>
<field name="tag">encoach_dashboard</field>
</record>
<menuitem id="menu_encoach_dashboard_action"
name="Dashboard"
parent="menu_encoach_dashboard"
action="action_encoach_dashboard"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_encoach_root" name="EnCoach" sequence="10" web_icon="encoach_core,static/description/icon.png"/>
<menuitem id="menu_encoach_dashboard" name="Dashboard" parent="menu_encoach_root" sequence="1"/>
<menuitem id="menu_encoach_exams" name="Exams" parent="menu_encoach_root" sequence="10"/>
<menuitem id="menu_encoach_courses" name="Courses" parent="menu_encoach_root" sequence="20"/>
<menuitem id="menu_encoach_students" name="Students" parent="menu_encoach_root" sequence="30"/>
<menuitem id="menu_encoach_lms" name="LMS" parent="menu_encoach_root" sequence="40"/>
<menuitem id="menu_encoach_content" name="Content" parent="menu_encoach_root" sequence="50"/>
<menuitem id="menu_encoach_entity" name="Entity Management" parent="menu_encoach_root" sequence="60"/>
<menuitem id="menu_encoach_reports" name="Reports" parent="menu_encoach_root" sequence="70"/>
<menuitem id="menu_encoach_config" name="Configuration" parent="menu_encoach_root" sequence="80"/>
<menuitem id="menu_encoach_support" name="Support" parent="menu_encoach_root" sequence="90"/>
</odoo>

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_entity_level_mapping_form" model="ir.ui.view">
<field name="name">encoach.entity.level.mapping.form</field>
<field name="model">encoach.entity.level.mapping</field>
<field name="arch" type="xml">
<form string="Entity Level Mapping">
<sheet>
<group>
<group>
<field name="entity_id"/>
<field name="internal_level_name"/>
<field name="cefr_equivalent"/>
</group>
<group>
<field name="min_score"/>
<field name="max_score"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_entity_level_mapping_list" model="ir.ui.view">
<field name="name">encoach.entity.level.mapping.list</field>
<field name="model">encoach.entity.level.mapping</field>
<field name="arch" type="xml">
<list string="Entity Level Mappings" editable="bottom">
<field name="entity_id"/>
<field name="internal_level_name"/>
<field name="min_score"/>
<field name="max_score"/>
<field name="cefr_equivalent"/>
</list>
</field>
</record>
<record id="action_entity_level_mapping" model="ir.actions.act_window">
<field name="name">Entity Level Mappings</field>
<field name="res_model">encoach.entity.level.mapping</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_entity_level_mapping"
name="Entity Level Mappings"
parent="encoach_core.menu_encoach_entity"
action="encoach_core.action_entity_level_mapping"
sequence="10"/>
</odoo>

View File

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

View File

@@ -0,0 +1,26 @@
{
'name': 'EnCoach Course Generation',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Gap analysis, auto course generation, and adaptive progression',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_taxonomy'],
'data': [
'security/ir.model.access.csv',
'views/gap_profile_views.xml',
'views/course_module_views.xml',
'views/gap_analysis_action.xml',
'views/course_gen_menus.xml',
],
'assets': {
'web.assets_backend': [
'encoach_course_gen/static/src/js/gap_analysis.js',
'encoach_course_gen/static/src/xml/gap_analysis.xml',
'encoach_course_gen/static/src/css/gap_analysis.css',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,630 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
CEFR_ORDER = ['pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2']
STEP_DOWN_THRESHOLD = 0.4
PASS_THRESHOLD_DEFAULT = 0.7
def _cefr_index(level):
try:
return CEFR_ORDER.index(level)
except ValueError:
return 0
def _next_cefr(level):
idx = _cefr_index(level)
return CEFR_ORDER[min(idx + 1, len(CEFR_ORDER) - 1)]
def _module_to_dict(mod):
return {
'id': mod.id,
'name': mod.name,
'cefr_target': mod.cefr_target or '',
'auto_generated': mod.auto_generated,
'completion_criteria': mod.completion_criteria or 'all_resources',
'score_threshold': mod.score_threshold,
'prerequisite_module_id': mod.prerequisite_module_id.id if mod.prerequisite_module_id else None,
'status': mod.status,
'sequence': mod.sequence,
'generation_brief': mod.generation_brief or '',
}
def _course_to_dict(course, include_modules=True):
data = {
'id': course.id,
'name': course.name,
'code': course.code if hasattr(course, 'code') and course.code else '',
'description': course.description if hasattr(course, 'description') else '',
'generation_source': course.generation_source if hasattr(course, 'generation_source') else '',
'progression_model': course.progression_model if hasattr(course, 'progression_model') else 'linear',
'target_band': course.target_band if hasattr(course, 'target_band') else 0.0,
'entity_id': course.entity_id.id if hasattr(course, 'entity_id') and course.entity_id else None,
}
if include_modules:
Module = request.env['encoach.course.module'].sudo()
modules = Module.search([('course_id', '=', course.id)], order='sequence asc')
data['modules'] = [_module_to_dict(m) for m in modules]
return data
class EncoachCourseController(http.Controller):
# ------------------------------------------------------------------
# POST /api/course/create
# ------------------------------------------------------------------
@http.route('/api/course/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create(self, **kw):
try:
body = _get_json_body()
name = (body.get('name') or '').strip()
if not name:
return _error_response('name is required', 400)
Course = request.env['op.course'].sudo()
course_vals = {
'name': name,
'code': body.get('code', name[:20].upper().replace(' ', '_')),
}
if body.get('subject_id'):
course_vals['subject_id'] = int(body['subject_id'])
if body.get('description') and hasattr(Course, 'description'):
course_vals['description'] = body['description']
if hasattr(Course, 'generation_source'):
course_vals['generation_source'] = 'manual'
if hasattr(Course, 'entity_id'):
user = request.env.user.sudo()
if user.entity_ids:
course_vals['entity_id'] = user.entity_ids[0].id
course = Course.create(course_vals)
Module = request.env['encoach.course.module'].sudo()
modules_data = body.get('modules', [])
for idx, mod_data in enumerate(modules_data):
Module.create({
'name': mod_data.get('name', f'Module {idx + 1}'),
'course_id': course.id,
'cefr_target': mod_data.get('cefr_target', ''),
'completion_criteria': mod_data.get('completion_criteria', 'all_resources'),
'score_threshold': mod_data.get('score_threshold', 0.0),
'sequence': mod_data.get('sequence', (idx + 1) * 10),
'status': 'available' if idx == 0 else 'locked',
})
return _json_response(_course_to_dict(course), 201)
except Exception as e:
_logger.exception('course create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/course/gap-analysis
# ------------------------------------------------------------------
@http.route('/api/course/gap-analysis', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def gap_analysis(self, **kw):
try:
uid = request.env.user.id
GapProfile = request.env['encoach.gap.profile'].sudo()
profile = GapProfile.search([
('student_id', '=', uid),
], limit=1, order='created_at desc')
if not profile:
CatSession = request.env['encoach.cat.session'].sudo()
session = CatSession.search([
('student_id', '=', uid),
('status', '=', 'completed'),
], limit=1, order='completed_at desc')
if not session:
return _error_response('No placement results or gap profile found', 404)
Ability = request.env['encoach.student.ability.model'].sudo()
abilities = Ability.search([('student_id', '=', uid)])
skill_gaps = {}
for ab in abilities:
if ab.theta < 0.5:
skill_gaps[ab.skill] = {
'theta': ab.theta,
'sem': ab.sem,
'gap_severity': 'high' if ab.theta < -0.5 else 'medium' if ab.theta < 0.0 else 'low',
}
Attempt = request.env['encoach.student.attempt'].sudo()
placement_attempt = Attempt.search([
('student_id', '=', uid),
('is_placement', '=', True),
], limit=1, order='id desc')
question_type_weaknesses = {}
topic_weaknesses = {}
if placement_attempt:
Answer = request.env['encoach.student.answer'].sudo()
answers = Answer.search([('attempt_id', '=', placement_attempt.id)])
type_stats = {}
topic_stats = {}
for ans in answers:
q = ans.question_id
qt = q.question_type or 'unknown'
type_stats.setdefault(qt, {'correct': 0, 'total': 0})
type_stats[qt]['total'] += 1
if ans.is_correct:
type_stats[qt]['correct'] += 1
if q.topic_id:
tid = q.topic_id.name or str(q.topic_id.id)
topic_stats.setdefault(tid, {'correct': 0, 'total': 0})
topic_stats[tid]['total'] += 1
if ans.is_correct:
topic_stats[tid]['correct'] += 1
for qt, stats in type_stats.items():
pct = stats['correct'] / stats['total'] if stats['total'] else 0
if pct < 0.6:
question_type_weaknesses[qt] = round(pct, 2)
for topic, stats in topic_stats.items():
pct = stats['correct'] / stats['total'] if stats['total'] else 0
if pct < 0.6:
topic_weaknesses[topic] = round(pct, 2)
user = request.env.user.sudo()
entity_id = user.entity_ids[0].id if user.entity_ids else False
profile = GapProfile.create({
'student_id': uid,
'source_type': 'placement',
'source_id': session.id,
'skill_gaps': json.dumps(skill_gaps),
'question_type_weaknesses': json.dumps(question_type_weaknesses),
'topic_weaknesses': json.dumps(topic_weaknesses),
'entity_id': entity_id,
})
return _json_response({
'gap_profile_id': profile.id,
'student_id': profile.student_id.id,
'source_type': profile.source_type,
'skill_gaps': json.loads(profile.skill_gaps) if profile.skill_gaps else {},
'question_type_weaknesses': json.loads(profile.question_type_weaknesses) if profile.question_type_weaknesses else {},
'topic_weaknesses': json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {},
'created_at': profile.created_at,
})
except Exception as e:
_logger.exception('gap_analysis failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/course/auto-generate
# ------------------------------------------------------------------
@http.route('/api/course/auto-generate', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def auto_generate(self, **kw):
try:
body = _get_json_body()
uid = request.env.user.id
GapProfile = request.env['encoach.gap.profile'].sudo()
gap_profile_id = body.get('gap_profile_id')
if gap_profile_id:
profile = GapProfile.browse(int(gap_profile_id))
if not profile.exists():
return _error_response('Gap profile not found', 404)
else:
profile = GapProfile.search([
('student_id', '=', uid),
], limit=1, order='created_at desc')
if not profile:
return _error_response('No gap profile found. Run gap-analysis first.', 404)
skill_gaps = json.loads(profile.skill_gaps) if profile.skill_gaps else {}
topic_weaknesses = json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {}
Ability = request.env['encoach.student.ability.model'].sudo()
abilities = Ability.search([('student_id', '=', uid)])
ability_map = {}
for ab in abilities:
ability_map[ab.skill] = ab.theta
user = request.env.user.sudo()
entity_id = user.entity_ids[0].id if user.entity_ids else False
Course = request.env['op.course'].sudo()
course_vals = {
'name': f"Personalized Course for {user.name}",
'code': f"AUTO_{uid}_{profile.id}",
}
if hasattr(Course, 'generation_source'):
course_vals['generation_source'] = 'auto_gap'
if hasattr(Course, 'gap_profile_id'):
course_vals['gap_profile_id'] = profile.id
if hasattr(Course, 'entity_id'):
course_vals['entity_id'] = entity_id
course = Course.create(course_vals)
Module = request.env['encoach.course.module'].sudo()
seq = 10
created_modules = []
sorted_gaps = sorted(skill_gaps.items(),
key=lambda x: x[1].get('theta', 0) if isinstance(x[1], dict) else 0)
for skill, gap_info in sorted_gaps:
theta = gap_info.get('theta', 0) if isinstance(gap_info, dict) else 0
if theta < -0.5:
cefr = 'a1'
elif theta < 0.0:
cefr = 'a2'
elif theta < 0.5:
cefr = 'b1'
else:
cefr = 'b2'
severity = gap_info.get('gap_severity', 'medium') if isinstance(gap_info, dict) else 'medium'
mod = Module.create({
'name': f"{skill.title()} Improvement ({severity})",
'course_id': course.id,
'cefr_target': cefr,
'auto_generated': True,
'generation_brief': json.dumps({
'skill': skill,
'theta': theta,
'severity': severity,
}),
'completion_criteria': 'score_threshold',
'score_threshold': PASS_THRESHOLD_DEFAULT,
'sequence': seq,
'status': 'available' if seq == 10 else 'locked',
})
created_modules.append(mod)
seq += 10
for topic_name, accuracy in sorted(topic_weaknesses.items(), key=lambda x: x[1]):
mod = Module.create({
'name': f"Topic Review: {topic_name}",
'course_id': course.id,
'cefr_target': 'b1',
'auto_generated': True,
'generation_brief': json.dumps({
'topic': topic_name,
'accuracy': accuracy,
}),
'completion_criteria': 'all_resources',
'sequence': seq,
'status': 'locked',
})
created_modules.append(mod)
seq += 10
if not created_modules:
Module.create({
'name': 'General Practice',
'course_id': course.id,
'cefr_target': 'b1',
'auto_generated': True,
'completion_criteria': 'all_resources',
'sequence': 10,
'status': 'available',
})
for i in range(1, len(created_modules)):
created_modules[i].write({
'prerequisite_module_id': created_modules[i - 1].id,
})
return _json_response(_course_to_dict(course), 201)
except Exception as e:
_logger.exception('auto_generate failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/course/<int:course_id>
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get(self, course_id, **kw):
try:
Course = request.env['op.course'].sudo()
course = Course.browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
data = _course_to_dict(course)
uid = request.env.user.id
Progress = request.env.get('encoach.course.progress')
if Progress is not None:
Progress = Progress.sudo()
for mod in data.get('modules', []):
progress_recs = Progress.search([
('student_id', '=', uid),
('module_id', '=', mod['id']),
])
mod['completed_resources'] = len(
progress_recs.filtered(lambda p: p.completed if hasattr(p, 'completed') else False)
)
return _json_response(data)
except Exception as e:
_logger.exception('course get failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/course/<int:course_id>
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update(self, course_id, **kw):
try:
body = _get_json_body()
Course = request.env['op.course'].sudo()
course = Course.browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
allowed_fields = {'name', 'description', 'code'}
vals = {}
for key in allowed_fields:
if key in body and hasattr(course, key):
vals[key] = body[key]
if vals:
course.write(vals)
module_order = body.get('module_order')
if module_order and isinstance(module_order, list):
Module = request.env['encoach.course.module'].sudo()
for idx, mod_id in enumerate(module_order):
mod = Module.browse(int(mod_id))
if mod.exists() and mod.course_id.id == course.id:
mod.write({'sequence': (idx + 1) * 10})
return _json_response(_course_to_dict(course))
except Exception as e:
_logger.exception('course update failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/course/<int:course_id>/progress
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>/progress', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def progress(self, course_id, **kw):
try:
body = _get_json_body()
module_id = body.get('module_id')
resource_id = body.get('resource_id')
completed = body.get('completed', False)
if not module_id:
return _error_response('module_id is required', 400)
Module = request.env['encoach.course.module'].sudo()
mod = Module.browse(int(module_id))
if not mod.exists() or mod.course_id.id != course_id:
return _error_response('Module not found in this course', 404)
uid = request.env.user.id
if mod.status == 'locked':
return _error_response('Module is locked', 400)
if mod.status in ('locked', 'available'):
mod.write({'status': 'in_progress'})
Progress = request.env.get('encoach.course.progress')
if Progress is not None and resource_id:
Progress = Progress.sudo()
existing = Progress.search([
('student_id', '=', uid),
('module_id', '=', mod.id),
('resource_id', '=', int(resource_id)),
], limit=1)
if existing:
existing.write({'completed': completed})
else:
Progress.create({
'student_id': uid,
'module_id': mod.id,
'resource_id': int(resource_id),
'completed': completed,
})
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
total_modules = len(all_modules)
completed_modules = len(all_modules.filtered(lambda m: m.status == 'completed'))
module_status = mod.status
if mod.completion_criteria == 'all_resources' and Progress is not None:
Progress = request.env['encoach.course.progress'].sudo()
mod_progress = Progress.search([
('student_id', '=', uid),
('module_id', '=', mod.id),
])
all_done = all(
getattr(p, 'completed', False) for p in mod_progress
) if mod_progress else False
if all_done and mod_progress:
mod.write({'status': 'completed'})
module_status = 'completed'
completed_modules += 1
self._unlock_next_module(mod, all_modules)
overall_pct = round((completed_modules / total_modules) * 100, 1) if total_modules else 0.0
return _json_response({
'module_id': mod.id,
'module_status': module_status,
'overall_progress_pct': overall_pct,
})
except Exception as e:
_logger.exception('progress failed')
return _error_response(str(e), 500)
def _unlock_next_module(self, current_mod, all_modules):
"""Unlock the next sequential module when the current one is completed."""
Module = request.env['encoach.course.module'].sudo()
next_mods = Module.search([
('course_id', '=', current_mod.course_id.id),
('prerequisite_module_id', '=', current_mod.id),
('status', '=', 'locked'),
])
for nm in next_mods:
nm.write({'status': 'available'})
if not next_mods:
found_current = False
for m in all_modules:
if found_current and m.status == 'locked':
m.write({'status': 'available'})
break
if m.id == current_mod.id:
found_current = True
# ------------------------------------------------------------------
# POST /api/course/<int:course_id>/checkpoint
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>/checkpoint', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def checkpoint(self, course_id, **kw):
try:
body = _get_json_body()
module_id = body.get('module_id')
score = body.get('score')
if not module_id or score is None:
return _error_response('module_id and score are required', 400)
score = float(score)
Module = request.env['encoach.course.module'].sudo()
mod = Module.browse(int(module_id))
if not mod.exists() or mod.course_id.id != course_id:
return _error_response('Module not found in this course', 404)
threshold = mod.score_threshold or PASS_THRESHOLD_DEFAULT
passed = score >= threshold
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
next_module_id = None
action = 'continue'
if passed:
mod.write({'status': 'completed'})
self._unlock_next_module(mod, all_modules)
next_mods = Module.search([
('course_id', '=', course_id),
('status', 'in', ['available', 'locked']),
('sequence', '>', mod.sequence),
], limit=1, order='sequence asc')
if next_mods:
next_module_id = next_mods[0].id
action = 'next_module'
else:
action = 'course_complete'
elif score < STEP_DOWN_THRESHOLD:
action = 'remediation'
else:
action = 'retry'
return _json_response({
'module_id': mod.id,
'score': score,
'threshold': threshold,
'passed': passed,
'next_module_id': next_module_id,
'action': action,
})
except Exception as e:
_logger.exception('checkpoint failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/course/<int:course_id>/post-test
# ------------------------------------------------------------------
@http.route('/api/course/<int:course_id>/post-test', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def post_test(self, course_id, **kw):
try:
Course = request.env['op.course'].sudo()
course = Course.browse(course_id)
if not course.exists():
return _error_response('Course not found', 404)
Module = request.env['encoach.course.module'].sudo()
all_modules = Module.search([('course_id', '=', course_id)])
incomplete = all_modules.filtered(lambda m: m.status != 'completed')
if incomplete:
return _error_response(
f'{len(incomplete)} module(s) not completed. Finish all modules first.', 400
)
subject_id = course.subject_id.id if hasattr(course, 'subject_id') and course.subject_id else False
ExamCustom = request.env['encoach.exam.custom'].sudo()
exam = None
if subject_id:
exam = ExamCustom.search([
('subject_id', '=', subject_id),
('status', '=', 'published'),
], limit=1, order='id desc')
if not exam:
exam = ExamCustom.search([
('status', '=', 'published'),
], limit=1, order='id desc')
if not exam:
return _error_response('No published exam found for post-test', 404)
Assignment = request.env['encoach.exam.assignment'].sudo()
uid = request.env.user.id
existing = Assignment.search([
('exam_id', '=', exam.id),
('student_id', '=', uid),
('status', '=', 'assigned'),
], limit=1)
if existing:
return _json_response({'exam_assignment_id': existing.id})
assignment = Assignment.create({
'exam_id': exam.id,
'student_id': uid,
'status': 'assigned',
})
return _json_response({'exam_assignment_id': assignment.id}, 201)
except Exception as e:
_logger.exception('post_test failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,3 @@
from . import gap_profile
from . import course_extension
from . import course_module

View File

@@ -0,0 +1,21 @@
from odoo import models, fields
class OpCourseExtension(models.Model):
_inherit = 'op.course'
generation_source = fields.Selection([
('manual', 'Manual'),
('auto_gap', 'Auto from Gap'),
('ai_english', 'AI English'),
('ai_ielts', 'AI IELTS'),
])
gap_profile_id = fields.Many2one('encoach.gap.profile', ondelete='set null')
progression_model = fields.Selection([
('linear', 'Linear'),
('parallel', 'Parallel'),
('adaptive', 'Adaptive'),
], default='linear')
target_band = fields.Float()
study_hours_week = fields.Integer()
entity_id = fields.Many2one('encoach.entity', ondelete='set null')

View File

@@ -0,0 +1,35 @@
from odoo import models, fields
class EncoachCourseModule(models.Model):
_name = 'encoach.course.module'
_description = 'Course Module'
name = fields.Char(size=200, required=True)
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
cefr_target = fields.Selection([
('pre_a1', 'Pre-A1'),
('a1', 'A1'),
('a2', 'A2'),
('b1', 'B1'),
('b2', 'B2'),
('c1', 'C1'),
('c2', 'C2'),
])
auto_generated = fields.Boolean(default=False)
generation_brief = fields.Text()
completion_criteria = fields.Selection([
('all_resources', 'All Resources'),
('score_threshold', 'Score Threshold'),
('teacher_approval', 'Teacher Approval'),
], default='all_resources')
score_threshold = fields.Float()
prerequisite_module_id = fields.Many2one('encoach.course.module', ondelete='set null')
status = fields.Selection([
('locked', 'Locked'),
('available', 'Available'),
('in_progress', 'In Progress'),
('completed', 'Completed'),
('skipped', 'Skipped'),
], default='locked', required=True)
sequence = fields.Integer(default=10)

View File

@@ -0,0 +1,18 @@
from odoo import models, fields
class EncoachGapProfile(models.Model):
_name = 'encoach.gap.profile'
_description = 'Student Gap Profile'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
source_type = fields.Selection([
('placement', 'Placement'),
('exam', 'Exam'),
], required=True)
source_id = fields.Integer()
skill_gaps = fields.Text()
question_type_weaknesses = fields.Text()
topic_weaknesses = fields.Text()
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
created_at = fields.Datetime(default=fields.Datetime.now)

View File

@@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_encoach_gap_profile_user,encoach.gap.profile.user,model_encoach_gap_profile,base.group_user,1,1,1,1
access_encoach_course_module_user,encoach.course.module.user,model_encoach_course_module,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_gap_profile_user encoach.gap.profile.user model_encoach_gap_profile base.group_user 1 1 1 1
3 access_encoach_course_module_user encoach.course.module.user model_encoach_course_module base.group_user 1 1 1 1

View File

@@ -0,0 +1,40 @@
.o_gap_analysis .ga-bar-track {
height: 28px;
background: #e9ecef;
border-radius: 0.5rem;
overflow: hidden;
position: relative;
}
.o_gap_analysis .ga-bar-fill {
height: 100%;
border-radius: 0.5rem;
transition: width 0.6s ease;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
min-width: 0;
}
.o_gap_analysis .ga-bar-label {
color: #fff;
font-size: 0.75rem;
font-weight: 600;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.o_gap_analysis .ga-bar-row {
padding: 4px 0;
}
.o_gap_analysis .ga-student-card {
transition: transform 0.15s, box-shadow 0.15s;
cursor: pointer;
}
.o_gap_analysis .ga-student-card:hover {
transform: translateY(-2px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
}
.o_gap_analysis .card {
border-radius: 0.5rem;
}
.o_gap_analysis .cursor-pointer {
cursor: pointer;
}

View File

@@ -0,0 +1,163 @@
/** @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 GapAnalysisVisualization extends Component {
static template = "encoach_course_gen.GapAnalysis";
static components = { Layout };
static props = ["*"];
setup() {
this.rpc = useService("rpc");
this.action = useService("action");
this.state = useState({
loading: true,
studentId: null,
student: null,
gapProfile: null,
skillGaps: [],
weakTopics: [],
recommendations: [],
});
onWillStart(async () => {
const ctx = this.props.action?.context || {};
this.state.studentId = ctx.student_id || ctx.active_id || null;
if (this.state.studentId) {
await this.loadGapData();
} else {
await this.loadStudentList();
}
});
}
async loadStudentList() {
try {
this.state.studentList = await this.rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_read",
args: [[["account_source", "!=", false]], ["name", "email", "login"]],
kwargs: { limit: 50, order: "name" },
});
} catch (e) {
console.error("Failed to load students:", e);
} finally {
this.state.loading = false;
}
}
async selectStudent(studentId) {
this.state.studentId = studentId;
this.state.loading = true;
await this.loadGapData();
}
async loadGapData() {
this.state.loading = true;
try {
const [profiles, students] = await Promise.all([
this.rpc("/web/dataset/call_kw", {
model: "encoach.gap.profile",
method: "search_read",
args: [[["student_id", "=", this.state.studentId]], ["student_id", "source_type", "skill_gaps", "question_type_weaknesses", "topic_weaknesses", "created_at"]],
kwargs: { limit: 1, order: "created_at desc" },
}),
this.rpc("/web/dataset/call_kw", {
model: "res.users",
method: "search_read",
args: [[["id", "=", this.state.studentId]], ["name", "email"]],
kwargs: {},
}),
]);
this.state.student = students.length ? students[0] : null;
this.state.gapProfile = profiles.length ? profiles[0] : null;
if (this.state.gapProfile) {
this._parseGapProfile(this.state.gapProfile);
}
} catch (e) {
console.error("Gap analysis load error:", e);
} finally {
this.state.loading = false;
}
}
_parseGapProfile(profile) {
this.state.skillGaps = this._safeParseJson(profile.skill_gaps, []);
if (!Array.isArray(this.state.skillGaps)) {
const obj = this.state.skillGaps;
this.state.skillGaps = Object.entries(obj).map(([skill, data]) => ({
skill,
current: typeof data === "object" ? (data.current || 0) : data,
target: typeof data === "object" ? (data.target || 100) : 100,
gap: typeof data === "object" ? (data.gap || (data.target || 100) - (data.current || 0)) : (100 - data),
}));
}
const topicRaw = this._safeParseJson(profile.topic_weaknesses, []);
if (Array.isArray(topicRaw)) {
this.state.weakTopics = topicRaw;
} else if (typeof topicRaw === "object") {
this.state.weakTopics = Object.entries(topicRaw).map(([name, score]) => ({
name,
score: typeof score === "number" ? score : 0,
}));
}
this.state.recommendations = this._generateRecommendations();
}
_safeParseJson(val, fallback) {
if (!val) return fallback;
if (typeof val === "object") return val;
try { return JSON.parse(val); } catch { return fallback; }
}
_generateRecommendations() {
const recs = [];
for (const gap of this.state.skillGaps) {
const pct = gap.target > 0 ? (gap.current / gap.target) * 100 : 0;
if (pct < 50) {
recs.push({ icon: "fa-exclamation-triangle", color: "danger", text: `Critical gap in ${gap.skill} — intensive practice recommended` });
} else if (pct < 75) {
recs.push({ icon: "fa-arrow-up", color: "warning", text: `${gap.skill} needs improvement — targeted exercises suggested` });
}
}
if (this.state.weakTopics.length > 0) {
recs.push({ icon: "fa-book", color: "info", text: `Focus on ${this.state.weakTopics.length} weak topic(s) identified in the analysis` });
}
if (recs.length === 0) {
recs.push({ icon: "fa-thumbs-up", color: "success", text: "Student performance is on track across all areas" });
}
return recs;
}
barWidth(current, target) {
if (!target || target <= 0) return 0;
return Math.min(100, Math.round((current / target) * 100));
}
barColor(current, target) {
const pct = target > 0 ? (current / target) * 100 : 0;
if (pct >= 75) return "bg-success";
if (pct >= 50) return "bg-warning";
return "bg-danger";
}
goBack() {
this.state.studentId = null;
this.state.student = null;
this.state.gapProfile = null;
this.state.skillGaps = [];
this.state.weakTopics = [];
this.state.recommendations = [];
this.state.loading = true;
this.loadStudentList();
}
}
registry.category("actions").add("encoach_gap_analysis", GapAnalysisVisualization);

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="encoach_course_gen.GapAnalysis">
<Layout display="{ controlPanel: {} }">
<div class="o_gap_analysis">
<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">Analyzing gaps...</p>
</div>
</t>
<!-- Student Selector -->
<t t-elif="!state.studentId and state.studentList">
<h2 class="mb-3">Gap Analysis</h2>
<p class="text-muted mb-4">Select a student to view their skill gap analysis.</p>
<div class="row g-3">
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
<div class="col-lg-3 col-md-4 col-sm-6">
<div class="card shadow-sm h-100 cursor-pointer ga-student-card"
t-on-click="() => this.selectStudent(st.id)">
<div class="card-body text-center">
<div class="rounded-circle bg-primary bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
<i class="fa fa-user text-primary"/>
</div>
<h6 class="card-title mb-0" t-esc="st.name"/>
<small class="text-muted" t-esc="st.email"/>
</div>
</div>
</div>
</t>
</div>
</t>
<!-- Gap Analysis View -->
<t t-elif="state.student">
<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?.student_id">
<i class="fa fa-arrow-left me-1"/> Back
</button>
<div>
<h2 class="mb-0">Gap Analysis</h2>
<small class="text-muted">
Student: <strong t-esc="state.student.name"/>
<t t-if="state.gapProfile">
— Source: <span class="badge bg-secondary" t-esc="state.gapProfile.source_type"/>
</t>
</small>
</div>
</div>
<t t-if="!state.gapProfile">
<div class="alert alert-info">
<i class="fa fa-info-circle me-2"/>
No gap profile found for this student. A placement test or exam is required first.
</div>
</t>
<t t-else="">
<!-- Skill Gap Bars -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fa fa-bar-chart me-2 text-primary"/>
Skill Gaps
</h5>
</div>
<div class="card-body">
<t t-if="state.skillGaps.length">
<t t-foreach="state.skillGaps" t-as="gap" t-key="gap.skill">
<div class="ga-bar-row mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="fw-bold text-capitalize" t-esc="gap.skill"/>
<span class="small text-muted">
<t t-esc="gap.current"/> / <t t-esc="gap.target"/>
</span>
</div>
<div class="ga-bar-track">
<div class="ga-bar-fill"
t-att-class="barColor(gap.current, gap.target)"
t-att-style="'width:' + barWidth(gap.current, gap.target) + '%'">
<span class="ga-bar-label" t-if="barWidth(gap.current, gap.target) > 15">
<t t-esc="barWidth(gap.current, gap.target)"/>%
</span>
</div>
</div>
</div>
</t>
</t>
<t t-else="">
<p class="text-muted mb-0">No skill gap data available.</p>
</t>
</div>
</div>
<div class="row g-4">
<!-- Weak Topics -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fa fa-warning me-2 text-warning"/>
Weak Topics
</h5>
</div>
<div class="card-body">
<t t-if="state.weakTopics.length">
<t t-foreach="state.weakTopics" t-as="topic" t-key="topic_index">
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
<span>
<i class="fa fa-circle text-danger me-2" style="font-size:0.5rem; vertical-align:middle"/>
<t t-esc="topic.name || topic"/>
</span>
<span class="badge bg-danger bg-opacity-75" t-if="topic.score !== undefined">
<t t-esc="topic.score"/>%
</span>
</div>
</t>
</t>
<t t-else="">
<p class="text-muted text-center mb-0 py-3">
<i class="fa fa-check-circle text-success me-1"/>
No weak topics identified
</p>
</t>
</div>
</div>
</div>
<!-- Recommendations -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-white">
<h5 class="mb-0">
<i class="fa fa-lightbulb-o me-2 text-info"/>
Recommended Actions
</h5>
</div>
<div class="card-body">
<t t-foreach="state.recommendations" t-as="rec" t-key="rec_index">
<div class="d-flex align-items-start py-2 border-bottom">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3 flex-shrink-0"
t-att-class="'bg-' + rec.color + ' bg-opacity-10'"
style="width:36px; height:36px">
<i class="fa" t-att-class="rec.icon + ' text-' + rec.color"/>
</div>
<span class="small" t-esc="rec.text"/>
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</t>
<t t-elif="!state.studentId">
<div class="text-center py-5 text-muted">
<i class="fa fa-line-chart fa-3x mb-3 d-block"/>
<p>No student selected. Open from a student record or select one above.</p>
</div>
</t>
</div>
</div>
</Layout>
</t>
</templates>

View File

@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_all_courses" model="ir.actions.act_window">
<field name="name">All Courses</field>
<field name="res_model">op.course</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_all_courses"
name="All Courses"
parent="encoach_core.menu_encoach_courses"
action="encoach_course_gen.action_all_courses"
sequence="5"/>
<menuitem id="menu_course_modules"
name="Course Modules"
parent="encoach_core.menu_encoach_courses"
action="encoach_course_gen.action_course_module"
sequence="10"/>
<menuitem id="menu_gap_analysis"
name="Gap Analysis"
parent="encoach_core.menu_encoach_courses"
action="encoach_course_gen.action_gap_profile"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,76 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_course_module_form" model="ir.ui.view">
<field name="name">encoach.course.module.form</field>
<field name="model">encoach.course.module</field>
<field name="arch" type="xml">
<form string="Course Module">
<header>
<field name="status" widget="statusbar" statusbar_visible="locked,available,in_progress,completed"/>
</header>
<sheet>
<group>
<group>
<field name="name"/>
<field name="course_id"/>
<field name="cefr_target"/>
<field name="auto_generated"/>
</group>
<group>
<field name="completion_criteria"/>
<field name="score_threshold" invisible="completion_criteria != 'score_threshold'"/>
<field name="prerequisite_module_id"/>
<field name="sequence"/>
</group>
</group>
<group>
<field name="generation_brief"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="view_course_module_list" model="ir.ui.view">
<field name="name">encoach.course.module.list</field>
<field name="model">encoach.course.module</field>
<field name="arch" type="xml">
<list string="Course Modules">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="course_id"/>
<field name="cefr_target"/>
<field name="status" widget="badge" decoration-info="status == 'available'" decoration-success="status == 'completed'" decoration-warning="status == 'in_progress'"/>
</list>
</field>
</record>
<record id="view_course_module_search" model="ir.ui.view">
<field name="name">encoach.course.module.search</field>
<field name="model">encoach.course.module</field>
<field name="arch" type="xml">
<search string="Search Course Modules">
<field name="name"/>
<field name="course_id"/>
<separator/>
<filter string="Locked" name="locked" domain="[('status', '=', 'locked')]"/>
<filter string="Available" name="available" domain="[('status', '=', 'available')]"/>
<filter string="In Progress" name="in_progress" domain="[('status', '=', 'in_progress')]"/>
<filter string="Completed" name="completed" domain="[('status', '=', 'completed')]"/>
<filter string="Skipped" name="skipped" domain="[('status', '=', 'skipped')]"/>
<separator/>
<group expand="0" string="Group By">
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_course_module" model="ir.actions.act_window">
<field name="name">Course Modules</field>
<field name="res_model">encoach.course.module</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_gap_analysis_viz" model="ir.actions.client">
<field name="name">Gap Analysis</field>
<field name="tag">encoach_gap_analysis</field>
</record>
<menuitem id="menu_gap_analysis_viz"
name="Gap Analysis (Visual)"
parent="encoach_core.menu_encoach_courses"
action="action_gap_analysis_viz"
sequence="25"/>
</odoo>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_gap_profile_form" model="ir.ui.view">
<field name="name">encoach.gap.profile.form</field>
<field name="model">encoach.gap.profile</field>
<field name="arch" type="xml">
<form string="Gap Profile">
<sheet>
<group>
<group>
<field name="student_id"/>
<field name="source_type"/>
<field name="source_id"/>
</group>
<group>
<field name="entity_id"/>
<field name="created_at"/>
</group>
</group>
<notebook>
<page string="Skill Gaps" name="skill_gaps">
<field name="skill_gaps"/>
</page>
<page string="Question Type Weaknesses" name="question_type_weaknesses">
<field name="question_type_weaknesses"/>
</page>
<page string="Topic Weaknesses" name="topic_weaknesses">
<field name="topic_weaknesses"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="view_gap_profile_list" model="ir.ui.view">
<field name="name">encoach.gap.profile.list</field>
<field name="model">encoach.gap.profile</field>
<field name="arch" type="xml">
<list string="Gap Profiles">
<field name="student_id"/>
<field name="source_type"/>
<field name="entity_id"/>
<field name="created_at"/>
</list>
</field>
</record>
<record id="view_gap_profile_search" model="ir.ui.view">
<field name="name">encoach.gap.profile.search</field>
<field name="model">encoach.gap.profile</field>
<field name="arch" type="xml">
<search string="Search Gap Profiles">
<field name="student_id"/>
<separator/>
<filter string="Placement" name="placement" domain="[('source_type', '=', 'placement')]"/>
<filter string="Exam" name="exam" domain="[('source_type', '=', 'exam')]"/>
<separator/>
<group expand="0" string="Group By">
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_gap_profile" model="ir.actions.act_window">
<field name="name">Gap Analysis</field>
<field name="res_model">encoach.gap.profile</field>
<field name="view_mode">list,form</field>
</record>
</odoo>

View File

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

View File

@@ -0,0 +1,15 @@
{
'name': 'EnCoach Entity Onboarding',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Entity student CSV bulk upload, account creation, credential management',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core'],
'data': [
'security/ir.model.access.csv',
],
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,292 @@
import json
import logging
import re
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
class EncoachEntityOnboardController(http.Controller):
# ------------------------------------------------------------------
# POST /api/entity/students/validate-csv
# ------------------------------------------------------------------
@http.route('/api/entity/students/validate-csv', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def validate_csv(self, **kw):
try:
uploaded = request.httprequest.files.get('file')
if not uploaded:
return _error_response('CSV file is required (multipart field "file")', 400)
raw = uploaded.read()
from odoo.addons.encoach_entity_onboard.services.csv_parser import CsvParser
parser = CsvParser()
result = parser.validate(raw)
valid_rows = result['valid_rows']
parse_errors = result['errors']
row_errors = []
for err in parse_errors:
row_errors.append({'row': 0, 'field': '', 'message': err})
User = request.env['res.users'].sudo()
dedup_errors = []
for idx, row in enumerate(valid_rows):
email = row.get('email', '')
if email and not EMAIL_RE.match(email):
row_errors.append({
'row': idx + 2,
'field': 'email',
'message': f"Invalid email format: {email}",
})
continue
if email:
existing = User.search([('login', '=', email)], limit=1)
if existing:
dedup_errors.append({
'row': idx + 2,
'field': 'email',
'message': f"Duplicate email already exists in DB: {email}",
})
all_errors = row_errors + dedup_errors
error_count = len(all_errors)
truly_valid = [
r for i, r in enumerate(valid_rows)
if not any(e['row'] == i + 2 for e in all_errors)
]
preview = truly_valid[:5]
return _json_response({
'valid_count': len(truly_valid),
'error_count': error_count,
'errors': all_errors,
'preview': preview,
})
except Exception as e:
_logger.exception('validate_csv failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/bulk-create
# ------------------------------------------------------------------
@http.route('/api/entity/students/bulk-create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def bulk_create(self, **kw):
try:
body = _get_json_body()
rows = body.get('rows', [])
entity_id = body.get('entity_id')
if not rows:
return _error_response('rows array is required', 400)
if not entity_id:
return _error_response('entity_id is required', 400)
entity_id = int(entity_id)
Entity = request.env['encoach.entity'].sudo()
entity = Entity.browse(entity_id)
if not entity.exists():
return _error_response('Entity not found', 404)
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
service = CredentialService()
created_users = service.create_accounts(request.env, rows, entity_id)
return _json_response({
'created_count': len(created_users),
'user_ids': created_users.ids,
}, 201)
except Exception as e:
_logger.exception('bulk_create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/send-credentials
# ------------------------------------------------------------------
@http.route('/api/entity/students/send-credentials', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def send_credentials(self, **kw):
try:
body = _get_json_body()
user_ids = body.get('user_ids', [])
if not user_ids:
return _error_response('user_ids array is required', 400)
user_ids = [int(uid) for uid in user_ids]
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
service = CredentialService()
service.send_credentials(request.env, user_ids)
return _json_response({'sent_count': len(user_ids)})
except Exception as e:
_logger.exception('send_credentials failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/<int:student_id>/resend-credentials
# ------------------------------------------------------------------
@http.route('/api/entity/students/<int:student_id>/resend-credentials',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def resend_credentials(self, student_id, **kw):
try:
User = request.env['res.users'].sudo()
user = User.browse(student_id)
if not user.exists():
return _error_response('Student not found', 404)
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
service = CredentialService()
service.resend_credentials(request.env, student_id)
return _json_response({'sent': True})
except Exception as e:
_logger.exception('resend_credentials failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/resend-all-pending
# ------------------------------------------------------------------
@http.route('/api/entity/students/resend-all-pending', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def resend_all_pending(self, **kw):
try:
user = request.env.user.sudo()
domain = [
('first_login', '=', True),
('account_source', '=', 'entity_bulk_upload'),
]
if user.entity_ids:
domain.append(('entity_ids', 'in', user.entity_ids.ids))
User = request.env['res.users'].sudo()
pending_users = User.search(domain)
if not pending_users:
return _json_response({'sent_count': 0})
from odoo.addons.encoach_entity_onboard.services.credential_service import CredentialService
service = CredentialService()
service.send_credentials(request.env, pending_users.ids)
return _json_response({'sent_count': len(pending_users)})
except Exception as e:
_logger.exception('resend_all_pending failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/entity/students/sis-import
# ------------------------------------------------------------------
@http.route('/api/entity/students/sis-import', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def sis_import(self, **kw):
try:
sis_data = None
uploaded = request.httprequest.files.get('file')
if uploaded:
raw = uploaded.read()
try:
sis_data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return _error_response('Invalid JSON file', 400)
else:
body = _get_json_body()
sis_data = body.get('sis_data', [])
if not sis_data or not isinstance(sis_data, list):
return _error_response('sis_data must be a non-empty JSON array', 400)
user = request.env.user.sudo()
entity_id = None
if user.entity_ids:
entity_id = user.entity_ids[0].id
User = request.env['res.users'].sudo()
imported_count = 0
updated_count = 0
errors = []
for idx, record in enumerate(sis_data):
email = (record.get('email') or '').strip()
name = (record.get('name') or '').strip()
national_id = (record.get('national_id') or '').strip()
phone = (record.get('phone') or '').strip()
if not email or not name:
errors.append({
'index': idx,
'message': 'name and email are required',
})
continue
if not EMAIL_RE.match(email):
errors.append({
'index': idx,
'message': f'Invalid email: {email}',
})
continue
try:
existing = User.search([('login', '=', email)], limit=1)
if existing:
update_vals = {'name': name}
if phone:
update_vals['phone'] = phone
existing.write(update_vals)
if entity_id and hasattr(existing, 'entity_ids'):
if entity_id not in existing.entity_ids.ids:
existing.write({'entity_ids': [(4, entity_id)]})
updated_count += 1
else:
create_vals = {
'name': name,
'login': email,
'email': email,
'phone': phone,
'password': national_id or email.split('@')[0],
'account_source': 'entity_bulk_upload',
'account_status': 'activated',
}
if entity_id:
create_vals['entity_id'] = entity_id
User.create(create_vals)
imported_count += 1
except Exception as rec_err:
errors.append({
'index': idx,
'message': str(rec_err),
})
_logger.warning('SIS import error for row %d: %s', idx, rec_err)
return _json_response({
'imported_count': imported_count,
'updated_count': updated_count,
'errors': errors,
})
except Exception as e:
_logger.exception('sis_import failed')
return _error_response(str(e), 500)

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,2 @@
from . import csv_parser
from . import credential_service

View File

@@ -0,0 +1,72 @@
import logging
_logger = logging.getLogger(__name__)
class CredentialService:
def create_accounts(self, env, valid_rows, entity_id):
"""Create res.users records from validated CSV rows.
Each user gets password=national_id, account_source='entity_bulk_upload',
and account_status='activated'.
Args:
env: Odoo environment.
valid_rows: list of dicts with keys name, email, national_id, phone.
entity_id: int, the entity that owns these students.
Returns:
recordset of created res.users.
"""
User = env['res.users']
created_users = User
for row in valid_rows:
try:
user = User.sudo().create({
'name': row['name'],
'login': row['email'],
'email': row['email'],
'phone': row.get('phone', ''),
'password': row['national_id'],
'account_source': 'entity_bulk_upload',
'account_status': 'activated',
'entity_id': entity_id,
})
created_users |= user
except Exception:
_logger.exception("Failed to create account for %s", row.get('email'))
return created_users
def send_credentials(self, env, user_ids):
"""Send credential emails to newly created users.
Args:
env: Odoo environment.
user_ids: list of int, res.users IDs.
"""
Mail = env['mail.mail']
for user in env['res.users'].sudo().browse(user_ids):
mail_values = {
'subject': 'Your EnCoach Account Credentials',
'email_from': env.company.email or 'noreply@encoach.com',
'email_to': user.email,
'body_html': (
f'<p>Dear {user.name},</p>'
f'<p>Your EnCoach account has been created.</p>'
f'<p>Login: {user.login}</p>'
f'<p>Password: Your National ID</p>'
f'<p>Please change your password after first login.</p>'
),
}
mail = Mail.sudo().create(mail_values)
mail.send()
def resend_credentials(self, env, user_id):
"""Resend credential email for a single user.
Args:
env: Odoo environment.
user_id: int, res.users ID.
"""
self.send_credentials(env, [user_id])

View File

@@ -0,0 +1,69 @@
import csv
import io
REQUIRED_COLUMNS = {'name', 'email', 'national_id', 'phone'}
class CsvParser:
def validate(self, file_content):
"""Parse CSV content and validate required columns.
Args:
file_content: raw CSV string or bytes.
Returns:
dict with 'valid_rows' (list of dicts) and 'errors' (list of str).
"""
if isinstance(file_content, bytes):
file_content = file_content.decode('utf-8-sig')
errors = []
valid_rows = []
reader = csv.DictReader(io.StringIO(file_content))
if not reader.fieldnames:
return {'valid_rows': [], 'errors': ['Empty CSV file or missing header row']}
header_set = {col.strip().lower() for col in reader.fieldnames}
missing = REQUIRED_COLUMNS - header_set
if missing:
return {
'valid_rows': [],
'errors': [f"Missing required columns: {', '.join(sorted(missing))}"],
}
for idx, row in enumerate(reader, start=2):
parsed = self.parse_row(row)
row_errors = []
if not parsed.get('name'):
row_errors.append(f"Row {idx}: 'name' is required")
if not parsed.get('email'):
row_errors.append(f"Row {idx}: 'email' is required")
if not parsed.get('national_id'):
row_errors.append(f"Row {idx}: 'national_id' is required")
if row_errors:
errors.extend(row_errors)
else:
valid_rows.append(parsed)
return {'valid_rows': valid_rows, 'errors': errors}
def parse_row(self, row):
"""Normalize a single CSV row.
Args:
row: dict from csv.DictReader.
Returns:
dict with trimmed, normalized values.
"""
normalized = {}
for key, value in row.items():
clean_key = key.strip().lower()
normalized[clean_key] = value.strip() if value else ''
return normalized

View File

@@ -0,0 +1,120 @@
import json
from odoo import models, fields, api
MODULE_SELECTION = [
('reading', 'Reading'),
('writing', 'Writing'),
('listening', 'Listening'),
('speaking', 'Speaking'),
('math', 'Math'),
('it', 'IT'),
('level', 'Level'),
('general', 'General'),
]
class EncoachExam(models.Model):
_name = 'encoach.exam'
_description = 'EnCoach Exam'
name = fields.Char(required=True)
module = fields.Selection(MODULE_SELECTION, string='Module')
subject_id = fields.Many2one('encoach.subject', string='Subject')
topic_ids = fields.Many2many(
'encoach.topic', 'encoach_exam_topic_rel', 'exam_id', 'topic_id',
string='Topics',
)
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
creator_id = fields.Many2one(
'res.users', default=lambda self: self.env.user, ondelete='set null',
)
structure_id = fields.Many2one('encoach.exam.structure', ondelete='set null')
rubric_id = fields.Many2one('encoach.exam.rubric', ondelete='set null')
exercises = fields.Text(string='Exercises (JSON)')
passages = fields.Text(string='Passages (JSON)')
is_public = fields.Boolean(default=False)
status = fields.Selection(
[('draft', 'Draft'), ('published', 'Published'), ('archived', 'Archived')],
default='draft',
)
difficulty = fields.Selection(
[('easy', 'Easy'), ('medium', 'Medium'), ('hard', 'Hard'), ('mixed', 'Mixed')],
)
time_limit = fields.Integer(default=0, help='Minutes. 0 = no limit.')
total_questions = fields.Integer(compute='_compute_total_questions', store=True)
tags = fields.Char()
approval_status = fields.Selection(
[('none', 'None'), ('pending', 'Pending'),
('approved', 'Approved'), ('rejected', 'Rejected')],
default='none',
)
results_release_mode = fields.Selection([
('auto', 'Auto'),
('manual_approval', 'Manual Approval'),
], default='auto')
template_type = fields.Selection([
('ielts_academic', 'IELTS Academic'),
('ielts_general_training', 'IELTS General Training'),
('general_english', 'General English'),
('toefl', 'TOEFL'),
('step', 'STEP'),
('ic3', 'IC3'),
('custom', 'Custom'),
])
assembly_mode = fields.Selection([
('auto', 'Auto'),
('manual', 'Manual'),
('hybrid', 'Hybrid'),
])
target_band = fields.Float()
randomize = fields.Boolean(default=False)
@api.depends('exercises')
def _compute_total_questions(self):
for rec in self:
count = 0
if rec.exercises:
try:
data = json.loads(rec.exercises)
count = len(data) if isinstance(data, list) else 0
except (json.JSONDecodeError, TypeError):
pass
rec.total_questions = count
def action_publish(self):
for rec in self:
rec.status = 'published'
def action_archive(self):
for rec in self:
rec.status = 'archived'
def to_api_dict(self):
self.ensure_one()
return {
'id': self.id,
'name': self.name,
'module': self.module or '',
'subject_id': self.subject_id.id if self.subject_id else None,
'topic_ids': self.topic_ids.ids,
'entity_id': self.entity_id.id if self.entity_id else None,
'creator_id': self.creator_id.id if self.creator_id else None,
'structure_id': self.structure_id.id if self.structure_id else None,
'rubric_id': self.rubric_id.id if self.rubric_id else None,
'exercises': json.loads(self.exercises) if self.exercises else [],
'passages': json.loads(self.passages) if self.passages else [],
'is_public': self.is_public,
'status': self.status,
'difficulty': self.difficulty or '',
'time_limit': self.time_limit,
'total_questions': self.total_questions,
'tags': self.tags or '',
'approval_status': self.approval_status,
'results_release_mode': self.results_release_mode or 'auto',
'template_type': self.template_type or '',
'assembly_mode': self.assembly_mode or '',
'target_band': self.target_band,
'randomize': self.randomize,
}

View File

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

View File

@@ -0,0 +1,34 @@
{
'name': 'EnCoach Exam Session',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'POS-inspired full-screen exam delivery with timer, auto-save, and question renderers',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': [
'base',
'web',
'encoach_core',
'encoach_scoring',
'encoach_exam_template',
'encoach_placement',
],
'data': [
'security/ir.model.access.csv',
'views/exam_session_templates.xml',
],
'assets': {
'web.assets_frontend': [
'encoach_exam_session/static/src/css/exam_session.css',
'encoach_exam_session/static/src/js/question_renderers.js',
'encoach_exam_session/static/src/js/exam_app.js',
'encoach_exam_session/static/src/js/placement_app.js',
'encoach_exam_session/static/src/xml/question_renderers.xml',
'encoach_exam_session/static/src/xml/exam_app.xml',
'encoach_exam_session/static/src/xml/placement_app.xml',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

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

View File

@@ -0,0 +1,30 @@
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
class ExamSessionController(http.Controller):
@http.route('/exam/session/<int:attempt_id>', type='http',
auth='user', website=False)
def exam_session(self, attempt_id, **kw):
attempt = request.env['encoach.student.attempt'].sudo().browse(attempt_id)
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
return request.not_found()
return request.render('encoach_exam_session.exam_session_page', {
'attempt': attempt,
'session_token': request.session.sid,
})
@http.route('/placement/test/<int:session_id>', type='http',
auth='user', website=False)
def placement_test(self, session_id, **kw):
session = request.env['encoach.cat.session'].sudo().browse(session_id)
if not session.exists() or session.student_id.id != request.env.user.id:
return request.not_found()
return request.render('encoach_exam_session.placement_session_page', {
'cat_session': session,
})

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

File diff suppressed because it is too large Load Diff

View 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();
}

View File

@@ -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();
}

View File

@@ -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 || "";
}
}

View 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>

View File

@@ -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>

View File

@@ -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 &lt; minWords ? ' ec-word-count--low' : '')
+ (state.wordCount >= minWords and state.wordCount &lt;= 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>

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Full-screen exam delivery page (no header/footer) -->
<template id="exam_session_page" name="Exam Session">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<div id="exam_session_app"
t-att-data-attempt-id="attempt.id"
t-att-data-exam-id="attempt.exam_id.id"
t-att-data-session-token="session_token"
class="ec-exam-fullscreen"/>
</t>
</template>
<!-- Full-screen placement CAT test page (no header/footer) -->
<template id="placement_session_page" name="Placement Session">
<t t-call="web.frontend_layout">
<t t-set="no_header" t-value="True"/>
<t t-set="no_footer" t-value="True"/>
<div id="placement_session_app"
t-att-data-session-id="cat_session.id"
t-att-data-student-id="cat_session.student_id.id"
class="ec-exam-fullscreen"/>
</t>
</template>
</odoo>

View File

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

View File

@@ -0,0 +1,39 @@
{
'name': 'EnCoach Exam Template',
'version': '19.0.1.0',
'category': 'Education',
'summary': 'Exam templates, content pool (passages, audio, questions, prompts, cards, rubrics)',
'author': 'EnCoach',
'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_taxonomy'],
'data': [
'security/ir.model.access.csv',
'data/ielts_templates.xml',
'data/sample_passages.xml',
'data/sample_questions.xml',
'views/exam_template_views.xml',
'views/passage_views.xml',
'views/audio_file_views.xml',
'views/question_views.xml',
'views/writing_prompt_views.xml',
'views/speaking_card_views.xml',
'views/rubric_views.xml',
'views/exam_custom_views.xml',
'views/exam_assignment_views.xml',
'views/content_pool_action.xml',
'views/exam_validation_action.xml',
'views/exam_template_menus.xml',
],
'assets': {
'web.assets_backend': [
'encoach_exam_template/static/src/js/content_pool.js',
'encoach_exam_template/static/src/xml/content_pool.xml',
'encoach_exam_template/static/src/css/content_pool.css',
'encoach_exam_template/static/src/js/exam_validation.js',
'encoach_exam_template/static/src/xml/exam_validation.xml',
],
},
'installable': True,
'application': False,
'auto_install': False,
}

View File

@@ -0,0 +1,3 @@
from . import templates
from . import ielts_exam
from . import custom_exam

View File

@@ -0,0 +1,364 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
def _section_to_dict(sec):
return {
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'question_count': sec.question_count or 0,
'time_limit_min': sec.time_limit_min or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
'question_ids': sec.question_ids.ids,
}
def _exam_to_dict(exam):
return {
'id': exam.id,
'title': exam.title,
'template_id': exam.template_id.id if exam.template_id else None,
'subject_id': exam.subject_id.id if exam.subject_id else None,
'entity_id': exam.entity_id.id if exam.entity_id else None,
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
'description': exam.description or '',
'total_time_min': exam.total_time_min or 0,
'pass_threshold': exam.pass_threshold or 0.0,
'results_release_mode': exam.results_release_mode or 'auto',
'randomize_questions': exam.randomize_questions,
'status': exam.status,
'sections': [_section_to_dict(s) for s in exam.section_ids],
}
class EncoachCustomExamController(http.Controller):
# ------------------------------------------------------------------
# POST /api/exam/custom/create
# ------------------------------------------------------------------
@http.route('/api/exam/custom/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create(self, **kw):
try:
body = _get_json_body()
title = (body.get('title') or '').strip()
if not title:
return _error_response('title is required', 400)
vals = {
'title': title,
'teacher_id': request.env.user.id,
'subject_id': body.get('subject_id') or False,
'template_id': body.get('template_id') or False,
'entity_id': body.get('entity_id') or False,
'description': body.get('description', ''),
'total_time_min': body.get('total_time_min', 0),
'pass_threshold': body.get('pass_threshold', 0.0),
'results_release_mode': body.get('results_release_mode', 'auto'),
'randomize_questions': body.get('randomize_questions', False),
'status': 'draft',
}
exam = request.env['encoach.exam.custom'].sudo().create(vals)
sections = body.get('sections', [])
Section = request.env['encoach.exam.custom.section'].sudo()
for idx, sec_data in enumerate(sections):
sec_vals = {
'exam_id': exam.id,
'title': sec_data.get('title', f'Section {idx + 1}'),
'skill': sec_data.get('skill', ''),
'question_count': sec_data.get('question_count', 0),
'time_limit_min': sec_data.get('time_limit_min', 0),
'scoring_method': sec_data.get('scoring_method', 'auto'),
'sequence': sec_data.get('sequence', (idx + 1) * 10),
}
section = Section.create(sec_vals)
q_ids = sec_data.get('question_ids', [])
if q_ids:
section.write({'question_ids': [(6, 0, q_ids)]})
exam.invalidate_recordset()
return _json_response(_exam_to_dict(exam), 201)
except Exception as e:
_logger.exception('custom exam create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/custom/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('custom exam get failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/exam/custom/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be updated', 400)
body = _get_json_body()
allowed = {'title', 'description', 'total_time_min', 'pass_threshold',
'results_release_mode', 'randomize_questions', 'subject_id'}
vals = {k: v for k, v in body.items() if k in allowed}
if vals:
exam.write(vals)
sections_data = body.get('sections')
if sections_data and isinstance(sections_data, list):
Section = request.env['encoach.exam.custom.section'].sudo()
existing_ids = set(exam.section_ids.ids)
incoming_ids = set()
for idx, sec_data in enumerate(sections_data):
sec_id = sec_data.get('id')
sec_vals = {
'title': sec_data.get('title', f'Section {idx + 1}'),
'skill': sec_data.get('skill', ''),
'question_count': sec_data.get('question_count', 0),
'time_limit_min': sec_data.get('time_limit_min', 0),
'scoring_method': sec_data.get('scoring_method', 'auto'),
'sequence': sec_data.get('sequence', (idx + 1) * 10),
}
if sec_id and sec_id in existing_ids:
section = Section.browse(sec_id)
section.write(sec_vals)
incoming_ids.add(sec_id)
else:
sec_vals['exam_id'] = exam.id
section = Section.create(sec_vals)
incoming_ids.add(section.id)
q_ids = sec_data.get('question_ids')
if q_ids is not None:
section.write({'question_ids': [(6, 0, q_ids)]})
to_delete = existing_ids - incoming_ids
if to_delete:
Section.browse(list(to_delete)).unlink()
exam.invalidate_recordset()
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('custom exam update failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# DELETE /api/exam/custom/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@jwt_required
def delete(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be deleted', 400)
exam.section_ids.unlink()
exam.unlink()
return _json_response({'deleted': True}, 204)
except Exception as e:
_logger.exception('custom exam delete failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/custom/<int:exam_id>/save-template
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>/save-template',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def save_template(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
structure = []
for sec in exam.section_ids.sorted('sequence'):
structure.append({
'title': sec.title,
'skill': sec.skill or '',
'question_count': sec.question_count or 0,
'time_limit_min': sec.time_limit_min or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
})
body = _get_json_body()
template_name = body.get('name', f'Template from: {exam.title}')
template = request.env['encoach.exam.template'].sudo().create({
'name': template_name,
'type': 'custom',
'teacher_id': request.env.user.id,
'subject_id': exam.subject_id.id if exam.subject_id else False,
'entity_id': exam.entity_id.id if exam.entity_id else False,
'structure': json.dumps(structure),
'total_time_min': exam.total_time_min or 0,
'pass_threshold': exam.pass_threshold or 0.0,
'results_release_mode': exam.results_release_mode or 'auto',
'randomize_questions': exam.randomize_questions,
})
return _json_response({
'id': template.id,
'name': template.name,
'type': template.type,
'structure': structure,
}, 201)
except Exception as e:
_logger.exception('save_template failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/custom/<int:exam_id>/validate
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>/validate',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def validate(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
errors = []
warnings = []
if not exam.section_ids:
errors.append('Exam has no sections')
for section in exam.section_ids:
actual = len(section.question_ids)
expected = section.question_count or 0
if actual == 0:
errors.append(
f'Section "{section.title}" has no questions assigned')
elif expected > 0 and actual < expected:
warnings.append(
f'Section "{section.title}" has {actual}/{expected} questions')
if not section.scoring_method:
warnings.append(
f'Section "{section.title}" has no scoring method set')
if not section.time_limit_min:
warnings.append(
f'Section "{section.title}" has no time limit set')
if not exam.total_time_min:
warnings.append('Exam has no total time limit set')
if not exam.pass_threshold:
warnings.append('Exam has no pass threshold set')
return _json_response({
'valid': len(errors) == 0,
'errors': errors,
'warnings': warnings,
})
except Exception as e:
_logger.exception('validate failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/custom/<int:exam_id>/assign
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>/assign',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def assign(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
body = _get_json_body()
student_ids = body.get('student_ids', [])
batch_id = body.get('batch_id')
access_start = body.get('access_start')
access_end = body.get('access_end')
Assignment = request.env['encoach.exam.assignment'].sudo()
created = 0
if student_ids:
for sid in student_ids:
Assignment.create({
'exam_id': exam.id,
'student_id': int(sid),
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
if batch_id:
try:
batch = request.env['op.batch'].sudo().browse(int(batch_id))
if batch.exists():
batch_students = request.env['op.student'].sudo().search(
[('batch_id', '=', batch.id)])
for student in batch_students:
user = student.user_id if hasattr(student, 'user_id') else None
if user:
Assignment.create({
'exam_id': exam.id,
'student_id': user.id,
'batch_id': batch.id,
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
except Exception:
_logger.warning('Batch lookup failed for batch_id=%s', batch_id)
return _json_response({'assignments_created': created})
except Exception as e:
_logger.exception('assign failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,504 @@
import json
import logging
from odoo import http, fields
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
IELTS_SKILLS = ['listening', 'reading', 'writing', 'speaking']
def _section_to_dict(sec):
return {
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'question_count': sec.question_count or 0,
'time_limit_min': sec.time_limit_min or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
'question_ids': sec.question_ids.ids,
}
def _exam_to_dict(exam):
return {
'id': exam.id,
'title': exam.title,
'template_id': exam.template_id.id if exam.template_id else None,
'subject_id': exam.subject_id.id if exam.subject_id else None,
'entity_id': exam.entity_id.id if exam.entity_id else None,
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
'description': exam.description or '',
'total_time_min': exam.total_time_min or 0,
'pass_threshold': exam.pass_threshold or 0.0,
'results_release_mode': exam.results_release_mode or 'auto',
'randomize_questions': exam.randomize_questions,
'status': exam.status,
'sections': [_section_to_dict(s) for s in exam.section_ids],
}
def _question_to_dict(q):
options = None
if q.options:
try:
options = json.loads(q.options)
except (json.JSONDecodeError, TypeError):
options = q.options
return {
'id': q.id,
'skill': q.skill,
'question_type': q.question_type,
'stem': q.stem,
'options': options,
'marks': q.marks,
'difficulty': q.difficulty,
'status': q.status,
'ielts_certified': q.ielts_certified,
}
class EncoachIeltsExamController(http.Controller):
# ------------------------------------------------------------------
# POST /api/exam/ielts/create
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create(self, **kw):
try:
body = _get_json_body()
template_id = body.get('template_id')
title = (body.get('title') or '').strip()
entity_id = body.get('entity_id')
if not template_id or not title:
return _error_response('template_id and title are required', 400)
template = request.env['encoach.exam.template'].sudo().browse(
int(template_id))
if not template.exists():
return _error_response('Template not found', 404)
exam = request.env['encoach.exam.custom'].sudo().create({
'title': title,
'template_id': template.id,
'subject_id': template.subject_id.id if template.subject_id else False,
'entity_id': int(entity_id) if entity_id else False,
'teacher_id': request.env.user.id,
'total_time_min': template.total_time_min or 0,
'pass_threshold': template.pass_threshold or 0.0,
'results_release_mode': template.results_release_mode or 'auto',
'status': 'draft',
})
structure = None
if template.structure:
try:
structure = json.loads(template.structure)
except (json.JSONDecodeError, TypeError):
structure = None
Section = request.env['encoach.exam.custom.section'].sudo()
if structure and isinstance(structure, list):
for idx, sec_def in enumerate(structure):
Section.create({
'exam_id': exam.id,
'title': sec_def.get('title', f'Section {idx + 1}'),
'skill': sec_def.get('skill', ''),
'question_count': sec_def.get('question_count', 0),
'time_limit_min': sec_def.get('time_limit_min', 0),
'scoring_method': sec_def.get('scoring_method', 'auto'),
'sequence': sec_def.get('sequence', (idx + 1) * 10),
})
else:
for idx, skill in enumerate(IELTS_SKILLS):
Section.create({
'exam_id': exam.id,
'title': skill.capitalize(),
'skill': skill,
'question_count': 40 if skill in ('listening', 'reading') else 0,
'time_limit_min': {'listening': 30, 'reading': 60,
'writing': 60, 'speaking': 15}.get(skill, 30),
'scoring_method': 'auto' if skill in ('listening', 'reading') else 'rubric',
'sequence': (idx + 1) * 10,
})
exam.invalidate_recordset()
return _json_response(_exam_to_dict(exam), 201)
except Exception as e:
_logger.exception('ielts create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('ielts get failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/exam/ielts/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be updated', 400)
body = _get_json_body()
allowed = {'title', 'description', 'total_time_min', 'pass_threshold',
'results_release_mode', 'randomize_questions'}
vals = {k: v for k, v in body.items() if k in allowed}
if vals:
exam.write(vals)
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('ielts update failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>/content-pool-count
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/content-pool-count',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def content_pool_count(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
Question = request.env['encoach.question'].sudo()
base_domain = [('status', '=', 'active')]
if exam.subject_id:
base_domain.append(('subject_id', '=', exam.subject_id.id))
counts = {}
for skill in IELTS_SKILLS:
domain = base_domain + [('skill', '=', skill)]
counts[skill] = Question.search_count(domain)
return _json_response(counts)
except Exception as e:
_logger.exception('content_pool_count failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>/content-pool
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/content-pool',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def content_pool(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
domain = [('status', '=', 'active')]
if exam.subject_id:
domain.append(('subject_id', '=', exam.subject_id.id))
skill = kw.get('skill')
if skill:
domain.append(('skill', '=', skill))
difficulty = kw.get('difficulty')
if difficulty:
domain.append(('difficulty', '=', difficulty))
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Question = request.env['encoach.question']
records, total = _paginate(Question, domain, page=page, size=size)
return _json_response({
'items': [_question_to_dict(q) for q in records],
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('content_pool failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/ielts/<int:exam_id>/auto-assemble
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/auto-assemble',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def auto_assemble(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be auto-assembled', 400)
Question = request.env['encoach.question'].sudo()
base_domain = [('status', '=', 'active')]
if exam.subject_id:
base_domain.append(('subject_id', '=', exam.subject_id.id))
assembled = []
for section in exam.section_ids:
needed = section.question_count or 10
domain = base_domain + [('skill', '=', section.skill)] if section.skill else base_domain
candidates = Question.search(domain, order='difficulty asc, id asc')
easy = candidates.filtered(lambda q: q.difficulty == 'easy')
medium = candidates.filtered(lambda q: q.difficulty == 'medium')
hard = candidates.filtered(lambda q: q.difficulty == 'hard')
easy_count = max(1, needed // 3)
hard_count = max(1, needed // 3)
medium_count = needed - easy_count - hard_count
selected_ids = []
selected_ids.extend(easy[:easy_count].ids)
selected_ids.extend(medium[:medium_count].ids)
selected_ids.extend(hard[:hard_count].ids)
if len(selected_ids) < needed:
remaining = candidates.filtered(lambda q: q.id not in selected_ids)
selected_ids.extend(remaining[:needed - len(selected_ids)].ids)
section.write({'question_ids': [(6, 0, selected_ids)]})
assembled.append({
'section_id': section.id,
'title': section.title,
'skill': section.skill or '',
'questions_assigned': len(selected_ids),
'question_ids': selected_ids,
})
return _json_response({'sections': assembled})
except Exception as e:
_logger.exception('auto_assemble failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/ielts/<int:exam_id>/suggest
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/suggest',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def suggest(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
body = _get_json_body()
section_id = body.get('section_id')
criteria = body.get('criteria', {})
section = request.env['encoach.exam.custom.section'].sudo().browse(
int(section_id)) if section_id else None
if section_id and (not section or not section.exists()):
return _error_response('Section not found', 404)
domain = [('status', '=', 'active')]
if exam.subject_id:
domain.append(('subject_id', '=', exam.subject_id.id))
if section and section.skill:
domain.append(('skill', '=', section.skill))
if criteria.get('difficulty'):
domain.append(('difficulty', '=', criteria['difficulty']))
if criteria.get('question_type'):
domain.append(('question_type', '=', criteria['question_type']))
if section:
current_ids = section.question_ids.ids
if current_ids:
domain.append(('id', 'not in', current_ids))
suggestions = request.env['encoach.question'].sudo().search(
domain, limit=int(criteria.get('limit', 10)))
return _json_response({
'suggestions': [_question_to_dict(q) for q in suggestions],
})
except Exception as e:
_logger.exception('suggest failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/exam/ielts/<int:exam_id>/sections/<int:section_id>/questions
# ------------------------------------------------------------------
@http.route(
'/api/exam/ielts/<int:exam_id>/sections/<int:section_id>/questions',
type='http', auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_section_questions(self, exam_id, section_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
section = request.env['encoach.exam.custom.section'].sudo().browse(
section_id)
if not section.exists() or section.exam_id.id != exam.id:
return _error_response('Section not found in this exam', 404)
body = _get_json_body()
question_ids = body.get('question_ids', [])
if not isinstance(question_ids, list):
return _error_response('question_ids must be a list', 400)
valid = request.env['encoach.question'].sudo().browse(question_ids)
valid_ids = valid.filtered(lambda q: q.exists()).ids
section.write({'question_ids': [(6, 0, valid_ids)]})
return _json_response(_section_to_dict(section))
except Exception as e:
_logger.exception('update_section_questions failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>/validate
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/validate',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def validate(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
errors = []
warnings = []
if not exam.section_ids:
errors.append('Exam has no sections')
for section in exam.section_ids:
actual = len(section.question_ids)
expected = section.question_count or 0
if actual == 0:
errors.append(
f'Section "{section.title}" has no questions assigned')
elif expected > 0 and actual < expected:
warnings.append(
f'Section "{section.title}" has {actual}/{expected} questions')
if not section.time_limit_min:
warnings.append(
f'Section "{section.title}" has no time limit set')
if not exam.total_time_min:
warnings.append('Exam has no total time limit set')
has_all_skills = {s.skill for s in exam.section_ids if s.skill}
for skill in IELTS_SKILLS:
if skill not in has_all_skills:
warnings.append(f'Missing IELTS section: {skill}')
return _json_response({
'valid': len(errors) == 0,
'errors': errors,
'warnings': warnings,
})
except Exception as e:
_logger.exception('validate failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/ielts/<int:exam_id>/assign
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/assign',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def assign(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
body = _get_json_body()
student_ids = body.get('student_ids', [])
batch_id = body.get('batch_id')
access_start = body.get('access_start')
access_end = body.get('access_end')
Assignment = request.env['encoach.exam.assignment'].sudo()
created = 0
if student_ids:
for sid in student_ids:
Assignment.create({
'exam_id': exam.id,
'student_id': int(sid),
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
if batch_id:
try:
batch = request.env['op.batch'].sudo().browse(int(batch_id))
if batch.exists():
batch_students = request.env['op.student'].sudo().search(
[('batch_id', '=', batch.id)])
for student in batch_students:
user = student.user_id if hasattr(student, 'user_id') else None
if user:
Assignment.create({
'exam_id': exam.id,
'student_id': user.id,
'batch_id': batch.id,
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
except Exception:
_logger.warning('Batch lookup failed for batch_id=%s', batch_id)
return _json_response({'assignments_created': created})
except Exception as e:
_logger.exception('assign failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,130 @@
import json
import logging
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate
)
_logger = logging.getLogger(__name__)
def _template_to_dict(rec):
structure = None
if rec.structure:
try:
structure = json.loads(rec.structure)
except (json.JSONDecodeError, TypeError):
structure = rec.structure
return {
'id': rec.id,
'name': rec.name,
'code': rec.code or '',
'type': rec.type,
'editable': rec.editable,
'active': rec.active,
'subject_id': rec.subject_id.id if rec.subject_id else None,
'subject_name': rec.subject_id.name if rec.subject_id else None,
'entity_id': rec.entity_id.id if rec.entity_id else None,
'teacher_id': rec.teacher_id.id if rec.teacher_id else None,
'structure': structure,
'total_time_min': rec.total_time_min or 0,
'pass_threshold': rec.pass_threshold or 0.0,
'results_release_mode': rec.results_release_mode or 'auto',
'randomize_questions': rec.randomize_questions,
'description': rec.description or '',
}
class EncoachTemplatesController(http.Controller):
# ------------------------------------------------------------------
# GET /api/exam/templates
# ------------------------------------------------------------------
@http.route('/api/exam/templates', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_templates(self, **kw):
try:
domain = [('active', '=', True)]
tpl_type = kw.get('type')
if tpl_type in ('international', 'custom'):
domain.append(('type', '=', tpl_type))
subject_id = kw.get('subject_id')
if subject_id:
domain.append(('subject_id', '=', int(subject_id)))
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Template = request.env['encoach.exam.template']
records, total = _paginate(Template, domain, page=page, size=size)
return _json_response({
'items': [_template_to_dict(r) for r in records],
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('list_templates failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/templates/<int:template_id>
# ------------------------------------------------------------------
@http.route('/api/exam/templates/<int:template_id>', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_template(self, template_id, **kw):
try:
rec = request.env['encoach.exam.template'].sudo().browse(template_id)
if not rec.exists():
return _error_response('Template not found', 404)
return _json_response(_template_to_dict(rec))
except Exception as e:
_logger.exception('get_template failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/templates/custom
# ------------------------------------------------------------------
@http.route('/api/exam/templates/custom', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_custom_template(self, **kw):
try:
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _error_response('name is required', 400)
structure = body.get('structure')
if structure and not isinstance(structure, str):
structure = json.dumps(structure)
vals = {
'name': name,
'type': 'custom',
'teacher_id': request.env.user.id,
'subject_id': body.get('subject_id'),
'structure': structure,
'total_time_min': body.get('total_time_min', 0),
'pass_threshold': body.get('pass_threshold', 0.0),
'results_release_mode': body.get('results_release_mode', 'auto'),
'randomize_questions': body.get('randomize_questions', False),
'description': body.get('description', ''),
}
rec = request.env['encoach.exam.template'].sudo().create(vals)
return _json_response(_template_to_dict(rec), 201)
except Exception as e:
_logger.exception('create_custom_template failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,117 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- IELTS Academic Template -->
<!-- ============================================================ -->
<record id="template_ielts_academic" model="encoach.exam.template">
<field name="name">IELTS Academic</field>
<field name="code">ielts_academic</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">170</field>
<field name="results_release_mode">manual_approval</field>
<field name="description">The International English Language Testing System (IELTS) Academic test measures English language proficiency for academic purposes. It is recognized by over 11,000 organizations worldwide including universities, employers, immigration authorities, and professional bodies. The test assesses all four language skills: listening, reading, writing, and speaking.</field>
<field name="structure">{
"sections": [
{"skill": "listening", "parts": 4, "questions": 40, "time_min": 30},
{"skill": "reading", "parts": 3, "questions": 40, "time_min": 60},
{"skill": "writing", "parts": 2, "questions": 2, "time_min": 60},
{"skill": "speaking", "parts": 3, "questions": 0, "time_min": 15}
]
}</field>
</record>
<!-- ============================================================ -->
<!-- IELTS General Training Template -->
<!-- ============================================================ -->
<record id="template_ielts_general" model="encoach.exam.template">
<field name="name">IELTS General Training</field>
<field name="code">ielts_general</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">170</field>
<field name="results_release_mode">manual_approval</field>
<field name="description">The IELTS General Training test measures English language proficiency in a practical, everyday context. It is suitable for those planning to undertake non-academic training, gain work experience, or immigrate to an English-speaking country. The reading section draws from notices, advertisements, company handbooks, and newspapers.</field>
<field name="structure">{
"sections": [
{"skill": "listening", "parts": 4, "questions": 40, "time_min": 30},
{"skill": "reading", "parts": 5, "questions": 40, "time_min": 60},
{"skill": "writing", "parts": 2, "questions": 2, "time_min": 60},
{"skill": "speaking", "parts": 3, "questions": 0, "time_min": 15}
]
}</field>
</record>
<!-- ============================================================ -->
<!-- TOEFL iBT Template -->
<!-- ============================================================ -->
<record id="template_toefl" model="encoach.exam.template">
<field name="name">TOEFL iBT</field>
<field name="code">toefl</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">200</field>
<field name="results_release_mode">manual_approval</field>
<field name="description">The Test of English as a Foreign Language Internet-Based Test (TOEFL iBT) measures the ability to use and understand English at the university level. It evaluates how well reading, listening, speaking, and writing skills are combined to perform academic tasks. Scores are accepted by more than 12,500 institutions in over 160 countries.</field>
<field name="structure">{
"sections": [
{"skill": "reading", "parts": 3, "questions": 30, "time_min": 54},
{"skill": "listening", "parts": 3, "questions": 28, "time_min": 41},
{"skill": "speaking", "parts": 4, "questions": 4, "time_min": 17},
{"skill": "writing", "parts": 2, "questions": 2, "time_min": 50}
]
}</field>
</record>
<!-- ============================================================ -->
<!-- STEP Template -->
<!-- ============================================================ -->
<record id="template_step" model="encoach.exam.template">
<field name="name">STEP (Standardized Test of English Proficiency)</field>
<field name="code">step</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">150</field>
<field name="pass_threshold">60.0</field>
<field name="results_release_mode">auto</field>
<field name="description">The Standardized Test of English Proficiency (STEP) is administered by the Saudi National Center for Assessment. It measures English language competency for Saudi students and professionals across listening comprehension, reading comprehension, grammar, and vocabulary. Results are used for university admissions, employment, and scholarship applications within Saudi Arabia.</field>
<field name="structure">{
"sections": [
{"skill": "listening", "parts": 2, "questions": 20, "time_min": 25},
{"skill": "reading", "parts": 4, "questions": 40, "time_min": 65},
{"skill": "grammar", "parts": 2, "questions": 30, "time_min": 30},
{"skill": "vocabulary", "parts": 1, "questions": 10, "time_min": 15}
]
}</field>
</record>
<!-- ============================================================ -->
<!-- IC3 Template -->
<!-- ============================================================ -->
<record id="template_ic3" model="encoach.exam.template">
<field name="name">IC3 (Internet and Computing Core Certification)</field>
<field name="code">ic3</field>
<field name="type">international</field>
<field name="editable" eval="False"/>
<field name="active" eval="True"/>
<field name="total_time_min">150</field>
<field name="pass_threshold">70.0</field>
<field name="results_release_mode">auto</field>
<field name="description">The Internet and Computing Core Certification (IC3) is a global benchmark for basic computer literacy. It validates foundational knowledge across computing fundamentals, key software applications, and internet and networking concepts required for academic and professional environments. The certification is administered by Certiport and recognized internationally.</field>
<field name="structure">{
"sections": [
{"skill": "computing_fundamentals", "parts": 1, "questions": 45, "time_min": 50},
{"skill": "key_applications", "parts": 1, "questions": 45, "time_min": 50},
{"skill": "living_online", "parts": 1, "questions": 45, "time_min": 50}
]
}</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- Passage 1: Easy — Technology -->
<!-- CEFR B1 | ~380 words | Academic Reading Section 1 -->
<!-- ============================================================ -->
<record id="passage_technology" model="encoach.passage">
<field name="exam_type">academic</field>
<field name="section_num">1</field>
<field name="topic_category">Technology</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="approved" eval="True"/>
<field name="ielts_certified" eval="True"/>
<field name="cefr_level">b1</field>
<field name="body_text">The Rise of Artificial Intelligence in Everyday Life
Artificial intelligence, once confined to science fiction novels and academic research laboratories, has become an integral part of daily life for millions of people worldwide. From the moment we wake up to an alarm set by a smart assistant to the personalised news feed we scroll through over breakfast, AI systems are quietly working behind the scenes to make our routines more convenient and efficient.
One of the most visible applications of AI is in virtual assistants such as Siri, Alexa, and Google Assistant. These systems use natural language processing to understand spoken commands and provide relevant responses. Whether it is setting a reminder, playing a song, or answering a factual question, these assistants have become household staples in many countries. Studies show that over 40 percent of adults now use voice-activated assistants at least once a day.
Recommendation algorithms represent another pervasive form of AI. Streaming platforms like Netflix and Spotify analyse viewing and listening habits to suggest content that users are likely to enjoy. Online retailers employ similar technology to recommend products based on browsing history and past purchases. While these systems can feel remarkably intuitive, they work by identifying patterns in enormous datasets and applying statistical models to predict preferences.
In healthcare, AI is making significant strides. Machine learning algorithms can now analyse medical images with accuracy that rivals trained radiologists in certain tasks. Diagnostic tools powered by AI help doctors identify conditions ranging from skin cancer to diabetic retinopathy at earlier stages. However, these tools are designed to assist rather than replace medical professionals, and their outputs still require clinical interpretation.
Transportation is another sector being transformed by artificial intelligence. Navigation applications use real-time traffic data and predictive models to suggest optimal routes. Ride-sharing services employ AI to match passengers with drivers and calculate dynamic pricing. Meanwhile, autonomous vehicle technology continues to advance, with several companies conducting trials of self-driving cars on public roads.
Despite these benefits, the growing presence of AI raises important questions. Concerns about data privacy, algorithmic bias, and the potential displacement of jobs have prompted calls for stronger regulation and ethical guidelines. As AI becomes more capable, society must grapple with how to harness its potential while mitigating its risks.</field>
</record>
<!-- ============================================================ -->
<!-- Passage 2: Medium — Environment -->
<!-- CEFR B2 | ~540 words | Academic Reading Section 2 -->
<!-- ============================================================ -->
<record id="passage_environment" model="encoach.passage">
<field name="exam_type">academic</field>
<field name="section_num">2</field>
<field name="topic_category">Environment</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="approved" eval="True"/>
<field name="ielts_certified" eval="True"/>
<field name="cefr_level">b2</field>
<field name="body_text">Ocean Acidification: The Silent Threat to Marine Ecosystems
While the atmospheric consequences of rising carbon dioxide levels have received considerable public attention, a parallel crisis is unfolding beneath the surface of the world's oceans. Ocean acidification, sometimes referred to as "climate change's equally evil twin," occurs when seawater absorbs excess carbon dioxide from the atmosphere, triggering chemical reactions that lower the water's pH. Since the beginning of the Industrial Revolution, the oceans have absorbed approximately 30 percent of all human-produced CO2 emissions, causing surface water pH to drop by 0.1 units. Although this figure may appear modest, it represents a 26 percent increase in hydrogen ion concentration, fundamentally altering ocean chemistry on a global scale.
The process is deceptively straightforward. When CO2 dissolves in seawater, it reacts with water molecules to form carbonic acid, which then dissociates into hydrogen ions and bicarbonate. The surplus hydrogen ions bond with carbonate ions, reducing their availability in the water. This is particularly consequential because carbonate ions are essential building blocks for the calcium carbonate structures produced by a wide range of marine organisms, including corals, molluscs, sea urchins, and certain species of plankton.
Coral reefs, which support roughly 25 percent of all marine species despite covering less than one percent of the ocean floor, are among the most vulnerable ecosystems. As carbonate ion concentrations decline, corals struggle to build and maintain their skeletal frameworks. Research conducted at multiple reef sites has demonstrated that calcification rates have already decreased by 14 percent compared to pre-industrial levels. In severely acidified conditions, existing coral structures may begin to dissolve, threatening the intricate habitats upon which countless species depend.
The implications extend beyond reef-building organisms. Pteropods, tiny sea snails that form a critical component of Arctic and Antarctic food webs, produce delicate shells made of aragonite, a particularly soluble form of calcium carbonate. Laboratory studies have shown that exposure to projected end-of-century pH levels causes visible shell dissolution within 48 hours. Since pteropods serve as a primary food source for species ranging from juvenile salmon to baleen whales, their decline could trigger cascading effects throughout marine food chains.
Perhaps most troubling is the speed at which these changes are occurring. Geological evidence suggests that current acidification rates are at least ten times faster than any natural event in the past 300 million years, leaving marine organisms with little evolutionary time to adapt. Some species may shift their geographic ranges toward more favourable conditions, while others may face population declines or local extinction.
Mitigating ocean acidification ultimately requires reducing CO2 emissions at their source. However, several complementary strategies are being explored, including the cultivation of seagrass beds and kelp forests, which absorb CO2 through photosynthesis and create localised refugia of higher pH. Researchers are also investigating selective breeding programmes aimed at identifying and propagating acid-resistant genetic variants of key species. While these approaches show promise, scientists emphasise that they cannot substitute for the fundamental changes in energy production and consumption needed to address the root cause of the problem.</field>
</record>
<!-- ============================================================ -->
<!-- Passage 3: Hard — Research Methodology -->
<!-- CEFR C1 | ~640 words | Academic Reading Section 3 -->
<!-- ============================================================ -->
<record id="passage_research" model="encoach.passage">
<field name="exam_type">academic</field>
<field name="section_num">3</field>
<field name="topic_category">Research Methodology</field>
<field name="difficulty">hard</field>
<field name="status">active</field>
<field name="approved" eval="True"/>
<field name="ielts_certified" eval="True"/>
<field name="cefr_level">c1</field>
<field name="body_text">Randomised Controlled Trials: Methodological Rigour and Its Limitations
The randomised controlled trial (RCT) is widely regarded as the gold standard of evidence-based research, occupying the highest tier in most hierarchies of scientific evidence. By randomly assigning participants to experimental and control groups, RCTs minimise the influence of confounding variables and selection bias, thereby isolating the effect of the intervention under investigation. This methodological strength has made RCTs the preferred design for evaluating pharmaceutical treatments, surgical procedures, and, increasingly, educational and social policy interventions. Yet despite their epistemological advantages, RCTs are subject to a range of practical, ethical, and conceptual limitations that researchers and policymakers must carefully consider.
One fundamental challenge concerns external validity, or the extent to which findings from a controlled trial can be generalised to broader populations and real-world settings. RCTs typically employ stringent inclusion and exclusion criteria to create homogeneous study samples, which enhances internal validity but may produce results that are not representative of the heterogeneous populations encountered in clinical practice. A landmark analysis by Rothwell (2005) found that fewer than 20 percent of patients seen in routine care would have met the eligibility criteria for the trials whose results were used to guide their treatment. This discrepancy raises legitimate questions about the applicability of trial-derived evidence to individual patients with multiple comorbidities, varying genetic backgrounds, and diverse socioeconomic circumstances.
Ethical constraints further limit the scope of RCTs. The principle of equipoise demands that genuine uncertainty exist regarding the relative merits of the interventions being compared; it is ethically impermissible to randomly withhold a treatment that is believed to be superior. In emergency medicine, obtaining informed consent from patients in acute distress presents additional dilemmas that have prompted the development of exception-from-informed-consent protocols, which themselves remain controversial. Moreover, certain research questions — such as the long-term health effects of environmental pollutants or the consequences of adverse childhood experiences — cannot ethically be addressed through experimental manipulation.
The issue of blinding introduces another layer of complexity. Double-blind designs, in which neither participants nor investigators are aware of group assignments, are considered ideal for minimising performance and detection bias. However, effective blinding is often difficult or impossible to achieve, particularly in trials of surgical interventions, psychotherapy, or lifestyle modifications. When participants can discern their group allocation, placebo effects and expectation biases may contaminate outcomes, and the Hawthorne effect — whereby individuals modify their behaviour simply because they know they are being observed — can influence results in both experimental and control groups.
The increasing recognition of these limitations has spurred methodological innovation. Pragmatic trials, which relax eligibility criteria and are conducted in real-world clinical settings, represent one response to concerns about external validity. Adaptive trial designs allow modifications to sample size, dosage, or even study hypotheses based on interim analyses, potentially improving efficiency without compromising statistical rigour. Bayesian approaches offer an alternative framework for incorporating prior evidence and updating probability estimates as data accumulate.
Nevertheless, these innovations do not eliminate the fundamental tension between the controlled conditions that give RCTs their inferential power and the messy complexity of the environments in which their findings must ultimately be applied. A judicious approach to evidence-based practice requires not only high-quality RCTs but also complementary forms of evidence — including observational studies, qualitative research, and clinical expertise — synthesised through systematic reviews and interpreted with sensitivity to the specific context of application.</field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,770 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<!-- ============================================================ -->
<!-- RUBRICS -->
<!-- ============================================================ -->
<record id="rubric_writing_academic" model="encoach.rubric">
<field name="name">IELTS Academic Writing Assessment Rubric</field>
<field name="skill">writing</field>
<field name="exam_type">academic</field>
<field name="criteria">{
"criteria": [
{
"name": "Task Achievement",
"weight": 25,
"descriptors": {
"9": "Fully addresses all parts of the task. Presents a fully developed position with relevant, extended, and well-supported ideas.",
"7": "Addresses all parts of the task. Presents a clear position throughout the response with relevant main ideas that are extended and supported.",
"5": "Addresses the task only partially. The format may be inappropriate in places. A position is expressed but development is not always clear.",
"3": "Does not adequately address any part of the task. Does not express a clear position. Few ideas are presented with little development."
}
},
{
"name": "Coherence and Cohesion",
"weight": 25,
"descriptors": {
"9": "Uses cohesion in such a way that it attracts no attention. Skilfully manages paragraphing.",
"7": "Logically organises information and ideas. Uses a range of cohesive devices appropriately although there may be some under- or over-use.",
"5": "Presents information with some organisation but there may be a lack of overall progression. Makes inadequate or inaccurate use of cohesive devices.",
"3": "Does not organise ideas logically. Relationships between ideas may be unclear or absent."
}
},
{
"name": "Lexical Resource",
"weight": 25,
"descriptors": {
"9": "Uses a wide range of vocabulary with very natural and sophisticated control of lexical features. Rare minor errors occur only as slips.",
"7": "Uses a sufficient range of vocabulary to allow some flexibility and precision. Uses less common lexical items with some awareness of style and collocation.",
"5": "Uses a limited range of vocabulary but this is minimally adequate for the task. May make noticeable errors in spelling or word formation that may cause some difficulty for the reader.",
"3": "Uses only a very limited range of words and expressions with very limited control of word formation or spelling."
}
},
{
"name": "Grammatical Range and Accuracy",
"weight": 25,
"descriptors": {
"9": "Uses a wide range of structures with full flexibility and accuracy. Rare minor errors occur only as slips.",
"7": "Uses a variety of complex structures. Produces frequent error-free sentences. Has good control of grammar and punctuation but may make a few errors.",
"5": "Uses only a limited range of structures. Attempts complex sentences but these tend to be less accurate than simple sentences. Errors can cause some difficulty for the reader.",
"3": "Attempts sentence forms but errors in grammar and punctuation predominate and distort the meaning."
}
}
]
}</field>
</record>
<record id="rubric_speaking_ielts" model="encoach.rubric">
<field name="name">IELTS Speaking Assessment Rubric</field>
<field name="skill">speaking</field>
<field name="exam_type">academic</field>
<field name="criteria">{
"criteria": [
{
"name": "Fluency and Coherence",
"weight": 25,
"descriptors": {
"9": "Speaks fluently with only rare repetition or self-correction. Any hesitation is content-related rather than to find words or grammar. Develops topics fully and coherently.",
"7": "Speaks at length without noticeable effort or loss of coherence. May demonstrate language-related hesitation at times. Uses a range of connectives and discourse markers with some flexibility.",
"5": "Usually maintains flow of speech but uses repetition, self-correction or slow speech to keep going. May over-use certain connectives and discourse markers.",
"3": "Speaks with long pauses. Has limited ability to link simple sentences. Gives only simple responses and is frequently unable to convey a basic message."
}
},
{
"name": "Lexical Resource",
"weight": 25,
"descriptors": {
"9": "Uses vocabulary with full flexibility and precision in all topics. Uses idiomatic language naturally and accurately.",
"7": "Uses vocabulary resource flexibly to discuss a variety of topics. Uses some less common and idiomatic vocabulary with some awareness of style and collocation.",
"5": "Manages to talk about familiar and unfamiliar topics but uses vocabulary with limited flexibility. Attempts to use paraphrase but with mixed success.",
"3": "Uses simple vocabulary to convey personal information. Has insufficient vocabulary for less familiar topics."
}
},
{
"name": "Grammatical Range and Accuracy",
"weight": 25,
"descriptors": {
"9": "Uses a full range of structures naturally and appropriately. Produces consistently accurate structures apart from slips characteristic of native speaker speech.",
"7": "Uses a range of complex structures with some flexibility. Frequently produces error-free sentences though some grammatical mistakes persist.",
"5": "Produces basic sentence forms with reasonable accuracy. Uses a limited range of more complex structures but these usually contain errors.",
"3": "Attempts basic sentence forms but with limited success. Subordinate structures are rare. Errors are frequent and may lead to misunderstanding."
}
},
{
"name": "Pronunciation",
"weight": 25,
"descriptors": {
"9": "Uses a full range of pronunciation features with precision and subtlety. Sustains flexible use of features throughout. Is effortless to understand.",
"7": "Shows all the positive features of Band 6 and some but not all positive features of Band 8. Generally easy to understand throughout.",
"5": "Shows all the positive features of Band 4 and some but not all positive features of Band 6. Pronunciation is generally intelligible but mispronunciation of individual words reduces clarity at times.",
"3": "Speech is often unintelligible. Attempts some pronunciation features but control is inconsistent. Mispronunciations are frequent and cause difficulty for the listener."
}
}
]
}</field>
</record>
<!-- ============================================================ -->
<!-- WRITING PROMPTS -->
<!-- ============================================================ -->
<record id="writing_prompt_task1_graph" model="encoach.writing.prompt">
<field name="exam_type">academic</field>
<field name="task">task1</field>
<field name="writing_type">Line Graph</field>
<field name="prompt_text">The graph below shows the percentage of households with internet access in four different countries between 2000 and 2020.
Summarise the information by selecting and reporting the main features, and make comparisons where relevant.
Write at least 150 words.</field>
<field name="rubric_id" ref="rubric_writing_academic"/>
<field name="min_words">150</field>
<field name="model_answer">The line graph illustrates the proportion of households that had internet access in four countries — the United States, the United Kingdom, Japan, and Brazil — over a twenty-year period from 2000 to 2020.
Overall, all four nations experienced significant growth in internet penetration, though the rate and timing of adoption varied considerably. The US and UK consistently maintained the highest levels of access throughout the period.
In 2000, approximately 40 percent of American households were connected to the internet, making it the leading country. The UK followed closely at around 25 percent, while Japan stood at roughly 20 percent and Brazil at just 5 percent. By 2010, the US and UK had both surpassed 70 percent, and Japan had risen sharply to approximately 75 percent, briefly overtaking the other nations.
By 2020, all four countries had achieved substantial coverage, with the US, UK, and Japan each exceeding 90 percent. Brazil showed the most dramatic relative growth, climbing from 5 percent to approximately 75 percent, although it remained the lowest among the four countries.</field>
<field name="approved" eval="True"/>
<field name="ielts_certified" eval="True"/>
</record>
<record id="writing_prompt_task2_essay" model="encoach.writing.prompt">
<field name="exam_type">academic</field>
<field name="task">task2</field>
<field name="writing_type">Opinion Essay</field>
<field name="prompt_text">Some people believe that the increasing use of technology in education has made students less creative and independent in their thinking. Others argue that technology enhances learning and fosters new forms of creativity.
Discuss both views and give your own opinion.
Write at least 250 words.</field>
<field name="rubric_id" ref="rubric_writing_academic"/>
<field name="min_words">250</field>
<field name="model_answer">The role of technology in education is a subject of ongoing debate. While some commentators contend that digital tools stifle creativity and foster dependence, others maintain that technology opens new avenues for innovative thinking. This essay will examine both perspectives before presenting a personal view.
Those who criticise the growing presence of technology in classrooms argue that it encourages passive consumption of information. Students can now access answers instantly through search engines, which may reduce the motivation to think critically or engage in sustained problem-solving. Furthermore, the standardised nature of many educational software programmes can impose rigid learning pathways that leave little room for divergent thinking or experimentation.
On the other hand, proponents of educational technology point to the unprecedented creative possibilities it offers. Digital tools such as video editing software, coding platforms, and collaborative design applications enable students to produce work that would have been impossible using traditional methods alone. Online learning environments also expose students to diverse perspectives from around the world, broadening their intellectual horizons and stimulating cross-cultural dialogue.
In my view, technology is neither inherently beneficial nor detrimental to creativity in education. Its impact depends largely on how it is integrated into the curriculum and how teachers guide students in its use. When technology is employed thoughtfully — as a tool for exploration rather than a substitute for reflection — it can complement traditional methods and enhance both creativity and independent thinking. Educators must therefore be equipped with the training and resources to harness technology's potential while preserving the critical thinking skills that remain essential to meaningful learning.</field>
<field name="approved" eval="True"/>
<field name="ielts_certified" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- SPEAKING CARDS -->
<!-- ============================================================ -->
<record id="speaking_card_part1" model="encoach.speaking.card">
<field name="part">1</field>
<field name="topic">Work and Studies</field>
<field name="questions">[
"Do you work or are you a student?",
"What do you like most about your job or studies?",
"How did you choose your current field of work or study?",
"Would you like to change your job or field of study in the future? Why or why not?"
]</field>
<field name="difficulty">easy</field>
<field name="rubric_id" ref="rubric_speaking_ielts"/>
<field name="model_response">Sample response for Q1: I'm currently working as a software developer at a technology company. I've been in this role for about three years now.
Sample response for Q2: What I enjoy most is the problem-solving aspect. Every day presents a new challenge, and there's a great sense of satisfaction when you find an elegant solution to a complex issue. I also appreciate the collaborative nature of my work — bouncing ideas off colleagues often leads to better outcomes.
Sample response for Q3: I was always interested in computers growing up. When it came time to choose a university course, computer science felt like a natural fit. I did an internship during my second year and that really confirmed my passion for programming.
Sample response for Q4: I'd like to stay in technology but perhaps move towards a more leadership-oriented role. I find that I enjoy mentoring junior developers, so managing a team could be an interesting next step for me.</field>
<field name="approved" eval="True"/>
<field name="ielts_certified" eval="True"/>
</record>
<record id="speaking_card_part2" model="encoach.speaking.card">
<field name="part">2</field>
<field name="topic">Describe a Place You Have Visited That Left a Strong Impression</field>
<field name="bullet_points">[
"Where the place is",
"When you visited it",
"What you did there",
"Explain why it left a strong impression on you"
]</field>
<field name="difficulty">medium</field>
<field name="rubric_id" ref="rubric_speaking_ielts"/>
<field name="model_response">I'd like to talk about my visit to Kyoto, Japan, which I visited about two years ago during the autumn season.
Kyoto is located in the Kansai region of Japan and it was the imperial capital for over a thousand years. I spent five days there as part of a longer trip through Japan.
While I was there, I visited several of the famous temples and shrines, including Kinkaku-ji, the Golden Pavilion, and the Fushimi Inari shrine with its thousands of orange torii gates. I also took part in a traditional tea ceremony and spent an afternoon wandering through the Arashiyama bamboo grove, which was absolutely breathtaking.
What made the experience so memorable was the contrast between old and new. You could walk from a centuries-old zen garden into a modern shopping district within minutes. The autumn colours were spectacular — the maple trees had turned vivid shades of red and gold, and the whole city seemed to glow. But more than the visual beauty, it was the sense of calm and mindfulness that pervaded every interaction. Even in busy tourist areas, there was an underlying tranquility that I found deeply refreshing. It genuinely changed my perspective on how a city can balance preservation of heritage with modern development.</field>
<field name="approved" eval="True"/>
<field name="ielts_certified" eval="True"/>
</record>
<record id="speaking_card_part3" model="encoach.speaking.card">
<field name="part">3</field>
<field name="topic">Travel and Cultural Understanding</field>
<field name="questions">[
"How important is it for people to travel to other countries?",
"Do you think tourism has a positive or negative effect on local communities?",
"In what ways has globalisation changed the way people travel?",
"Some people say that international travel promotes understanding between cultures. Do you agree?",
"How do you think travel will change in the next twenty years?"
]</field>
<field name="linked_card_id" ref="speaking_card_part2"/>
<field name="difficulty">hard</field>
<field name="rubric_id" ref="rubric_speaking_ielts"/>
<field name="model_response">Sample response for Q1: I believe international travel is extremely valuable, though I wouldn't say it's essential for everyone. When you immerse yourself in a different culture, you gain a perspective that simply can't be replicated through books or documentaries. You develop empathy and a broader understanding of how diverse human experiences really are.
Sample response for Q2: This is a complex issue. Tourism can bring significant economic benefits — job creation, infrastructure development, and cultural exchange. However, overtourism can damage local environments, inflate living costs, and sometimes reduce cultural sites to superficial attractions. I think the key lies in sustainable tourism practices that prioritise the wellbeing of local communities.
Sample response for Q3: Globalisation has made travel far more accessible and affordable. Budget airlines, online booking platforms, and social media have opened up destinations that were once obscure. However, this accessibility has also led to a degree of homogenisation — you can find the same chain hotels and restaurants almost anywhere. There's a tension between convenience and authentic cultural experience.
Sample response for Q4: I do agree, to an extent. Direct exposure to different ways of life can break down stereotypes and build genuine understanding. However, the depth of that understanding depends on the traveller's openness and the nature of the trip. A superficial holiday may reinforce stereotypes rather than challenge them.
Sample response for Q5: I think we'll see a stronger emphasis on sustainable and eco-friendly travel. Technology will play a larger role — virtual reality tours, AI-powered translation tools, and perhaps even carbon-neutral aviation. Remote work may also blur the lines between travel and living, with more people becoming long-term nomads rather than short-term tourists.</field>
<field name="approved" eval="True"/>
<field name="ielts_certified" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- LISTENING QUESTIONS (5) -->
<!-- MCQ type, various difficulties, with IRT parameters -->
<!-- ============================================================ -->
<record id="q_listen_01" model="encoach.question">
<field name="skill">listening</field>
<field name="source_type">audio</field>
<field name="question_type">mcq</field>
<field name="stem">What time does the university library close on Saturdays?</field>
<field name="options">[
{"key": "A", "text": "4:00 PM"},
{"key": "B", "text": "5:30 PM"},
{"key": "C", "text": "6:00 PM"},
{"key": "D", "text": "8:00 PM"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="irt_a">0.85</field>
<field name="irt_b">-1.2</field>
<field name="irt_c">0.22</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<record id="q_listen_02" model="encoach.question">
<field name="skill">listening</field>
<field name="source_type">audio</field>
<field name="question_type">mcq</field>
<field name="stem">How much does the monthly gym membership cost for students?</field>
<field name="options">[
{"key": "A", "text": "15 pounds"},
{"key": "B", "text": "20 pounds"},
{"key": "C", "text": "25 pounds"},
{"key": "D", "text": "30 pounds"}
]</field>
<field name="correct_answer">C</field>
<field name="marks">1.0</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="irt_a">0.90</field>
<field name="irt_b">-1.0</field>
<field name="irt_c">0.20</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<record id="q_listen_03" model="encoach.question">
<field name="skill">listening</field>
<field name="source_type">audio</field>
<field name="question_type">mcq</field>
<field name="stem">What is the main purpose of the new campus wellness centre?</field>
<field name="options">[
{"key": "A", "text": "To provide free medical consultations"},
{"key": "B", "text": "To offer mental health support and stress management workshops"},
{"key": "C", "text": "To serve as additional study space during exam periods"},
{"key": "D", "text": "To host social events for international students"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.15</field>
<field name="irt_b">0.1</field>
<field name="irt_c">0.18</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<record id="q_listen_04" model="encoach.question">
<field name="skill">listening</field>
<field name="source_type">audio</field>
<field name="question_type">mcq</field>
<field name="stem">According to the discussion between the two students, the main limitation of their research project was</field>
<field name="options">[
{"key": "A", "text": "insufficient funding for data collection"},
{"key": "B", "text": "the small sample size of the participant group"},
{"key": "C", "text": "the lack of available academic literature"},
{"key": "D", "text": "time constraints imposed by the university"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.30</field>
<field name="irt_b">0.3</field>
<field name="irt_c">0.20</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<record id="q_listen_05" model="encoach.question">
<field name="skill">listening</field>
<field name="source_type">audio</field>
<field name="question_type">mcq</field>
<field name="stem">The lecturer suggests that the primary factor driving the decline in pollinator populations is</field>
<field name="options">[
{"key": "A", "text": "widespread use of neonicotinoid pesticides"},
{"key": "B", "text": "the cumulative effect of habitat fragmentation and monoculture farming"},
{"key": "C", "text": "climate change altering seasonal flowering patterns"},
{"key": "D", "text": "the introduction of non-native bee species competing for resources"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">hard</field>
<field name="status">active</field>
<field name="irt_a">1.70</field>
<field name="irt_b">1.1</field>
<field name="irt_c">0.15</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- READING QUESTIONS (5) -->
<!-- Mix of MCQ, TFNG, gap_fill — referencing passages -->
<!-- ============================================================ -->
<record id="q_read_01" model="encoach.question">
<field name="skill">reading</field>
<field name="source_type">passage</field>
<field name="source_id" eval="ref('passage_technology')"/>
<field name="question_type">mcq</field>
<field name="stem">What is the main idea of the passage about artificial intelligence?</field>
<field name="options">[
{"key": "A", "text": "AI technology is too dangerous to be used in everyday life"},
{"key": "B", "text": "AI has become widely integrated into many aspects of daily routines"},
{"key": "C", "text": "Virtual assistants are the only significant application of AI"},
{"key": "D", "text": "Most people are unaware that AI exists in their devices"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="irt_a">0.80</field>
<field name="irt_b">-1.5</field>
<field name="irt_c">0.25</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<record id="q_read_02" model="encoach.question">
<field name="skill">reading</field>
<field name="source_type">passage</field>
<field name="source_id" eval="ref('passage_environment')"/>
<field name="question_type">mcq</field>
<field name="stem">According to the passage, ocean acidification is caused by</field>
<field name="options">[
{"key": "A", "text": "rising sea temperatures due to global warming"},
{"key": "B", "text": "seawater absorbing excess carbon dioxide from the atmosphere"},
{"key": "C", "text": "industrial chemicals being released directly into the oceans"},
{"key": "D", "text": "the natural erosion of underwater limestone formations"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.10</field>
<field name="irt_b">-0.3</field>
<field name="irt_c">0.22</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<record id="q_read_03" model="encoach.question">
<field name="skill">reading</field>
<field name="source_type">passage</field>
<field name="source_id" eval="ref('passage_technology')"/>
<field name="question_type">tfng</field>
<field name="stem">Over 40 percent of adults use voice-activated assistants at least once a day.</field>
<field name="options">[
{"key": "T", "text": "True"},
{"key": "F", "text": "False"},
{"key": "NG", "text": "Not Given"}
]</field>
<field name="correct_answer">T</field>
<field name="marks">1.0</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="irt_a">0.90</field>
<field name="irt_b">-0.8</field>
<field name="irt_c">0.30</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<record id="q_read_04" model="encoach.question">
<field name="skill">reading</field>
<field name="source_type">passage</field>
<field name="source_id" eval="ref('passage_environment')"/>
<field name="question_type">tfng</field>
<field name="stem">Pteropod shells dissolve within 24 hours when exposed to projected end-of-century pH levels.</field>
<field name="options">[
{"key": "T", "text": "True"},
{"key": "F", "text": "False"},
{"key": "NG", "text": "Not Given"}
]</field>
<field name="correct_answer">F</field>
<field name="marks">1.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.25</field>
<field name="irt_b">0.2</field>
<field name="irt_c">0.28</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<record id="q_read_05" model="encoach.question">
<field name="skill">reading</field>
<field name="source_type">passage</field>
<field name="source_id" eval="ref('passage_research')"/>
<field name="question_type">gap_fill</field>
<field name="stem">According to Rothwell's analysis, fewer than ___ percent of patients in routine care would have met the eligibility criteria for relevant clinical trials.</field>
<field name="correct_answer">20</field>
<field name="marks">1.0</field>
<field name="difficulty">hard</field>
<field name="status">active</field>
<field name="irt_a">1.60</field>
<field name="irt_b">1.3</field>
<field name="irt_c">0.10</field>
<field name="ielts_certified" eval="True"/>
<field name="format_validated" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- GRAMMAR QUESTIONS (5) -->
<!-- MCQ type, testing tenses, conditionals, passive, subjunctive -->
<!-- ============================================================ -->
<record id="q_gram_01" model="encoach.question">
<field name="skill">grammar</field>
<field name="question_type">mcq</field>
<field name="stem">She ___ to the office by bus every morning.</field>
<field name="options">[
{"key": "A", "text": "is going"},
{"key": "B", "text": "goes"},
{"key": "C", "text": "go"},
{"key": "D", "text": "gone"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="irt_a">0.75</field>
<field name="irt_b">-1.8</field>
<field name="irt_c">0.22</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_gram_02" model="encoach.question">
<field name="skill">grammar</field>
<field name="question_type">mcq</field>
<field name="stem">They ___ the research project by the end of last month.</field>
<field name="options">[
{"key": "A", "text": "completed"},
{"key": "B", "text": "had completed"},
{"key": "C", "text": "have completed"},
{"key": "D", "text": "were completing"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="irt_a">0.85</field>
<field name="irt_b">-1.0</field>
<field name="irt_c">0.20</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_gram_03" model="encoach.question">
<field name="skill">grammar</field>
<field name="question_type">mcq</field>
<field name="stem">If I had known about the schedule change, I ___ on time for the meeting.</field>
<field name="options">[
{"key": "A", "text": "would arrive"},
{"key": "B", "text": "would have arrived"},
{"key": "C", "text": "will arrive"},
{"key": "D", "text": "arrived"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.20</field>
<field name="irt_b">0.0</field>
<field name="irt_c">0.18</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_gram_04" model="encoach.question">
<field name="skill">grammar</field>
<field name="question_type">mcq</field>
<field name="stem">The annual report ___ by the finance committee and submitted to the board yesterday.</field>
<field name="options">[
{"key": "A", "text": "reviewed"},
{"key": "B", "text": "was reviewed"},
{"key": "C", "text": "has been reviewed"},
{"key": "D", "text": "is reviewed"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.10</field>
<field name="irt_b">-0.2</field>
<field name="irt_c">0.20</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_gram_05" model="encoach.question">
<field name="skill">grammar</field>
<field name="question_type">mcq</field>
<field name="stem">It is essential that every participant ___ the consent form before the experiment begins.</field>
<field name="options">[
{"key": "A", "text": "signs"},
{"key": "B", "text": "sign"},
{"key": "C", "text": "signed"},
{"key": "D", "text": "will sign"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">hard</field>
<field name="status">active</field>
<field name="irt_a">1.55</field>
<field name="irt_b">0.9</field>
<field name="irt_c">0.15</field>
<field name="format_validated" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- VOCABULARY QUESTIONS (5) -->
<!-- MCQ type, academic vocabulary, various difficulties -->
<!-- ============================================================ -->
<record id="q_vocab_01" model="encoach.question">
<field name="skill">vocabulary</field>
<field name="question_type">mcq</field>
<field name="stem">Choose the word closest in meaning to "abundant".</field>
<field name="options">[
{"key": "A", "text": "scarce"},
{"key": "B", "text": "plentiful"},
{"key": "C", "text": "expensive"},
{"key": "D", "text": "artificial"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="irt_a">0.80</field>
<field name="irt_b">-1.4</field>
<field name="irt_c">0.22</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_vocab_02" model="encoach.question">
<field name="skill">vocabulary</field>
<field name="question_type">mcq</field>
<field name="stem">The word "detrimental" in the passage is closest in meaning to</field>
<field name="options">[
{"key": "A", "text": "beneficial"},
{"key": "B", "text": "irrelevant"},
{"key": "C", "text": "harmful"},
{"key": "D", "text": "surprising"}
]</field>
<field name="correct_answer">C</field>
<field name="marks">1.0</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="irt_a">0.85</field>
<field name="irt_b">-1.2</field>
<field name="irt_c">0.20</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_vocab_03" model="encoach.question">
<field name="skill">vocabulary</field>
<field name="question_type">mcq</field>
<field name="stem">In academic writing, the word "paradigm" most commonly refers to</field>
<field name="options">[
{"key": "A", "text": "a statistical method used in data analysis"},
{"key": "B", "text": "a widely accepted framework of theories and assumptions"},
{"key": "C", "text": "a type of experimental research design"},
{"key": "D", "text": "a formal citation style used in publications"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.15</field>
<field name="irt_b">0.1</field>
<field name="irt_c">0.18</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_vocab_04" model="encoach.question">
<field name="skill">vocabulary</field>
<field name="question_type">mcq</field>
<field name="stem">Choose the word that best completes the sentence: "The study yielded ___ results that challenged several long-held assumptions."</field>
<field name="options">[
{"key": "A", "text": "negligible"},
{"key": "B", "text": "empirical"},
{"key": "C", "text": "ambiguous"},
{"key": "D", "text": "compelling"}
]</field>
<field name="correct_answer">D</field>
<field name="marks">1.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.25</field>
<field name="irt_b">0.4</field>
<field name="irt_c">0.20</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_vocab_05" model="encoach.question">
<field name="skill">vocabulary</field>
<field name="question_type">mcq</field>
<field name="stem">The term "epistemological" most closely relates to</field>
<field name="options">[
{"key": "A", "text": "the study of moral principles and ethical behaviour"},
{"key": "B", "text": "the philosophical investigation of knowledge and justified belief"},
{"key": "C", "text": "the classification of biological organisms"},
{"key": "D", "text": "the analysis of statistical distributions in large datasets"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">1.0</field>
<field name="difficulty">hard</field>
<field name="status">active</field>
<field name="irt_a">1.80</field>
<field name="irt_b">1.5</field>
<field name="irt_c">0.12</field>
<field name="format_validated" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- MATH QUESTIONS (3) -->
<!-- Numerical and expression types for mathematics assessment -->
<!-- ============================================================ -->
<record id="q_math_01" model="encoach.question">
<field name="skill">math</field>
<field name="question_type">numerical</field>
<field name="stem">A factory produces 240 units per hour. Due to a process improvement, production increases by 15%. How many units does the factory now produce in an 8-hour shift?</field>
<field name="correct_answer">2208</field>
<field name="marks">2.0</field>
<field name="difficulty">easy</field>
<field name="status">active</field>
<field name="irt_a">0.75</field>
<field name="irt_b">-1.0</field>
<field name="irt_c">0.10</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_math_02" model="encoach.question">
<field name="skill">math</field>
<field name="question_type">numerical</field>
<field name="stem">A right circular cone has a base radius of 6 cm and a slant height of 10 cm. Calculate the total surface area of the cone in cm². Give your answer to the nearest whole number. (Use π = 3.14159)</field>
<field name="correct_answer">301</field>
<field name="marks">3.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.30</field>
<field name="irt_b">0.4</field>
<field name="irt_c">0.10</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_math_03" model="encoach.question">
<field name="skill">math</field>
<field name="question_type">expression</field>
<field name="stem">Simplify the following expression completely:
(x² - 9) / (x² + 5x + 6)
Express your answer as a single fraction in lowest terms.</field>
<field name="correct_answer">(x - 3) / (x + 2)</field>
<field name="marks">3.0</field>
<field name="difficulty">hard</field>
<field name="status">active</field>
<field name="irt_a">1.65</field>
<field name="irt_b">1.2</field>
<field name="irt_c">0.10</field>
<field name="format_validated" eval="True"/>
</record>
<!-- ============================================================ -->
<!-- IT QUESTIONS (2) -->
<!-- code_output and sql_query types for IT assessment -->
<!-- ============================================================ -->
<record id="q_it_01" model="encoach.question">
<field name="skill">it</field>
<field name="question_type">code_output</field>
<field name="stem">What is the output of the following Python code?
```
values = [10, 20, 30, 40, 50]
result = [v * 2 for v in values if v > 20]
print(sum(result))
```</field>
<field name="options">[
{"key": "A", "text": "120"},
{"key": "B", "text": "240"},
{"key": "C", "text": "300"},
{"key": "D", "text": "180"}
]</field>
<field name="correct_answer">B</field>
<field name="marks">2.0</field>
<field name="difficulty">medium</field>
<field name="status">active</field>
<field name="irt_a">1.20</field>
<field name="irt_b">0.2</field>
<field name="irt_c">0.20</field>
<field name="format_validated" eval="True"/>
</record>
<record id="q_it_02" model="encoach.question">
<field name="skill">it</field>
<field name="question_type">sql_query</field>
<field name="stem">Given the following tables:
employees(id, name, department_id, salary)
departments(id, name)
Write a SQL query that returns the department name and the average salary for each department, but only for departments where the average salary exceeds 50000. Order the results by average salary in descending order.</field>
<field name="correct_answer">SELECT d.name, AVG(e.salary) AS avg_salary FROM employees e JOIN departments d ON e.department_id = d.id GROUP BY d.name HAVING AVG(e.salary) > 50000 ORDER BY avg_salary DESC;</field>
<field name="marks">3.0</field>
<field name="difficulty">hard</field>
<field name="status">active</field>
<field name="irt_a">1.75</field>
<field name="irt_b">1.4</field>
<field name="irt_c">0.10</field>
<field name="format_validated" eval="True"/>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,10 @@
from . import rubric
from . import exam_template
from . import passage
from . import audio_file
from . import question
from . import writing_prompt
from . import speaking_card
from . import exam_custom
from . import exam_custom_section
from . import exam_assignment

Some files were not shown because too many files have changed in this diff Show More