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:
Yamen Ahmad
2026-04-08 03:12:00 +04:00
parent e30e52e21e
commit 5ff9f12de7
39 changed files with 1106 additions and 87 deletions

View File

@@ -4,9 +4,10 @@
'category': 'Education', 'category': 'Education',
'summary': 'Proficiency tracking, learning plans, diagnostics, content cache', 'summary': 'Proficiency tracking, learning plans, diagnostics, content cache',
'author': 'EnCoach', 'author': 'EnCoach',
'depends': ['encoach_core', 'encoach_taxonomy'], 'depends': ['encoach_core', 'encoach_taxonomy', 'mail'],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/ir_cron.xml',
'views/adaptive_event_views.xml', 'views/adaptive_event_views.xml',
'views/adaptive_path_views.xml', 'views/adaptive_path_views.xml',
'views/adaptive_settings_views.xml', 'views/adaptive_settings_views.xml',

View File

@@ -5,6 +5,7 @@ from odoo.http import request
from odoo.addons.encoach_api.controllers.base import ( from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate 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__) _logger = logging.getLogger(__name__)
@@ -163,6 +164,54 @@ class EncoachAdaptiveController(http.Controller):
_logger.exception('student signals failed') _logger.exception('student signals failed')
return _error_response(str(e), 500) 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 # GET /api/adaptive/settings
# ------------------------------------------------------------------ # ------------------------------------------------------------------

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

View File

@@ -1,4 +1,4 @@
from odoo import models, fields from odoo import api, models, fields
class EncoachAdaptiveSettings(models.Model): class EncoachAdaptiveSettings(models.Model):
@@ -13,3 +13,8 @@ class EncoachAdaptiveSettings(models.Model):
module_skip_threshold = fields.Float(default=0.95) module_skip_threshold = fields.Float(default=0.95)
no_progress_alert_days = fields.Integer(default=3) no_progress_alert_days = fields.Integer(default=3)
max_retries = 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)

View File

@@ -1 +1,3 @@
from .adaptive_engine import AdaptiveEngine from .adaptive_engine import AdaptiveEngine
from . import style_matcher
from . import alert_service

View File

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

View File

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

View File

@@ -11,6 +11,8 @@
'views/ai_generation_log_views.xml', 'views/ai_generation_log_views.xml',
'views/ai_ielts_log_views.xml', 'views/ai_ielts_log_views.xml',
'views/ai_course_menus.xml', 'views/ai_course_menus.xml',
'views/ai_review_views.xml',
'views/ielts_review_views.xml',
], ],
'installable': True, 'installable': True,
'application': False, 'application': False,

View File

@@ -1,6 +1,6 @@
import json import json
import logging import logging
from odoo import http from odoo import http, fields
from odoo.http import request from odoo.http import request
from odoo.addons.encoach_api.controllers.base import ( from odoo.addons.encoach_api.controllers.base import (
jwt_required, _json_response, _error_response, _get_json_body, _paginate jwt_required, _json_response, _error_response, _get_json_body, _paginate
@@ -262,3 +262,135 @@ class EncoachAiCourseController(http.Controller):
except Exception as e: except Exception as e:
_logger.exception('validation check failed') _logger.exception('validation check failed')
return _error_response(str(e), 500) 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)

View File

@@ -24,3 +24,12 @@ class EncoachAiIeltsGenerationLog(models.Model):
], default='generating', required=True) ], default='generating', required=True)
attempts = fields.Integer(default=0) attempts = fields.Integer(default=0)
created_at = fields.Datetime(default=fields.Datetime.now) 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()

View File

