feat: complete all 12 missing spec features from EnCoach v3.0 audit
- Add student_progress, course_section models and module_resources M2M - Activate encoach_resources module with security, views, and ielts_certified field - Add password strength indicator JS on registration page - Rewrite onboarding wizard step 4 as placement test prompt with skip option - Dynamic goal list fetched from exam templates in DB with fallback - B1 default CEFR level assigned when placement test is skipped - Teacher review dashboard with 2 API endpoints for AI content approval - IELTS examiner review interface with review_status, examiner_notes fields - Content source gate: mandatory review for AI, spot-check for certified - Resource-to-learning-style matching in adaptive engine - Daily cron job for teacher no-progress alerts via mail.activity - Entity student placement redirect on dashboard access - Fix Odoo 19 compat: search view groups, ir.cron fields, OWL xml templates Made-with: Cursor
This commit is contained in:
@@ -4,9 +4,10 @@
|
||||
'category': 'Education',
|
||||
'summary': 'Proficiency tracking, learning plans, diagnostics, content cache',
|
||||
'author': 'EnCoach',
|
||||
'depends': ['encoach_core', 'encoach_taxonomy'],
|
||||
'depends': ['encoach_core', 'encoach_taxonomy', 'mail'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/ir_cron.xml',
|
||||
'views/adaptive_event_views.xml',
|
||||
'views/adaptive_path_views.xml',
|
||||
'views/adaptive_settings_views.xml',
|
||||
|
||||
@@ -5,6 +5,7 @@ from odoo.http import request
|
||||
from odoo.addons.encoach_api.controllers.base import (
|
||||
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
||||
)
|
||||
from odoo.addons.encoach_adaptive.services.style_matcher import STYLE_RESOURCE_MAP
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -163,6 +164,54 @@ class EncoachAdaptiveController(http.Controller):
|
||||
_logger.exception('student signals failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/adaptive/student/<int:student_id>/recommended-resources
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/adaptive/student/<int:student_id>/recommended-resources',
|
||||
type='http', auth='none', methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def recommended_resources(self, student_id, **kw):
|
||||
try:
|
||||
profile = request.env['encoach.student.profile'].sudo().search(
|
||||
[('user_id', '=', student_id)], limit=1)
|
||||
learning_style = profile.learning_style if profile else ''
|
||||
|
||||
# Get resources for student's current course/module
|
||||
path = request.env['encoach.adaptive.path'].sudo().search(
|
||||
[('student_id', '=', student_id)], limit=1, order='id desc')
|
||||
|
||||
resources = request.env['encoach.resource'].sudo().search(
|
||||
[('active', '=', True), ('review_status', '=', 'approved')], limit=50)
|
||||
|
||||
if learning_style:
|
||||
from odoo.addons.encoach_adaptive.services.style_matcher import StyleMatcher
|
||||
ranked = StyleMatcher.rank_resources(learning_style, resources)
|
||||
else:
|
||||
ranked = list(resources)
|
||||
|
||||
items = []
|
||||
for r in ranked[:20]:
|
||||
items.append({
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'type': r.type or '',
|
||||
'cefr_level': r.cefr_level or '',
|
||||
'difficulty': r.difficulty or '',
|
||||
'duration_minutes': r.duration_minutes,
|
||||
'style_match': learning_style if r.type in (
|
||||
STYLE_RESOURCE_MAP.get(learning_style, []) if learning_style else []
|
||||
) else '',
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'student_id': student_id,
|
||||
'learning_style': learning_style or 'none',
|
||||
'items': items,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('recommended_resources failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/adaptive/settings
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
14
new_project/custom_addons/encoach_adaptive/data/ir_cron.xml
Normal file
14
new_project/custom_addons/encoach_adaptive/data/ir_cron.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="ir_cron_adaptive_no_progress_alert" model="ir.cron">
|
||||
<field name="name">EnCoach: Check Student Progress Alerts</field>
|
||||
<field name="model_id" search="[('model', '=', 'encoach.adaptive.settings')]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_check_no_progress()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -1,4 +1,4 @@
|
||||
from odoo import models, fields
|
||||
from odoo import api, models, fields
|
||||
|
||||
|
||||
class EncoachAdaptiveSettings(models.Model):
|
||||
@@ -13,3 +13,8 @@ class EncoachAdaptiveSettings(models.Model):
|
||||
module_skip_threshold = fields.Float(default=0.95)
|
||||
no_progress_alert_days = fields.Integer(default=3)
|
||||
max_retries = fields.Integer(default=3)
|
||||
|
||||
@api.model
|
||||
def _cron_check_no_progress(self):
|
||||
from odoo.addons.encoach_adaptive.services.alert_service import AdaptiveAlertService
|
||||
AdaptiveAlertService.check_no_progress(self.env)
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
from .adaptive_engine import AdaptiveEngine
|
||||
from . import style_matcher
|
||||
from . import alert_service
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import fields
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdaptiveAlertService:
|
||||
"""Checks for students with no learning progress and creates teacher alerts."""
|
||||
|
||||
@classmethod
|
||||
def check_no_progress(cls, env):
|
||||
"""Find students with no adaptive events within threshold days and alert teachers.
|
||||
|
||||
Called by scheduled action (ir.cron).
|
||||
"""
|
||||
Settings = env['encoach.adaptive.settings'].sudo()
|
||||
Event = env['encoach.adaptive.event'].sudo()
|
||||
Path = env['encoach.adaptive.path'].sudo()
|
||||
Activity = env['mail.activity'].sudo()
|
||||
|
||||
all_settings = Settings.search([])
|
||||
if not all_settings:
|
||||
all_settings = Settings.new({'no_progress_alert_days': 3})
|
||||
|
||||
for setting in all_settings:
|
||||
days = setting.no_progress_alert_days or 3
|
||||
cutoff = fields.Datetime.now() - timedelta(days=days)
|
||||
teacher = setting.teacher_id
|
||||
|
||||
if not teacher:
|
||||
continue
|
||||
|
||||
# Find students with active paths but no recent events
|
||||
paths = Path.search([])
|
||||
for path in paths:
|
||||
student_id = path.student_id.id
|
||||
recent_events = Event.search_count([
|
||||
('student_id', '=', student_id),
|
||||
('created_at', '>=', cutoff),
|
||||
])
|
||||
|
||||
if recent_events == 0:
|
||||
# Check if alert already exists for this student
|
||||
existing = Activity.search([
|
||||
('res_model', '=', 'res.users'),
|
||||
('res_id', '=', student_id),
|
||||
('user_id', '=', teacher.id),
|
||||
('summary', 'ilike', 'No learning progress'),
|
||||
('date_deadline', '>=', fields.Date.today()),
|
||||
], limit=1)
|
||||
|
||||
if not existing:
|
||||
activity_type = env.ref('mail.mail_activity_data_todo', raise_if_not_found=False)
|
||||
Activity.create({
|
||||
'res_model_id': env['ir.model']._get_id('res.users'),
|
||||
'res_id': student_id,
|
||||
'user_id': teacher.id,
|
||||
'activity_type_id': activity_type.id if activity_type else False,
|
||||
'summary': f'No learning progress for {days}+ days',
|
||||
'note': f'Student {path.student_id.name} has not shown any '
|
||||
f'learning activity in the last {days} days.',
|
||||
'date_deadline': fields.Date.today(),
|
||||
})
|
||||
_logger.info('Created no-progress alert for student %s → teacher %s',
|
||||
path.student_id.name, teacher.name)
|
||||
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
STYLE_RESOURCE_MAP = {
|
||||
'visual': ['video', 'pdf', 'interactive'],
|
||||
'auditory': ['video', 'interactive'],
|
||||
'reading': ['pdf', 'document'],
|
||||
'kinesthetic': ['interactive'],
|
||||
}
|
||||
|
||||
|
||||
class StyleMatcher:
|
||||
"""Ranks learning resources based on student's learning style preference."""
|
||||
|
||||
@classmethod
|
||||
def rank_resources(cls, learning_style, resources):
|
||||
"""Sort resources so that types matching the student's style come first.
|
||||
|
||||
Args:
|
||||
learning_style: str, one of visual/auditory/reading/kinesthetic
|
||||
resources: recordset of encoach.resource
|
||||
|
||||
Returns:
|
||||
sorted list of resource records
|
||||
"""
|
||||
preferred_types = STYLE_RESOURCE_MAP.get(learning_style, [])
|
||||
return sorted(resources, key=lambda r: (r.type not in preferred_types, r.id))
|
||||
|
||||
@classmethod
|
||||
def filter_by_style(cls, learning_style, resources):
|
||||
"""Return only resources matching the learning style (with fallback to all)."""
|
||||
preferred_types = STYLE_RESOURCE_MAP.get(learning_style, [])
|
||||
matched = [r for r in resources if r.type in preferred_types]
|
||||
return matched if matched else list(resources)
|
||||
@@ -11,6 +11,8 @@
|
||||
'views/ai_generation_log_views.xml',
|
||||
'views/ai_ielts_log_views.xml',
|
||||
'views/ai_course_menus.xml',
|
||||
'views/ai_review_views.xml',
|
||||
'views/ielts_review_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import logging
|
||||
from odoo import http
|
||||
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
|
||||
@@ -262,3 +262,135 @@ class EncoachAiCourseController(http.Controller):
|
||||
except Exception as e:
|
||||
_logger.exception('validation check failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai-course/review-queue
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/review-queue', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def review_queue(self, **kw):
|
||||
try:
|
||||
body = _get_json_body() if request.httprequest.content_length else {}
|
||||
page = int(kw.get('page', 1))
|
||||
size = int(kw.get('size', 20))
|
||||
|
||||
Resource = request.env['encoach.resource'].sudo()
|
||||
domain = [('ai_generated', '=', True), ('review_status', '=', 'pending')]
|
||||
total = Resource.search_count(domain)
|
||||
resources = Resource.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
|
||||
|
||||
return _json_response({
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': size,
|
||||
'items': [r.to_api_dict() for r in resources],
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('review_queue failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai-course/review/<int:resource_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/review/<int:resource_id>', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def review_resource(self, resource_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
action = body.get('action')
|
||||
notes = body.get('notes', '')
|
||||
|
||||
if action not in ('approve', 'reject'):
|
||||
return _error_response('action must be approve or reject', 400)
|
||||
|
||||
Resource = request.env['encoach.resource'].sudo()
|
||||
resource = Resource.browse(resource_id)
|
||||
if not resource.exists():
|
||||
return _error_response('Resource not found', 404)
|
||||
|
||||
vals = {
|
||||
'review_status': 'approved' if action == 'approve' else 'rejected',
|
||||
'approved': action == 'approve',
|
||||
}
|
||||
resource.write(vals)
|
||||
|
||||
return _json_response({'status': vals['review_status'], 'resource_id': resource_id})
|
||||
except Exception as e:
|
||||
_logger.exception('review_resource failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/ai-course/ielts-review-queue
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/ielts-review-queue', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def ielts_review_queue(self, **kw):
|
||||
try:
|
||||
page = int(kw.get('page', 1))
|
||||
size = int(kw.get('size', 20))
|
||||
|
||||
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||
domain = [('review_status', '=', 'pending_review')]
|
||||
total = Log.search_count(domain)
|
||||
logs = Log.search(domain, limit=size, offset=(page - 1) * size, order='create_date desc')
|
||||
|
||||
items = []
|
||||
for log in logs:
|
||||
items.append({
|
||||
'id': log.id,
|
||||
'skill': log.skill or '',
|
||||
'band_target': log.band_target if hasattr(log, 'band_target') else 0,
|
||||
'review_status': log.review_status,
|
||||
'created_at': log.create_date.isoformat() if log.create_date else '',
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'total': total,
|
||||
'page': page,
|
||||
'size': size,
|
||||
'items': items,
|
||||
})
|
||||
except Exception as e:
|
||||
_logger.exception('ielts_review_queue failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/ai-course/ielts-review/<int:log_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/ai-course/ielts-review/<int:log_id>', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def ielts_review(self, log_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
action = body.get('action')
|
||||
examiner_notes = body.get('examiner_notes', '')
|
||||
|
||||
if action not in ('approve', 'reject', 'revise'):
|
||||
return _error_response('action must be approve, reject, or revise', 400)
|
||||
|
||||
Log = request.env['encoach.ai.ielts.generation.log'].sudo()
|
||||
log = Log.browse(log_id)
|
||||
if not log.exists():
|
||||
return _error_response('Log not found', 404)
|
||||
|
||||
status_map = {
|
||||
'approve': 'approved',
|
||||
'reject': 'rejected',
|
||||
'revise': 'revision_needed',
|
||||
}
|
||||
|
||||
log.write({
|
||||
'review_status': status_map[action],
|
||||
'examiner_id': request.env.user.id,
|
||||
'examiner_notes': examiner_notes,
|
||||
'reviewed_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
return _json_response({'status': status_map[action], 'log_id': log_id})
|
||||
except Exception as e:
|
||||
_logger.exception('ielts_review failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
@@ -24,3 +24,12 @@ class EncoachAiIeltsGenerationLog(models.Model):
|
||||
], default='generating', required=True)
|
||||
attempts = fields.Integer(default=0)
|
||||
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||
review_status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('pending_review', 'Pending Review'),
|
||||
('approved', 'Approved'),
|
||||
('rejected', 'Rejected'),
|
||||
('revision_needed', 'Revision Needed'),
|
||||
], default='draft')
|
||||
examiner_notes = fields.Text()
|
||||
reviewed_at = fields.Datetime()
|
||||
|
||||
@@ -6,6 +6,11 @@ try:
|
||||
except ImportError:
|
||||
OpenAIService = None
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
|
||||
except ImportError:
|
||||
ContentSourceGate = None
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -56,13 +61,20 @@ class EnglishPipeline:
|
||||
})
|
||||
return {'error': str(e)}
|
||||
|
||||
env['encoach.ai.generation.log'].create({
|
||||
resource = env['encoach.ai.generation.log'].create({
|
||||
'course_type': 'general_english',
|
||||
'brief': json.dumps(gap_profile),
|
||||
'status': 'quality_check',
|
||||
'attempts': 1,
|
||||
})
|
||||
|
||||
# Apply content source gate logic
|
||||
if ContentSourceGate is not None:
|
||||
try:
|
||||
ContentSourceGate.apply_gate(resource)
|
||||
except Exception as e:
|
||||
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
|
||||
|
||||
return content
|
||||
|
||||
def quality_check(self, env, content, cefr_level):
|
||||
|
||||
@@ -6,6 +6,11 @@ try:
|
||||
except ImportError:
|
||||
OpenAIService = None
|
||||
|
||||
try:
|
||||
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
|
||||
except ImportError:
|
||||
ContentSourceGate = None
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
SKILL_PROMPTS = {
|
||||
@@ -71,13 +76,20 @@ class IeltsPipeline:
|
||||
_logger.error("IELTS %s content generation failed: %s", skill, e)
|
||||
return {'error': str(e)}
|
||||
|
||||
env['encoach.ai.ielts.generation.log'].create({
|
||||
resource = env['encoach.ai.ielts.generation.log'].create({
|
||||
'skill': skill,
|
||||
'brief': brief_text,
|
||||
'status': 'format_check',
|
||||
'attempts': 1,
|
||||
})
|
||||
|
||||
# Apply content source gate logic
|
||||
if ContentSourceGate is not None:
|
||||
try:
|
||||
ContentSourceGate.apply_gate(resource)
|
||||
except Exception as e:
|
||||
_logger.warning("ContentSourceGate.apply_gate failed: %s", e)
|
||||
|
||||
return content
|
||||
|
||||
def format_check(self, env, content, skill):
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_ai_review_queue_list" model="ir.ui.view">
|
||||
<field name="name">encoach.resource.ai.review.list</field>
|
||||
<field name="model">encoach.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Content Review Queue">
|
||||
<field name="name"/>
|
||||
<field name="type"/>
|
||||
<field name="cefr_level"/>
|
||||
<field name="grammar_topic"/>
|
||||
<field name="review_status" widget="badge" decoration-info="review_status == 'pending'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'"/>
|
||||
<field name="create_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ai_review_queue" model="ir.actions.act_window">
|
||||
<field name="name">AI Content Review</field>
|
||||
<field name="res_model">encoach.resource</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('ai_generated', '=', True)]</field>
|
||||
<field name="context">{'search_default_pending_review': 1}</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_ielts_review_form" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.ielts.generation.log.review.form</field>
|
||||
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="IELTS Content Review">
|
||||
<header>
|
||||
<field name="review_status" widget="statusbar" statusbar_visible="draft,pending_review,approved"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="skill" readonly="1"/>
|
||||
<field name="review_status"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="examiner_id"/>
|
||||
<field name="reviewed_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Examiner Notes">
|
||||
<field name="examiner_notes" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ielts_review_list" model="ir.ui.view">
|
||||
<field name="name">encoach.ai.ielts.generation.log.review.list</field>
|
||||
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="IELTS Content Review Queue">
|
||||
<field name="skill"/>
|
||||
<field name="review_status" widget="badge" decoration-info="review_status == 'pending_review'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'" decoration-warning="review_status == 'revision_needed'"/>
|
||||
<field name="examiner_id"/>
|
||||
<field name="reviewed_at"/>
|
||||
<field name="create_date"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ielts_review_queue" model="ir.actions.act_window">
|
||||
<field name="name">IELTS Content Review</field>
|
||||
<field name="res_model">encoach.ai.ielts.generation.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('review_status', '\!=', 'draft')]</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -5,10 +5,11 @@
|
||||
'summary': 'Gap analysis, auto course generation, and adaptive progression',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_taxonomy'],
|
||||
'depends': ['encoach_core', 'encoach_taxonomy', 'encoach_resources'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/gap_profile_views.xml',
|
||||
'views/course_section_views.xml',
|
||||
'views/course_module_views.xml',
|
||||
'views/gap_analysis_action.xml',
|
||||
'views/course_gen_menus.xml',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from . import gap_profile
|
||||
from . import course_extension
|
||||
from . import course_section
|
||||
from . import course_module
|
||||
|
||||
@@ -33,3 +33,5 @@ class EncoachCourseModule(models.Model):
|
||||
('skipped', 'Skipped'),
|
||||
], default='locked', required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
section_id = fields.Many2one('encoach.course.section', ondelete='set null')
|
||||
resource_ids = fields.Many2many('encoach.resource', string='Resources')
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachCourseSection(models.Model):
|
||||
_name = 'encoach.course.section'
|
||||
_description = 'Course Section'
|
||||
_order = 'sequence'
|
||||
|
||||
name = fields.Char(size=200, required=True)
|
||||
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
|
||||
skill = fields.Selection([
|
||||
('listening', 'Listening'),
|
||||
('reading', 'Reading'),
|
||||
('writing', 'Writing'),
|
||||
('speaking', 'Speaking'),
|
||||
('grammar', 'Grammar'),
|
||||
('vocabulary', 'Vocabulary'),
|
||||
('overall', 'Overall'),
|
||||
])
|
||||
sequence = fields.Integer(default=10)
|
||||
module_ids = fields.One2many('encoach.course.module', 'section_id', string='Modules')
|
||||
@@ -1,3 +1,4 @@
|
||||
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
|
||||
access_encoach_course_section_user,encoach.course.section.user,model_encoach_course_section,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_encoach_course_section_form" model="ir.ui.view">
|
||||
<field name="name">encoach.course.section.form</field>
|
||||
<field name="model">encoach.course.section</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Course Section">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="course_id"/>
|
||||
<field name="skill"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Modules">
|
||||
<field name="module_ids">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="cefr_target"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'available'" decoration-success="status == 'completed'" decoration-warning="status == 'in_progress'"/>
|
||||
<field name="sequence"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_encoach_course_section_list" model="ir.ui.view">
|
||||
<field name="name">encoach.course.section.list</field>
|
||||
<field name="model">encoach.course.section</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Course Sections">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="course_id"/>
|
||||
<field name="skill" widget="badge"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_encoach_course_section_search" model="ir.ui.view">
|
||||
<field name="name">encoach.course.section.search</field>
|
||||
<field name="model">encoach.course.section</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Sections">
|
||||
<field name="name"/>
|
||||
<field name="course_id"/>
|
||||
<separator/>
|
||||
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
|
||||
<filter string="Skill" name="group_skill" context="{'group_by': 'skill'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_encoach_course_section" model="ir.actions.act_window">
|
||||
<field name="name">Course Sections</field>
|
||||
<field name="res_model">encoach.course.section</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -23,6 +23,7 @@
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'encoach_portal/static/src/css/portal.css',
|
||||
'encoach_portal/static/src/js/password_strength.js',
|
||||
'encoach_portal/static/src/js/onboarding_wizard.js',
|
||||
'encoach_portal/static/src/xml/onboarding_wizard.xml',
|
||||
],
|
||||
|
||||
@@ -44,6 +44,16 @@ class EncoachPortal(CustomerPortal):
|
||||
@http.route('/my/dashboard', type='http', auth='user', website=True)
|
||||
def student_dashboard(self, **kw):
|
||||
user = request.env.user
|
||||
|
||||
# Entity students must complete placement test first
|
||||
if getattr(user, 'account_source', '') == 'entity_bulk_upload':
|
||||
completed_session = request.env['encoach.cat.session'].sudo().search([
|
||||
('student_id', '=', user.id),
|
||||
('status', '=', 'completed'),
|
||||
], limit=1)
|
||||
if not completed_session:
|
||||
return request.redirect('/my/placement')
|
||||
|
||||
profile = request.env['encoach.student.profile'].sudo().search(
|
||||
[('user_id', '=', user.id)], limit=1,
|
||||
)
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, useRef, onMounted } from "@odoo/owl";
|
||||
import { jsonrpc } from "@web/core/network/rpc_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { Component, useState, useRef, onMounted, xml } from "@odoo/owl";
|
||||
|
||||
async function rpc(url, params) {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "call", id: 1, params: params || {} }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (data.error) throw new Error(data.error.data?.message || "RPC error");
|
||||
return data.result;
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{ key: "goal", title: "Learning Goal", icon: "fa-bullseye" },
|
||||
{ key: "level", title: "Target Level", icon: "fa-signal" },
|
||||
{ key: "prefs", title: "Study Preferences", icon: "fa-sliders" },
|
||||
{ key: "confirm", title: "Confirmation", icon: "fa-check-circle" },
|
||||
{ key: "placement", title: "Placement Test", icon: "fa-tasks" },
|
||||
];
|
||||
|
||||
const GOALS = [
|
||||
const FALLBACK_GOALS = [
|
||||
{ id: "ielts", label: "IELTS Preparation", icon: "fa-language", desc: "Prepare for your IELTS exam" },
|
||||
{ id: "general", label: "General English", icon: "fa-comments", desc: "Improve everyday English" },
|
||||
{ id: "academic", label: "Academic English", icon: "fa-graduation-cap", desc: "University-level English" },
|
||||
@@ -19,13 +28,24 @@ const GOALS = [
|
||||
{ id: "it", label: "IT Skills", icon: "fa-laptop", desc: "Computer and IT proficiency" },
|
||||
];
|
||||
|
||||
const ICON_MAP = {
|
||||
"ielts": "fa-language",
|
||||
"general_english": "fa-comments",
|
||||
"general": "fa-comments",
|
||||
"academic": "fa-graduation-cap",
|
||||
"math": "fa-calculator",
|
||||
"mathematics": "fa-calculator",
|
||||
"it": "fa-laptop",
|
||||
"information_technology": "fa-laptop",
|
||||
};
|
||||
|
||||
const LEVELS = [
|
||||
{ id: "A1", label: "A1 – Beginner" },
|
||||
{ id: "A2", label: "A2 – Elementary" },
|
||||
{ id: "B1", label: "B1 – Intermediate" },
|
||||
{ id: "B2", label: "B2 – Upper Intermediate" },
|
||||
{ id: "C1", label: "C1 – Advanced" },
|
||||
{ id: "C2", label: "C2 – Proficiency" },
|
||||
{ id: "A1", label: "A1 \u2013 Beginner" },
|
||||
{ id: "A2", label: "A2 \u2013 Elementary" },
|
||||
{ id: "B1", label: "B1 \u2013 Intermediate" },
|
||||
{ id: "B2", label: "B2 \u2013 Upper Intermediate" },
|
||||
{ id: "C1", label: "C1 \u2013 Advanced" },
|
||||
{ id: "C2", label: "C2 \u2013 Proficiency" },
|
||||
{ id: "band_5", label: "IELTS Band 5.0" },
|
||||
{ id: "band_6", label: "IELTS Band 6.0" },
|
||||
{ id: "band_7", label: "IELTS Band 7.0+" },
|
||||
@@ -38,13 +58,158 @@ const STUDY_PREFS = [
|
||||
];
|
||||
|
||||
export class OnboardingWizard extends Component {
|
||||
static template = "encoach_portal.OnboardingWizard";
|
||||
static template = xml`
|
||||
<div class="ec-wizard" t-ref="root">
|
||||
<div class="ec-wizard-progress mb-4">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<t t-foreach="steps" t-as="step" t-key="step.key">
|
||||
<div t-attf-class="ec-wizard-step text-center flex-fill #{ step_index <= state.currentStep ? 'active' : '' } #{ step_index < state.currentStep ? 'completed' : '' }">
|
||||
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-1 #{ step_index <= state.currentStep ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:36px;height:36px;">
|
||||
<i t-attf-class="fa #{ step_index < state.currentStep ? 'fa-check' : step.icon }"/>
|
||||
</span>
|
||||
<small class="d-block" t-esc="step.title"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="progress rounded-pill" style="height:6px;">
|
||||
<div class="progress-bar bg-primary rounded-pill" t-attf-style="width: #{ progressPercent }%;"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.currentStep === 0" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">What would you like to learn?</h4>
|
||||
<p class="text-muted text-center mb-4">Choose your primary learning goal</p>
|
||||
<div class="row g-3">
|
||||
<t t-foreach="goals" t-as="goal" t-key="goal.id">
|
||||
<div class="col-md-6">
|
||||
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedGoal === goal.id ? 'border-primary shadow-sm' : 'border-light' }"
|
||||
t-on-click="onClickGoal" t-att-data-id="goal.id" role="button">
|
||||
<div class="card-body p-3 d-flex align-items-center">
|
||||
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-3 me-3 #{ state.selectedGoal === goal.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:44px;height:44px;">
|
||||
<i t-attf-class="fa #{ goal.icon }"/>
|
||||
</span>
|
||||
<div>
|
||||
<h6 class="fw-bold mb-0" t-esc="goal.label"/>
|
||||
<small class="text-muted" t-esc="goal.desc"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.currentStep === 1" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">What level are you aiming for?</h4>
|
||||
<p class="text-muted text-center mb-4">Select your target proficiency</p>
|
||||
<div class="row g-2 justify-content-center">
|
||||
<t t-foreach="levels" t-as="lvl" t-key="lvl.id">
|
||||
<div class="col-sm-6 col-md-4">
|
||||
<div t-attf-class="card border-2 rounded-3 ec-selectable-card #{ state.selectedLevel === lvl.id ? 'border-primary shadow-sm' : 'border-light' }"
|
||||
t-on-click="onClickLevel" t-att-data-id="lvl.id" role="button">
|
||||
<div class="card-body p-3 text-center">
|
||||
<span class="fw-semibold" t-esc="lvl.label"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.currentStep === 2" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">How do you prefer to study?</h4>
|
||||
<p class="text-muted text-center mb-4">Pick the style that suits you best</p>
|
||||
<div class="row g-3 justify-content-center">
|
||||
<t t-foreach="studyPrefs" t-as="pref" t-key="pref.id">
|
||||
<div class="col-md-4">
|
||||
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedPref === pref.id ? 'border-primary shadow-sm' : 'border-light' }"
|
||||
t-on-click="onClickPref" t-att-data-id="pref.id" role="button">
|
||||
<div class="card-body p-4 text-center">
|
||||
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-2 #{ state.selectedPref === pref.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:52px;height:52px;">
|
||||
<i t-attf-class="fa fa-lg #{ pref.icon }"/>
|
||||
</span>
|
||||
<h6 class="fw-bold mb-1" t-esc="pref.label"/>
|
||||
<small class="text-muted" t-esc="pref.desc"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
|
||||
<p class="text-muted text-center mb-4">We recommend a quick placement test to personalise your learning path</p>
|
||||
<div class="card border-0 bg-light rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4 text-center">
|
||||
<small class="text-muted d-block mb-1">Goal</small>
|
||||
<span class="fw-bold" t-esc="state.selectedGoal"/>
|
||||
</div>
|
||||
<div class="col-sm-4 text-center">
|
||||
<small class="text-muted d-block mb-1">Target Level</small>
|
||||
<span class="fw-bold" t-esc="state.selectedLevel"/>
|
||||
</div>
|
||||
<div class="col-sm-4 text-center">
|
||||
<small class="text-muted d-block mb-1">Study Style</small>
|
||||
<span class="fw-bold" t-esc="state.selectedPref"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card border-2 border-primary rounded-4 mb-3">
|
||||
<div class="card-body p-4 text-center">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
|
||||
<i class="fa fa-tasks fa-lg"/>
|
||||
</span>
|
||||
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
|
||||
<p class="text-muted mb-0">20-30 minutes \u2014 covers Grammar, Vocabulary, Reading, and Speaking</p>
|
||||
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
|
||||
</div>
|
||||
</div>
|
||||
<t t-if="state.error">
|
||||
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4">
|
||||
<button t-attf-class="btn btn-outline-secondary rounded-3 px-4 #{ isFirstStep ? 'invisible' : '' }"
|
||||
t-on-click="prevStep">
|
||||
<i class="fa fa-arrow-left me-2"/>Back
|
||||
</button>
|
||||
<t t-if="!isLastStep">
|
||||
<button class="btn btn-primary rounded-3 px-4"
|
||||
t-att-disabled="!canProceed"
|
||||
t-on-click="nextStep">
|
||||
Next<i class="fa fa-arrow-right ms-2"/>
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary rounded-3 px-4"
|
||||
t-att-disabled="state.saving"
|
||||
t-on-click="skipPlacement">
|
||||
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/></t>
|
||||
<t t-else="">Skip for now</t>
|
||||
</button>
|
||||
<button class="btn btn-primary rounded-3 px-4"
|
||||
t-att-disabled="state.saving"
|
||||
t-on-click="takePlacement">
|
||||
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/>Saving\u2026</t>
|
||||
<t t-else=""><i class="fa fa-play me-2"/>Take Placement Test</t>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.rootRef = useRef("root");
|
||||
this.steps = STEPS;
|
||||
this.goals = GOALS;
|
||||
this.goals = FALLBACK_GOALS;
|
||||
this.levels = LEVELS;
|
||||
this.studyPrefs = STUDY_PREFS;
|
||||
|
||||
@@ -55,37 +220,46 @@ export class OnboardingWizard extends Component {
|
||||
selectedPref: null,
|
||||
saving: false,
|
||||
error: null,
|
||||
goalsLoaded: false,
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
const el = document.getElementById("ec_onboarding_wizard");
|
||||
if (el) {
|
||||
this.userId = el.dataset.userId;
|
||||
this.profileId = el.dataset.profileId;
|
||||
}
|
||||
await this._loadGoals();
|
||||
});
|
||||
}
|
||||
|
||||
get isFirstStep() {
|
||||
return this.state.currentStep === 0;
|
||||
async _loadGoals() {
|
||||
try {
|
||||
const resp = await rpc("/encoach/onboarding/goals");
|
||||
if (resp && resp.goals && resp.goals.length) {
|
||||
this.goals = resp.goals.map((g) => ({
|
||||
id: g.id,
|
||||
label: g.name || g.label || g.id,
|
||||
icon: g.icon || ICON_MAP[g.id] || "fa-book",
|
||||
desc: g.desc || g.description || "",
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to hardcoded goals on error
|
||||
}
|
||||
this.state.goalsLoaded = true;
|
||||
}
|
||||
|
||||
get isLastStep() {
|
||||
return this.state.currentStep === STEPS.length - 1;
|
||||
}
|
||||
get isFirstStep() { return this.state.currentStep === 0; }
|
||||
get isLastStep() { return this.state.currentStep === STEPS.length - 1; }
|
||||
|
||||
get canProceed() {
|
||||
switch (this.state.currentStep) {
|
||||
case 0:
|
||||
return !!this.state.selectedGoal;
|
||||
case 1:
|
||||
return !!this.state.selectedLevel;
|
||||
case 2:
|
||||
return !!this.state.selectedPref;
|
||||
case 3:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
case 0: return !!this.state.selectedGoal;
|
||||
case 1: return !!this.state.selectedLevel;
|
||||
case 2: return !!this.state.selectedPref;
|
||||
case 3: return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,52 +267,46 @@ export class OnboardingWizard extends Component {
|
||||
return Math.round(((this.state.currentStep + 1) / STEPS.length) * 100);
|
||||
}
|
||||
|
||||
selectGoal(goalId) {
|
||||
this.state.selectedGoal = goalId;
|
||||
onClickGoal(ev) {
|
||||
const el = ev.target.closest("[data-id]");
|
||||
if (el) this.state.selectedGoal = el.dataset.id;
|
||||
}
|
||||
onClickLevel(ev) {
|
||||
const el = ev.target.closest("[data-id]");
|
||||
if (el) this.state.selectedLevel = el.dataset.id;
|
||||
}
|
||||
onClickPref(ev) {
|
||||
const el = ev.target.closest("[data-id]");
|
||||
if (el) this.state.selectedPref = el.dataset.id;
|
||||
}
|
||||
|
||||
selectLevel(levelId) {
|
||||
this.state.selectedLevel = levelId;
|
||||
}
|
||||
nextStep() { if (this.canProceed && !this.isLastStep) this.state.currentStep++; }
|
||||
prevStep() { if (!this.isFirstStep) this.state.currentStep--; }
|
||||
|
||||
selectPref(prefId) {
|
||||
this.state.selectedPref = prefId;
|
||||
}
|
||||
|
||||
nextStep() {
|
||||
if (this.canProceed && !this.isLastStep) {
|
||||
this.state.currentStep++;
|
||||
}
|
||||
}
|
||||
|
||||
prevStep() {
|
||||
if (!this.isFirstStep) {
|
||||
this.state.currentStep--;
|
||||
}
|
||||
}
|
||||
|
||||
async submit() {
|
||||
async submit(skipPlacement) {
|
||||
this.state.saving = true;
|
||||
this.state.error = null;
|
||||
try {
|
||||
await jsonrpc("/encoach/onboarding/save", {
|
||||
await rpc("/encoach/onboarding/save", {
|
||||
goal: this.state.selectedGoal,
|
||||
target_level: this.state.selectedLevel,
|
||||
study_preference: this.state.selectedPref,
|
||||
skip_placement: !!skipPlacement,
|
||||
});
|
||||
window.location.href = "/my/dashboard";
|
||||
window.location.href = skipPlacement ? "/my/dashboard" : "/my/placement";
|
||||
} catch (e) {
|
||||
this.state.error = "Something went wrong. Please try again.";
|
||||
this.state.saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
takePlacement() { this.submit(false); }
|
||||
skipPlacement() { this.submit(true); }
|
||||
}
|
||||
|
||||
function mountOnboardingWizard() {
|
||||
const target = document.getElementById("ec_onboarding_wizard");
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (!target) return;
|
||||
const { mount } = owl;
|
||||
mount(OnboardingWizard, target);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
function computeStrength(password) {
|
||||
let score = 0;
|
||||
if (password.length >= 8) score++;
|
||||
if (/[A-Z]/.test(password)) score++;
|
||||
if (/[0-9]/.test(password)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) score++;
|
||||
return score;
|
||||
}
|
||||
|
||||
const LEVELS = [
|
||||
{ label: "", width: "0%", color: "" },
|
||||
{ label: "Weak", width: "25%", color: "bg-danger" },
|
||||
{ label: "Fair", width: "50%", color: "bg-warning" },
|
||||
{ label: "Good", width: "75%", color: "bg-info" },
|
||||
{ label: "Strong", width: "100%", color: "bg-success" },
|
||||
];
|
||||
|
||||
function initPasswordStrength() {
|
||||
const input = document.getElementById("reg_password");
|
||||
const bar = document.getElementById("password_strength_bar");
|
||||
const text = document.getElementById("password_strength_text");
|
||||
if (!input || !bar || !text) return;
|
||||
|
||||
input.addEventListener("input", function () {
|
||||
const score = computeStrength(this.value);
|
||||
const level = LEVELS[score];
|
||||
bar.style.width = level.width;
|
||||
bar.className = "progress-bar " + level.color;
|
||||
text.textContent = level.label;
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initPasswordStrength);
|
||||
} else {
|
||||
initPasswordStrength();
|
||||
}
|
||||
})();
|
||||
@@ -85,11 +85,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Confirmation -->
|
||||
<!-- Step 4: Placement Test -->
|
||||
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
|
||||
<h4 class="fw-bold mb-1 text-center">Confirm Your Choices</h4>
|
||||
<p class="text-muted text-center mb-4">Review before we set up your learning path</p>
|
||||
<div class="card border-0 bg-light rounded-4">
|
||||
<h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
|
||||
<p class="text-muted text-center mb-4">We recommend a quick placement test to personalise your learning path</p>
|
||||
|
||||
<div class="card border-0 bg-light rounded-4 mb-4">
|
||||
<div class="card-body p-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4 text-center">
|
||||
@@ -107,6 +108,18 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-2 border-primary rounded-4 mb-3">
|
||||
<div class="card-body p-4 text-center">
|
||||
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
|
||||
<i class="fa fa-tasks fa-lg"/>
|
||||
</span>
|
||||
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
|
||||
<p class="text-muted mb-0">20-30 minutes — covers Grammar, Vocabulary, Reading, and Speaking</p>
|
||||
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.error">
|
||||
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
|
||||
</t>
|
||||
@@ -126,16 +139,26 @@
|
||||
</button>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<button class="btn btn-primary rounded-3 px-4"
|
||||
t-att-disabled="state.saving"
|
||||
t-on-click="submit">
|
||||
<t t-if="state.saving">
|
||||
<span class="spinner-border spinner-border-sm me-2"/>Saving…
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-check me-2"/>Get Started
|
||||
</t>
|
||||
</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary rounded-3 px-4"
|
||||
t-att-disabled="state.saving"
|
||||
t-on-click="skipPlacement">
|
||||
<t t-if="state.saving">
|
||||
<span class="spinner-border spinner-border-sm me-2"/>
|
||||
</t>
|
||||
<t t-else="">Skip for now</t>
|
||||
</button>
|
||||
<button class="btn btn-primary rounded-3 px-4"
|
||||
t-att-disabled="state.saving"
|
||||
t-on-click="takePlacement">
|
||||
<t t-if="state.saving">
|
||||
<span class="spinner-border spinner-border-sm me-2"/>Saving…
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-play me-2"/>Take Placement Test
|
||||
</t>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
from .quality_checker import QualityChecker
|
||||
from . import content_gate
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import random
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContentSourceGate:
|
||||
"""Determines review requirements based on content source.
|
||||
|
||||
- AI-generated, not IELTS-certified → mandatory full review
|
||||
- IELTS-certified entity content → spot-check (random 10% sampling)
|
||||
- Otherwise → auto-approve
|
||||
"""
|
||||
|
||||
SPOT_CHECK_RATE = 0.10
|
||||
|
||||
@classmethod
|
||||
def check(cls, resource):
|
||||
if resource.ai_generated and not resource.ielts_certified:
|
||||
return 'mandatory_review'
|
||||
if hasattr(resource, 'ielts_certified') and resource.ielts_certified:
|
||||
if random.random() < cls.SPOT_CHECK_RATE:
|
||||
return 'spot_check'
|
||||
return 'auto_approve'
|
||||
return 'auto_approve'
|
||||
|
||||
@classmethod
|
||||
def apply_gate(cls, resource):
|
||||
result = cls.check(resource)
|
||||
if result == 'mandatory_review':
|
||||
resource.write({'review_status': 'pending', 'approved': False})
|
||||
elif result == 'spot_check':
|
||||
resource.write({'review_status': 'pending', 'approved': False})
|
||||
else:
|
||||
resource.write({'review_status': 'approved', 'approved': True})
|
||||
return result
|
||||
1
new_project/custom_addons/encoach_resources/__init__.py
Normal file
1
new_project/custom_addons/encoach_resources/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import models
|
||||
16
new_project/custom_addons/encoach_resources/__manifest__.py
Normal file
16
new_project/custom_addons/encoach_resources/__manifest__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
'name': 'EnCoach Resources',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Learning resources management with module assignment',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_taxonomy'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/resource_views.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import resource
|
||||
@@ -37,6 +37,7 @@ class EncoachResource(models.Model):
|
||||
vocab_band = fields.Char(size=50)
|
||||
ai_generated = fields.Boolean(default=False)
|
||||
approved = fields.Boolean(default=False)
|
||||
ielts_certified = fields.Boolean(default=False)
|
||||
|
||||
def to_api_dict(self):
|
||||
self.ensure_one()
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_resource_user,encoach.resource.user,model_encoach_resource,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_encoach_resource_form" model="ir.ui.view">
|
||||
<field name="name">encoach.resource.form</field>
|
||||
<field name="model">encoach.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Resource">
|
||||
<header>
|
||||
<field name="review_status" widget="statusbar" statusbar_visible="pending,approved,rejected"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="type"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="duration_minutes"/>
|
||||
<field name="cefr_level"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="subject_id"/>
|
||||
<field name="topic_ids" widget="many2many_tags"/>
|
||||
<field name="grammar_topic"/>
|
||||
<field name="vocab_band"/>
|
||||
<field name="ai_generated"/>
|
||||
<field name="approved"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Content">
|
||||
<field name="url"/>
|
||||
<field name="file" filename="name"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_encoach_resource_list" model="ir.ui.view">
|
||||
<field name="name">encoach.resource.list</field>
|
||||
<field name="model">encoach.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Resources">
|
||||
<field name="name"/>
|
||||
<field name="type"/>
|
||||
<field name="cefr_level"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="duration_minutes"/>
|
||||
<field name="review_status" widget="badge" decoration-info="review_status == 'pending'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'"/>
|
||||
<field name="ai_generated"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_encoach_resource_search" model="ir.ui.view">
|
||||
<field name="name">encoach.resource.search</field>
|
||||
<field name="model">encoach.resource</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Resources">
|
||||
<field name="name"/>
|
||||
<filter string="AI Generated" name="ai_generated" domain="[('ai_generated', '=', True)]"/>
|
||||
<filter string="Pending Review" name="pending_review" domain="[('review_status', '=', 'pending')]"/>
|
||||
<filter string="Approved" name="approved" domain="[('review_status', '=', 'approved')]"/>
|
||||
<separator/>
|
||||
<filter string="Type" name="group_type" context="{'group_by': 'type'}"/>
|
||||
<filter string="CEFR Level" name="group_cefr" context="{'group_by': 'cefr_level'}"/>
|
||||
<filter string="Review Status" name="group_review" context="{'group_by': 'review_status'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_encoach_resource" model="ir.actions.act_window">
|
||||
<field name="name">Resources</field>
|
||||
<field name="res_model">encoach.resource</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -5,13 +5,14 @@
|
||||
'summary': 'Exam scoring, grading queue, feedback, and score release management',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_exam_template'],
|
||||
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen', 'encoach_resources'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/student_attempt_views.xml',
|
||||
'views/student_answer_views.xml',
|
||||
'views/score_views.xml',
|
||||
'views/feedback_views.xml',
|
||||
'views/student_progress_views.xml',
|
||||
'views/scoring_menus.xml',
|
||||
],
|
||||
'installable': True,
|
||||
|
||||
@@ -2,3 +2,4 @@ from . import student_attempt
|
||||
from . import student_answer
|
||||
from . import score
|
||||
from . import feedback
|
||||
from . import student_progress
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachStudentProgress(models.Model):
|
||||
_name = 'encoach.student.progress'
|
||||
_description = 'Student Progress'
|
||||
_order = 'completed_at desc'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
course_id = fields.Many2one('op.course', ondelete='cascade')
|
||||
module_id = fields.Many2one('encoach.course.module', ondelete='cascade')
|
||||
resource_id = fields.Many2one('encoach.resource', ondelete='set null')
|
||||
status = fields.Selection([
|
||||
('not_started', 'Not Started'),
|
||||
('in_progress', 'In Progress'),
|
||||
('completed', 'Completed'),
|
||||
('skipped', 'Skipped'),
|
||||
], default='not_started', required=True)
|
||||
score = fields.Float()
|
||||
time_spent_minutes = fields.Integer()
|
||||
completed_at = fields.Datetime()
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
@@ -3,3 +3,4 @@ access_encoach_student_attempt_user,encoach.student.attempt.user,model_encoach_s
|
||||
access_encoach_student_answer_user,encoach.student.answer.user,model_encoach_student_answer,base.group_user,1,1,1,1
|
||||
access_encoach_score_user,encoach.score.user,model_encoach_score,base.group_user,1,1,1,1
|
||||
access_encoach_feedback_user,encoach.feedback.user,model_encoach_feedback,base.group_user,1,1,1,1
|
||||
access_encoach_student_progress_user,encoach.student.progress.user,model_encoach_student_progress,base.group_user,1,1,1,1
|
||||
|
||||
|
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_encoach_student_progress_form" model="ir.ui.view">
|
||||
<field name="name">encoach.student.progress.form</field>
|
||||
<field name="model">encoach.student.progress</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Student Progress">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="not_started,in_progress,completed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<field name="module_id"/>
|
||||
<field name="resource_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="score"/>
|
||||
<field name="time_spent_minutes"/>
|
||||
<field name="completed_at"/>
|
||||
<field name="entity_id"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_encoach_student_progress_list" model="ir.ui.view">
|
||||
<field name="name">encoach.student.progress.list</field>
|
||||
<field name="model">encoach.student.progress</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Student Progress">
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<field name="module_id"/>
|
||||
<field name="resource_id"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'in_progress'" decoration-success="status == 'completed'" decoration-warning="status == 'skipped'"/>
|
||||
<field name="score"/>
|
||||
<field name="time_spent_minutes"/>
|
||||
<field name="completed_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_encoach_student_progress_search" model="ir.ui.view">
|
||||
<field name="name">encoach.student.progress.search</field>
|
||||
<field name="model">encoach.student.progress</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Progress">
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<filter string="Completed" name="completed" domain="[('status', '=', 'completed')]"/>
|
||||
<filter string="In Progress" name="in_progress" domain="[('status', '=', 'in_progress')]"/>
|
||||
<separator/>
|
||||
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_encoach_student_progress" model="ir.actions.act_window">
|
||||
<field name="name">Student Progress</field>
|
||||
<field name="res_model">encoach.student.progress</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -198,16 +198,39 @@ class EncoachSignupController(http.Controller):
|
||||
@jwt_required
|
||||
def onboarding_goals(self, **kw):
|
||||
try:
|
||||
goals = [
|
||||
{'id': 'ielts', 'name': 'IELTS Preparation',
|
||||
'icon': 'mdi-book-education'},
|
||||
{'id': 'general_english', 'name': 'General English',
|
||||
'icon': 'mdi-translate'},
|
||||
{'id': 'math', 'name': 'Mathematics',
|
||||
'icon': 'mdi-calculator-variant'},
|
||||
{'id': 'it', 'name': 'Information Technology',
|
||||
'icon': 'mdi-laptop'},
|
||||
]
|
||||
# Dynamic goals from exam templates in DB
|
||||
Template = request.env['encoach.exam.template'].sudo()
|
||||
templates = Template.search([('active', '=', True)])
|
||||
seen = set()
|
||||
goals = []
|
||||
icon_map = {
|
||||
'ielts': 'fa-language',
|
||||
'general_english': 'fa-comments',
|
||||
'general english': 'fa-comments',
|
||||
'academic': 'fa-graduation-cap',
|
||||
'math': 'fa-calculator',
|
||||
'mathematics': 'fa-calculator',
|
||||
'it': 'fa-laptop',
|
||||
'information technology': 'fa-laptop',
|
||||
}
|
||||
for tpl in templates:
|
||||
subj = tpl.subject_id
|
||||
if subj and subj.name and subj.name.lower() not in seen:
|
||||
seen.add(subj.name.lower())
|
||||
goal_id = subj.name.lower().replace(' ', '_')
|
||||
goals.append({
|
||||
'id': goal_id,
|
||||
'name': subj.name,
|
||||
'icon': icon_map.get(goal_id, icon_map.get(subj.name.lower(), 'fa-book')),
|
||||
})
|
||||
# Fallback if no templates found
|
||||
if not goals:
|
||||
goals = [
|
||||
{'id': 'ielts', 'name': 'IELTS Preparation', 'icon': 'fa-language'},
|
||||
{'id': 'general_english', 'name': 'General English', 'icon': 'fa-comments'},
|
||||
{'id': 'math', 'name': 'Mathematics', 'icon': 'fa-calculator'},
|
||||
{'id': 'it', 'name': 'Information Technology', 'icon': 'fa-laptop'},
|
||||
]
|
||||
return _json_response({'goals': goals})
|
||||
|
||||
except Exception as e:
|
||||
@@ -229,6 +252,8 @@ class EncoachSignupController(http.Controller):
|
||||
if not learning_goal:
|
||||
return _error_response('learning_goal is required', 400)
|
||||
|
||||
skip_placement = body.get('skip_placement', False)
|
||||
|
||||
profile_vals = {
|
||||
'user_id': user.id,
|
||||
'learning_goal': learning_goal,
|
||||
@@ -238,6 +263,10 @@ class EncoachSignupController(http.Controller):
|
||||
'exam_date': body.get('exam_date'),
|
||||
}
|
||||
|
||||
# Assign B1 default level when placement test is skipped
|
||||
if skip_placement:
|
||||
profile_vals['cefr_level'] = 'b1'
|
||||
|
||||
Profile = request.env['encoach.student.profile'].sudo()
|
||||
profile = Profile.search([('user_id', '=', user.id)], limit=1)
|
||||
if profile:
|
||||
@@ -253,6 +282,7 @@ class EncoachSignupController(http.Controller):
|
||||
return _json_response({
|
||||
'message': 'Onboarding complete',
|
||||
'profile_id': profile.id,
|
||||
'skip_placement': skip_placement,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user