@@ -6,6 +6,11 @@ try:
except ImportError: except ImportError:
OpenAIService = None OpenAIService = None
try:
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
except ImportError:
ContentSourceGate = None
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -56,13 +61,20 @@ class EnglishPipeline:
}) })
return {'error': str(e)} return {'error': str(e)}
env['encoach.ai.generation.log'].create({ resource = env['encoach.ai.generation.log'].create({
'course_type': 'general_english', 'course_type': 'general_english',
'brief': json.dumps(gap_profile), 'brief': json.dumps(gap_profile),
'status': 'quality_check', 'status': 'quality_check',
'attempts': 1, '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 return content
def quality_check(self, env, content, cefr_level): def quality_check(self, env, content, cefr_level):

View File

@@ -6,6 +6,11 @@ try:
except ImportError: except ImportError:
OpenAIService = None OpenAIService = None
try:
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
except ImportError:
ContentSourceGate = None
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
SKILL_PROMPTS = { SKILL_PROMPTS = {
@@ -71,13 +76,20 @@ class IeltsPipeline:
_logger.error("IELTS %s content generation failed: %s", skill, e) _logger.error("IELTS %s content generation failed: %s", skill, e)
return {'error': str(e)} return {'error': str(e)}
env['encoach.ai.ielts.generation.log'].create({ resource = env['encoach.ai.ielts.generation.log'].create({
'skill': skill, 'skill': skill,
'brief': brief_text, 'brief': brief_text,
'status': 'format_check', 'status': 'format_check',
'attempts': 1, '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 return content
def format_check(self, env, content, skill): def format_check(self, env, content, skill):

View File

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

View File

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

View File

@@ -5,10 +5,11 @@
'summary': 'Gap analysis, auto course generation, and adaptive progression', 'summary': 'Gap analysis, auto course generation, and adaptive progression',
'author': 'EnCoach', 'author': 'EnCoach',
'license': 'LGPL-3', 'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_taxonomy'], 'depends': ['encoach_core', 'encoach_taxonomy', 'encoach_resources'],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'views/gap_profile_views.xml', 'views/gap_profile_views.xml',
'views/course_section_views.xml',
'views/course_module_views.xml', 'views/course_module_views.xml',
'views/gap_analysis_action.xml', 'views/gap_analysis_action.xml',
'views/course_gen_menus.xml', 'views/course_gen_menus.xml',

View File

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

View File

@@ -33,3 +33,5 @@ class EncoachCourseModule(models.Model):
('skipped', 'Skipped'), ('skipped', 'Skipped'),
], default='locked', required=True) ], default='locked', required=True)
sequence = fields.Integer(default=10) sequence = fields.Integer(default=10)
section_id = fields.Many2one('encoach.course.section', ondelete='set null')
resource_ids = fields.Many2many('encoach.resource', string='Resources')

View File

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

View File

@@ -1,3 +1,4 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink 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_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_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
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
4 access_encoach_course_section_user encoach.course.section.user model_encoach_course_section base.group_user 1 1 1 1

View File

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

View File

@@ -23,6 +23,7 @@
'assets': { 'assets': {
'web.assets_frontend': [ 'web.assets_frontend': [
'encoach_portal/static/src/css/portal.css', '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/js/onboarding_wizard.js',
'encoach_portal/static/src/xml/onboarding_wizard.xml', 'encoach_portal/static/src/xml/onboarding_wizard.xml',
], ],

View File

@@ -44,6 +44,16 @@ class EncoachPortal(CustomerPortal):
@http.route('/my/dashboard', type='http', auth='user', website=True) @http.route('/my/dashboard', type='http', auth='user', website=True)
def student_dashboard(self, **kw): def student_dashboard(self, **kw):
user = request.env.user 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( profile = request.env['encoach.student.profile'].sudo().search(
[('user_id', '=', user.id)], limit=1, [('user_id', '=', user.id)], limit=1,
) )

View File

@@ -1,17 +1,26 @@
/** @odoo-module **/ /** @odoo-module **/
import { Component, useState, useRef, onMounted } from "@odoo/owl"; import { Component, useState, useRef, onMounted, xml } from "@odoo/owl";
import { jsonrpc } from "@web/core/network/rpc_service";
import { registry } from "@web/core/registry"; 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 = [ const STEPS = [
{ key: "goal", title: "Learning Goal", icon: "fa-bullseye" }, { key: "goal", title: "Learning Goal", icon: "fa-bullseye" },
{ key: "level", title: "Target Level", icon: "fa-signal" }, { key: "level", title: "Target Level", icon: "fa-signal" },
{ key: "prefs", title: "Study Preferences", icon: "fa-sliders" }, { 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: "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: "general", label: "General English", icon: "fa-comments", desc: "Improve everyday English" },
{ id: "academic", label: "Academic English", icon: "fa-graduation-cap", desc: "University-level English" }, { id: "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" }, { 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 = [ const LEVELS = [
{ id: "A1", label: "A1 Beginner" }, { id: "A1", label: "A1 \u2013 Beginner" },
{ id: "A2", label: "A2 Elementary" }, { id: "A2", label: "A2 \u2013 Elementary" },
{ id: "B1", label: "B1 Intermediate" }, { id: "B1", label: "B1 \u2013 Intermediate" },
{ id: "B2", label: "B2 Upper Intermediate" }, { id: "B2", label: "B2 \u2013 Upper Intermediate" },
{ id: "C1", label: "C1 Advanced" }, { id: "C1", label: "C1 \u2013 Advanced" },
{ id: "C2", label: "C2 Proficiency" }, { id: "C2", label: "C2 \u2013 Proficiency" },
{ id: "band_5", label: "IELTS Band 5.0" }, { id: "band_5", label: "IELTS Band 5.0" },
{ id: "band_6", label: "IELTS Band 6.0" }, { id: "band_6", label: "IELTS Band 6.0" },
{ id: "band_7", label: "IELTS Band 7.0+" }, { id: "band_7", label: "IELTS Band 7.0+" },
@@ -38,13 +58,158 @@ const STUDY_PREFS = [
]; ];
export class OnboardingWizard extends Component { 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 &lt;= state.currentStep ? 'active' : '' } #{ step_index &lt; state.currentStep ? 'completed' : '' }">
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-1 #{ step_index &lt;= state.currentStep ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:36px;height:36px;">
<i t-attf-class="fa #{ step_index &lt; state.currentStep ? 'fa-check' : step.icon }"/>
</span>
<small class="d-block" t-esc="step.title"/>
</div>
</t>
</div>
<div class="progress rounded-pill" style="height:6px;">
<div class="progress-bar bg-primary rounded-pill" t-attf-style="width: #{ progressPercent }%;"/>
</div>
</div>
<div t-if="state.currentStep === 0" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">What would you like to learn?</h4>
<p class="text-muted text-center mb-4">Choose your primary learning goal</p>
<div class="row g-3">
<t t-foreach="goals" t-as="goal" t-key="goal.id">
<div class="col-md-6">
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedGoal === goal.id ? 'border-primary shadow-sm' : 'border-light' }"
t-on-click="onClickGoal" t-att-data-id="goal.id" role="button">
<div class="card-body p-3 d-flex align-items-center">
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-3 me-3 #{ state.selectedGoal === goal.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:44px;height:44px;">
<i t-attf-class="fa #{ goal.icon }"/>
</span>
<div>
<h6 class="fw-bold mb-0" t-esc="goal.label"/>
<small class="text-muted" t-esc="goal.desc"/>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
<div t-if="state.currentStep === 1" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">What level are you aiming for?</h4>
<p class="text-muted text-center mb-4">Select your target proficiency</p>
<div class="row g-2 justify-content-center">
<t t-foreach="levels" t-as="lvl" t-key="lvl.id">
<div class="col-sm-6 col-md-4">
<div t-attf-class="card border-2 rounded-3 ec-selectable-card #{ state.selectedLevel === lvl.id ? 'border-primary shadow-sm' : 'border-light' }"
t-on-click="onClickLevel" t-att-data-id="lvl.id" role="button">
<div class="card-body p-3 text-center">
<span class="fw-semibold" t-esc="lvl.label"/>
</div>
</div>
</div>
</t>
</div>
</div>
<div t-if="state.currentStep === 2" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">How do you prefer to study?</h4>
<p class="text-muted text-center mb-4">Pick the style that suits you best</p>
<div class="row g-3 justify-content-center">
<t t-foreach="studyPrefs" t-as="pref" t-key="pref.id">
<div class="col-md-4">
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedPref === pref.id ? 'border-primary shadow-sm' : 'border-light' }"
t-on-click="onClickPref" t-att-data-id="pref.id" role="button">
<div class="card-body p-4 text-center">
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-2 #{ state.selectedPref === pref.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:52px;height:52px;">
<i t-attf-class="fa fa-lg #{ pref.icon }"/>
</span>
<h6 class="fw-bold mb-1" t-esc="pref.label"/>
<small class="text-muted" t-esc="pref.desc"/>
</div>
</div>
</div>
</t>
</div>
</div>
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
<p class="text-muted text-center mb-4">We recommend a quick placement test to personalise your learning path</p>
<div class="card border-0 bg-light rounded-4 mb-4">
<div class="card-body p-4">
<div class="row g-3">
<div class="col-sm-4 text-center">
<small class="text-muted d-block mb-1">Goal</small>
<span class="fw-bold" t-esc="state.selectedGoal"/>
</div>
<div class="col-sm-4 text-center">
<small class="text-muted d-block mb-1">Target Level</small>
<span class="fw-bold" t-esc="state.selectedLevel"/>
</div>
<div class="col-sm-4 text-center">
<small class="text-muted d-block mb-1">Study Style</small>
<span class="fw-bold" t-esc="state.selectedPref"/>
</div>
</div>
</div>
</div>
<div class="card border-2 border-primary rounded-4 mb-3">
<div class="card-body p-4 text-center">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
<i class="fa fa-tasks fa-lg"/>
</span>
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
<p class="text-muted mb-0">20-30 minutes \u2014 covers Grammar, Vocabulary, Reading, and Speaking</p>
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
</div>
</div>
<t t-if="state.error">
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
</t>
</div>
<div class="d-flex justify-content-between mt-4">
<button t-attf-class="btn btn-outline-secondary rounded-3 px-4 #{ isFirstStep ? 'invisible' : '' }"
t-on-click="prevStep">
<i class="fa fa-arrow-left me-2"/>Back
</button>
<t t-if="!isLastStep">
<button class="btn btn-primary rounded-3 px-4"
t-att-disabled="!canProceed"
t-on-click="nextStep">
Next<i class="fa fa-arrow-right ms-2"/>
</button>
</t>
<t t-else="">
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary rounded-3 px-4"
t-att-disabled="state.saving"
t-on-click="skipPlacement">
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/></t>
<t t-else="">Skip for now</t>
</button>
<button class="btn btn-primary rounded-3 px-4"
t-att-disabled="state.saving"
t-on-click="takePlacement">
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/>Saving\u2026</t>
<t t-else=""><i class="fa fa-play me-2"/>Take Placement Test</t>
</button>
</div>
</t>
</div>
</div>`;
static props = {}; static props = {};
setup() { setup() {
this.rootRef = useRef("root"); this.rootRef = useRef("root");
this.steps = STEPS; this.steps = STEPS;
this.goals = GOALS; this.goals = FALLBACK_GOALS;
this.levels = LEVELS; this.levels = LEVELS;
this.studyPrefs = STUDY_PREFS; this.studyPrefs = STUDY_PREFS;
@@ -55,37 +220,46 @@ export class OnboardingWizard extends Component {
selectedPref: null, selectedPref: null,
saving: false, saving: false,
error: null, error: null,
goalsLoaded: false,
}); });
onMounted(() => { onMounted(async () => {
const el = document.getElementById("ec_onboarding_wizard"); const el = document.getElementById("ec_onboarding_wizard");
if (el) { if (el) {
this.userId = el.dataset.userId; this.userId = el.dataset.userId;
this.profileId = el.dataset.profileId; this.profileId = el.dataset.profileId;
} }
await this._loadGoals();
}); });
} }
get isFirstStep() { async _loadGoals() {
return this.state.currentStep === 0; 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() { get isFirstStep() { return this.state.currentStep === 0; }
return this.state.currentStep === STEPS.length - 1; get isLastStep() { return this.state.currentStep === STEPS.length - 1; }
}
get canProceed() { get canProceed() {
switch (this.state.currentStep) { switch (this.state.currentStep) {
case 0: case 0: return !!this.state.selectedGoal;
return !!this.state.selectedGoal; case 1: return !!this.state.selectedLevel;
case 1: case 2: return !!this.state.selectedPref;
return !!this.state.selectedLevel; case 3: return true;
case 2: default: return false;
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); return Math.round(((this.state.currentStep + 1) / STEPS.length) * 100);
} }
selectGoal(goalId) { onClickGoal(ev) {
this.state.selectedGoal = goalId; 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) { nextStep() { if (this.canProceed && !this.isLastStep) this.state.currentStep++; }
this.state.selectedLevel = levelId; prevStep() { if (!this.isFirstStep) this.state.currentStep--; }
}
selectPref(prefId) { async submit(skipPlacement) {
this.state.selectedPref = prefId;
}
nextStep() {
if (this.canProceed && !this.isLastStep) {
this.state.currentStep++;
}
}
prevStep() {
if (!this.isFirstStep) {
this.state.currentStep--;
}
}
async submit() {
this.state.saving = true; this.state.saving = true;
this.state.error = null; this.state.error = null;
try { try {
await jsonrpc("/encoach/onboarding/save", { await rpc("/encoach/onboarding/save", {
goal: this.state.selectedGoal, goal: this.state.selectedGoal,
target_level: this.state.selectedLevel, target_level: this.state.selectedLevel,
study_preference: this.state.selectedPref, study_preference: this.state.selectedPref,
skip_placement: !!skipPlacement,
}); });
window.location.href = "/my/dashboard"; window.location.href = skipPlacement ? "/my/dashboard" : "/my/placement";
} catch (e) { } catch (e) {
this.state.error = "Something went wrong. Please try again."; this.state.error = "Something went wrong. Please try again.";
this.state.saving = false; this.state.saving = false;
} }
} }
takePlacement() { this.submit(false); }
skipPlacement() { this.submit(true); }
} }
function mountOnboardingWizard() { function mountOnboardingWizard() {
const target = document.getElementById("ec_onboarding_wizard"); const target = document.getElementById("ec_onboarding_wizard");
if (!target) { if (!target) return;
return;
}
const { mount } = owl; const { mount } = owl;
mount(OnboardingWizard, target); mount(OnboardingWizard, target);
} }

View File

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

View File

@@ -85,11 +85,12 @@
</div> </div>
</div> </div>
<!-- Step 4: Confirmation --> <!-- Step 4: Placement Test -->
<div t-if="state.currentStep === 3" class="ec-wizard-panel"> <div t-if="state.currentStep === 3" class="ec-wizard-panel">
<h4 class="fw-bold mb-1 text-center">Confirm Your Choices</h4> <h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
<p class="text-muted text-center mb-4">Review before we set up your learning path</p> <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">
<div class="card border-0 bg-light rounded-4 mb-4">
<div class="card-body p-4"> <div class="card-body p-4">
<div class="row g-3"> <div class="row g-3">
<div class="col-sm-4 text-center"> <div class="col-sm-4 text-center">
@@ -107,6 +108,18 @@
</div> </div>
</div> </div>
</div> </div>
<div class="card border-2 border-primary rounded-4 mb-3">
<div class="card-body p-4 text-center">
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
<i class="fa fa-tasks fa-lg"/>
</span>
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
<p class="text-muted mb-0">20-30 minutes — covers Grammar, Vocabulary, Reading, and Speaking</p>
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
</div>
</div>
<t t-if="state.error"> <t t-if="state.error">
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/> <div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
</t> </t>
@@ -126,16 +139,26 @@
</button> </button>
</t> </t>
<t t-else=""> <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" <button class="btn btn-primary rounded-3 px-4"
t-att-disabled="state.saving" t-att-disabled="state.saving"
t-on-click="submit"> t-on-click="takePlacement">
<t t-if="state.saving"> <t t-if="state.saving">
<span class="spinner-border spinner-border-sm me-2"/>Saving… <span class="spinner-border spinner-border-sm me-2"/>Saving…
</t> </t>
<t t-else=""> <t t-else="">
<i class="fa fa-check me-2"/>Get Started <i class="fa fa-play me-2"/>Take Placement Test
</t> </t>
</button> </button>
</div>
</t> </t>
</div> </div>

View File

@@ -1 +1,2 @@
from .quality_checker import QualityChecker from .quality_checker import QualityChecker
from . import content_gate

View File

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

View File

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

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

View File

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

View File

@@ -37,6 +37,7 @@ class EncoachResource(models.Model):
vocab_band = fields.Char(size=50) vocab_band = fields.Char(size=50)
ai_generated = fields.Boolean(default=False) ai_generated = fields.Boolean(default=False)
approved = fields.Boolean(default=False) approved = fields.Boolean(default=False)
ielts_certified = fields.Boolean(default=False)
def to_api_dict(self): def to_api_dict(self):
self.ensure_one() self.ensure_one()

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_encoach_resource_user encoach.resource.user model_encoach_resource base.group_user 1 1 1 1

View File

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

View File

@@ -5,13 +5,14 @@
'summary': 'Exam scoring, grading queue, feedback, and score release management', 'summary': 'Exam scoring, grading queue, feedback, and score release management',
'author': 'EnCoach', 'author': 'EnCoach',
'license': 'LGPL-3', 'license': 'LGPL-3',
'depends': ['encoach_core', 'encoach_exam_template'], 'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen', 'encoach_resources'],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'views/student_attempt_views.xml', 'views/student_attempt_views.xml',
'views/student_answer_views.xml', 'views/student_answer_views.xml',
'views/score_views.xml', 'views/score_views.xml',
'views/feedback_views.xml', 'views/feedback_views.xml',
'views/student_progress_views.xml',
'views/scoring_menus.xml', 'views/scoring_menus.xml',
], ],
'installable': True, 'installable': True,

View File

@@ -2,3 +2,4 @@ from . import student_attempt
from . import student_answer from . import student_answer
from . import score from . import score
from . import feedback from . import feedback
from . import student_progress

View File

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

View File

@@ -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_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_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_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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
3 access_encoach_student_answer_user encoach.student.answer.user model_encoach_student_answer base.group_user 1 1 1 1
4 access_encoach_score_user encoach.score.user model_encoach_score base.group_user 1 1 1 1
5 access_encoach_feedback_user encoach.feedback.user model_encoach_feedback base.group_user 1 1 1 1
6 access_encoach_student_progress_user encoach.student.progress.user model_encoach_student_progress base.group_user 1 1 1 1

View File

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

View File

@@ -198,15 +198,38 @@ class EncoachSignupController(http.Controller):
@jwt_required @jwt_required
def onboarding_goals(self, **kw): def onboarding_goals(self, **kw):
try: try:
# 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 = [ goals = [
{'id': 'ielts', 'name': 'IELTS Preparation', {'id': 'ielts', 'name': 'IELTS Preparation', 'icon': 'fa-language'},
'icon': 'mdi-book-education'}, {'id': 'general_english', 'name': 'General English', 'icon': 'fa-comments'},
{'id': 'general_english', 'name': 'General English', {'id': 'math', 'name': 'Mathematics', 'icon': 'fa-calculator'},
'icon': 'mdi-translate'}, {'id': 'it', 'name': 'Information Technology', 'icon': 'fa-laptop'},
{'id': 'math', 'name': 'Mathematics',
'icon': 'mdi-calculator-variant'},
{'id': 'it', 'name': 'Information Technology',
'icon': 'mdi-laptop'},
] ]
return _json_response({'goals': goals}) return _json_response({'goals': goals})
@@ -229,6 +252,8 @@ class EncoachSignupController(http.Controller):
if not learning_goal: if not learning_goal:
return _error_response('learning_goal is required', 400) return _error_response('learning_goal is required', 400)
skip_placement = body.get('skip_placement', False)
profile_vals = { profile_vals = {
'user_id': user.id, 'user_id': user.id,
'learning_goal': learning_goal, 'learning_goal': learning_goal,
@@ -238,6 +263,10 @@ class EncoachSignupController(http.Controller):
'exam_date': body.get('exam_date'), '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 = request.env['encoach.student.profile'].sudo()
profile = Profile.search([('user_id', '=', user.id)], limit=1) profile = Profile.search([('user_id', '=', user.id)], limit=1)
if profile: if profile:
@@ -253,6 +282,7 @@ class EncoachSignupController(http.Controller):
return _json_response({ return _json_response({
'message': 'Onboarding complete', 'message': 'Onboarding complete',
'profile_id': profile.id, 'profile_id': profile.id,
'skip_placement': skip_placement,
}) })
except Exception as e: except Exception as e: