diff --git a/new_project/custom_addons/encoach_adaptive/__manifest__.py b/new_project/custom_addons/encoach_adaptive/__manifest__.py index 166fbacf5..0e6c2a0aa 100644 --- a/new_project/custom_addons/encoach_adaptive/__manifest__.py +++ b/new_project/custom_addons/encoach_adaptive/__manifest__.py @@ -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', diff --git a/new_project/custom_addons/encoach_adaptive/controllers/adaptive.py b/new_project/custom_addons/encoach_adaptive/controllers/adaptive.py index 99b75c80b..c9bd7adb8 100644 --- a/new_project/custom_addons/encoach_adaptive/controllers/adaptive.py +++ b/new_project/custom_addons/encoach_adaptive/controllers/adaptive.py @@ -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//recommended-resources + # ------------------------------------------------------------------ + @http.route('/api/adaptive/student//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 # ------------------------------------------------------------------ diff --git a/new_project/custom_addons/encoach_adaptive/data/ir_cron.xml b/new_project/custom_addons/encoach_adaptive/data/ir_cron.xml new file mode 100644 index 000000000..94705fd57 --- /dev/null +++ b/new_project/custom_addons/encoach_adaptive/data/ir_cron.xml @@ -0,0 +1,14 @@ + + + + + EnCoach: Check Student Progress Alerts + + code + model._cron_check_no_progress() + 1 + days + + + + diff --git a/new_project/custom_addons/encoach_adaptive/models/adaptive_settings.py b/new_project/custom_addons/encoach_adaptive/models/adaptive_settings.py index e1c7d9502..7143ac525 100644 --- a/new_project/custom_addons/encoach_adaptive/models/adaptive_settings.py +++ b/new_project/custom_addons/encoach_adaptive/models/adaptive_settings.py @@ -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) diff --git a/new_project/custom_addons/encoach_adaptive/services/__init__.py b/new_project/custom_addons/encoach_adaptive/services/__init__.py index adbc6c3a3..574106d94 100644 --- a/new_project/custom_addons/encoach_adaptive/services/__init__.py +++ b/new_project/custom_addons/encoach_adaptive/services/__init__.py @@ -1 +1,3 @@ from .adaptive_engine import AdaptiveEngine +from . import style_matcher +from . import alert_service diff --git a/new_project/custom_addons/encoach_adaptive/services/alert_service.py b/new_project/custom_addons/encoach_adaptive/services/alert_service.py new file mode 100644 index 000000000..6ea3904c0 --- /dev/null +++ b/new_project/custom_addons/encoach_adaptive/services/alert_service.py @@ -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) diff --git a/new_project/custom_addons/encoach_adaptive/services/style_matcher.py b/new_project/custom_addons/encoach_adaptive/services/style_matcher.py new file mode 100644 index 000000000..fd1598d45 --- /dev/null +++ b/new_project/custom_addons/encoach_adaptive/services/style_matcher.py @@ -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) diff --git a/new_project/custom_addons/encoach_ai_course/__manifest__.py b/new_project/custom_addons/encoach_ai_course/__manifest__.py index 9a1877a41..7475023e1 100644 --- a/new_project/custom_addons/encoach_ai_course/__manifest__.py +++ b/new_project/custom_addons/encoach_ai_course/__manifest__.py @@ -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, diff --git a/new_project/custom_addons/encoach_ai_course/controllers/ai_course.py b/new_project/custom_addons/encoach_ai_course/controllers/ai_course.py index f7158872b..098d2c0a1 100644 --- a/new_project/custom_addons/encoach_ai_course/controllers/ai_course.py +++ b/new_project/custom_addons/encoach_ai_course/controllers/ai_course.py @@ -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/ + # ------------------------------------------------------------------ + @http.route('/api/ai-course/review/', 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/ + # ------------------------------------------------------------------ + @http.route('/api/ai-course/ielts-review/', 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) diff --git a/new_project/custom_addons/encoach_ai_course/models/ai_ielts_generation_log.py b/new_project/custom_addons/encoach_ai_course/models/ai_ielts_generation_log.py index e61a8f0a5..0812e7574 100644 --- a/new_project/custom_addons/encoach_ai_course/models/ai_ielts_generation_log.py +++ b/new_project/custom_addons/encoach_ai_course/models/ai_ielts_generation_log.py @@ -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() diff --git a/new_project/custom_addons/encoach_ai_course/services/english_pipeline.py b/new_project/custom_addons/encoach_ai_course/services/english_pipeline.py index 25f61b80e..eb20ee500 100644 --- a/new_project/custom_addons/encoach_ai_course/services/english_pipeline.py +++ b/new_project/custom_addons/encoach_ai_course/services/english_pipeline.py @@ -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): diff --git a/new_project/custom_addons/encoach_ai_course/services/ielts_pipeline.py b/new_project/custom_addons/encoach_ai_course/services/ielts_pipeline.py index 59026a7e2..3f7462e72 100644 --- a/new_project/custom_addons/encoach_ai_course/services/ielts_pipeline.py +++ b/new_project/custom_addons/encoach_ai_course/services/ielts_pipeline.py @@ -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): diff --git a/new_project/custom_addons/encoach_ai_course/views/ai_review_views.xml b/new_project/custom_addons/encoach_ai_course/views/ai_review_views.xml new file mode 100644 index 000000000..90daf7734 --- /dev/null +++ b/new_project/custom_addons/encoach_ai_course/views/ai_review_views.xml @@ -0,0 +1,27 @@ + + + + + encoach.resource.ai.review.list + encoach.resource + + + + + + + + + + + + + + AI Content Review + encoach.resource + list,form + [('ai_generated', '=', True)] + {'search_default_pending_review': 1} + + + diff --git a/new_project/custom_addons/encoach_ai_course/views/ielts_review_views.xml b/new_project/custom_addons/encoach_ai_course/views/ielts_review_views.xml new file mode 100644 index 000000000..918385646 --- /dev/null +++ b/new_project/custom_addons/encoach_ai_course/views/ielts_review_views.xml @@ -0,0 +1,52 @@ + + + + + encoach.ai.ielts.generation.log.review.form + encoach.ai.ielts.generation.log + +
+
+ +
+ + + + + + + + + + + + + + + +
+
+
+ + + encoach.ai.ielts.generation.log.review.list + encoach.ai.ielts.generation.log + + + + + + + + + + + + + IELTS Content Review + encoach.ai.ielts.generation.log + list,form + [('review_status', '\!=', 'draft')] + + +
diff --git a/new_project/custom_addons/encoach_course_gen/__manifest__.py b/new_project/custom_addons/encoach_course_gen/__manifest__.py index fb9cfc27a..f1550489d 100644 --- a/new_project/custom_addons/encoach_course_gen/__manifest__.py +++ b/new_project/custom_addons/encoach_course_gen/__manifest__.py @@ -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', diff --git a/new_project/custom_addons/encoach_course_gen/models/__init__.py b/new_project/custom_addons/encoach_course_gen/models/__init__.py index 06311583f..2fc57bb45 100644 --- a/new_project/custom_addons/encoach_course_gen/models/__init__.py +++ b/new_project/custom_addons/encoach_course_gen/models/__init__.py @@ -1,3 +1,4 @@ from . import gap_profile from . import course_extension +from . import course_section from . import course_module diff --git a/new_project/custom_addons/encoach_course_gen/models/course_module.py b/new_project/custom_addons/encoach_course_gen/models/course_module.py index 52a709133..a6458cc9a 100644 --- a/new_project/custom_addons/encoach_course_gen/models/course_module.py +++ b/new_project/custom_addons/encoach_course_gen/models/course_module.py @@ -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') diff --git a/new_project/custom_addons/encoach_course_gen/models/course_section.py b/new_project/custom_addons/encoach_course_gen/models/course_section.py new file mode 100644 index 000000000..8f71142c9 --- /dev/null +++ b/new_project/custom_addons/encoach_course_gen/models/course_section.py @@ -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') diff --git a/new_project/custom_addons/encoach_course_gen/security/ir.model.access.csv b/new_project/custom_addons/encoach_course_gen/security/ir.model.access.csv index 0b42bba6b..0bbae761d 100644 --- a/new_project/custom_addons/encoach_course_gen/security/ir.model.access.csv +++ b/new_project/custom_addons/encoach_course_gen/security/ir.model.access.csv @@ -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 diff --git a/new_project/custom_addons/encoach_course_gen/views/course_section_views.xml b/new_project/custom_addons/encoach_course_gen/views/course_section_views.xml new file mode 100644 index 000000000..4397a844d --- /dev/null +++ b/new_project/custom_addons/encoach_course_gen/views/course_section_views.xml @@ -0,0 +1,68 @@ + + + + + encoach.course.section.form + encoach.course.section + +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.course.section.list + encoach.course.section + + + + + + + + + + + + encoach.course.section.search + encoach.course.section + + + + + + + + + + + + + Course Sections + encoach.course.section + list,form + + +
diff --git a/new_project/custom_addons/encoach_portal/__manifest__.py b/new_project/custom_addons/encoach_portal/__manifest__.py index f9f0d19ca..b29a75d1f 100644 --- a/new_project/custom_addons/encoach_portal/__manifest__.py +++ b/new_project/custom_addons/encoach_portal/__manifest__.py @@ -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', ], diff --git a/new_project/custom_addons/encoach_portal/controllers/portal.py b/new_project/custom_addons/encoach_portal/controllers/portal.py index 2bfb8722f..f9fa5d0ab 100644 --- a/new_project/custom_addons/encoach_portal/controllers/portal.py +++ b/new_project/custom_addons/encoach_portal/controllers/portal.py @@ -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, ) diff --git a/new_project/custom_addons/encoach_portal/static/src/js/onboarding_wizard.js b/new_project/custom_addons/encoach_portal/static/src/js/onboarding_wizard.js index 0731233f5..cdcee9263 100644 --- a/new_project/custom_addons/encoach_portal/static/src/js/onboarding_wizard.js +++ b/new_project/custom_addons/encoach_portal/static/src/js/onboarding_wizard.js @@ -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` +
+
+
+ +
+ + + + +
+
+
+
+
+
+
+ +
+

What would you like to learn?

+

Choose your primary learning goal

+
+ +
+
+
+ + + +
+
+ +
+
+
+
+
+
+
+ +
+

What level are you aiming for?

+

Select your target proficiency

+
+ +
+
+
+ +
+
+
+
+
+
+ +
+

How do you prefer to study?

+

Pick the style that suits you best

+
+ +
+
+
+ + + +
+ +
+
+
+
+
+
+ +
+

Take Your Placement Test

+

We recommend a quick placement test to personalise your learning path

+
+
+
+
+ Goal + +
+
+ Target Level + +
+
+ Study Style + +
+
+
+
+
+
+ + + +
Adaptive Placement Test
+

20-30 minutes \u2014 covers Grammar, Vocabulary, Reading, and Speaking

+

Your results will unlock a personalised course tailored to your level

+
+
+ +
+ +
+ +
+ + + + + +
+ + +
+
+
+
`; + 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); } diff --git a/new_project/custom_addons/encoach_portal/static/src/js/password_strength.js b/new_project/custom_addons/encoach_portal/static/src/js/password_strength.js new file mode 100644 index 000000000..c6cf6ad87 --- /dev/null +++ b/new_project/custom_addons/encoach_portal/static/src/js/password_strength.js @@ -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(); + } +})(); diff --git a/new_project/custom_addons/encoach_portal/static/src/xml/onboarding_wizard.xml b/new_project/custom_addons/encoach_portal/static/src/xml/onboarding_wizard.xml index 4bad2cf51..f068a152e 100644 --- a/new_project/custom_addons/encoach_portal/static/src/xml/onboarding_wizard.xml +++ b/new_project/custom_addons/encoach_portal/static/src/xml/onboarding_wizard.xml @@ -85,11 +85,12 @@
- +
-

Confirm Your Choices

-

Review before we set up your learning path

-
+

Take Your Placement Test

+

We recommend a quick placement test to personalise your learning path

+ +
@@ -107,6 +108,18 @@
+ +
+
+ + + +
Adaptive Placement Test
+

20-30 minutes — covers Grammar, Vocabulary, Reading, and Speaking

+

Your results will unlock a personalised course tailored to your level

+
+
+
@@ -126,16 +139,26 @@ - +
+ + +
diff --git a/new_project/custom_addons/encoach_quality_gate/services/__init__.py b/new_project/custom_addons/encoach_quality_gate/services/__init__.py index 35da7ca73..341e174fd 100644 --- a/new_project/custom_addons/encoach_quality_gate/services/__init__.py +++ b/new_project/custom_addons/encoach_quality_gate/services/__init__.py @@ -1 +1,2 @@ from .quality_checker import QualityChecker +from . import content_gate diff --git a/new_project/custom_addons/encoach_quality_gate/services/content_gate.py b/new_project/custom_addons/encoach_quality_gate/services/content_gate.py new file mode 100644 index 000000000..70d2a69ef --- /dev/null +++ b/new_project/custom_addons/encoach_quality_gate/services/content_gate.py @@ -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 diff --git a/new_project/custom_addons/encoach_resources/__init__.py b/new_project/custom_addons/encoach_resources/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/new_project/custom_addons/encoach_resources/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/new_project/custom_addons/encoach_resources/__manifest__.py b/new_project/custom_addons/encoach_resources/__manifest__.py new file mode 100644 index 000000000..87e6dd3d3 --- /dev/null +++ b/new_project/custom_addons/encoach_resources/__manifest__.py @@ -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, +} diff --git a/new_project/custom_addons/encoach_resources/models/__init__.py b/new_project/custom_addons/encoach_resources/models/__init__.py new file mode 100644 index 000000000..364a06e78 --- /dev/null +++ b/new_project/custom_addons/encoach_resources/models/__init__.py @@ -0,0 +1 @@ +from . import resource diff --git a/new_project/custom_addons/encoach_resources/models/resource.py b/new_project/custom_addons/encoach_resources/models/resource.py index 1d594c27f..65f973390 100644 --- a/new_project/custom_addons/encoach_resources/models/resource.py +++ b/new_project/custom_addons/encoach_resources/models/resource.py @@ -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() diff --git a/new_project/custom_addons/encoach_resources/security/ir.model.access.csv b/new_project/custom_addons/encoach_resources/security/ir.model.access.csv new file mode 100644 index 000000000..be3963063 --- /dev/null +++ b/new_project/custom_addons/encoach_resources/security/ir.model.access.csv @@ -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 diff --git a/new_project/custom_addons/encoach_resources/views/resource_views.xml b/new_project/custom_addons/encoach_resources/views/resource_views.xml new file mode 100644 index 000000000..392d37b1b --- /dev/null +++ b/new_project/custom_addons/encoach_resources/views/resource_views.xml @@ -0,0 +1,78 @@ + + + + + encoach.resource.form + encoach.resource + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + encoach.resource.list + encoach.resource + + + + + + + + + + + + + + + encoach.resource.search + encoach.resource + + + + + + + + + + + + + + + + Resources + encoach.resource + list,form + + +
diff --git a/new_project/custom_addons/encoach_scoring/__manifest__.py b/new_project/custom_addons/encoach_scoring/__manifest__.py index 4476d67b8..8e24a0041 100644 --- a/new_project/custom_addons/encoach_scoring/__manifest__.py +++ b/new_project/custom_addons/encoach_scoring/__manifest__.py @@ -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, diff --git a/new_project/custom_addons/encoach_scoring/models/__init__.py b/new_project/custom_addons/encoach_scoring/models/__init__.py index 7263c3c76..6e138be74 100644 --- a/new_project/custom_addons/encoach_scoring/models/__init__.py +++ b/new_project/custom_addons/encoach_scoring/models/__init__.py @@ -2,3 +2,4 @@ from . import student_attempt from . import student_answer from . import score from . import feedback +from . import student_progress diff --git a/new_project/custom_addons/encoach_scoring/models/student_progress.py b/new_project/custom_addons/encoach_scoring/models/student_progress.py new file mode 100644 index 000000000..56149230b --- /dev/null +++ b/new_project/custom_addons/encoach_scoring/models/student_progress.py @@ -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') diff --git a/new_project/custom_addons/encoach_scoring/security/ir.model.access.csv b/new_project/custom_addons/encoach_scoring/security/ir.model.access.csv index 3bc347349..1cab86af2 100644 --- a/new_project/custom_addons/encoach_scoring/security/ir.model.access.csv +++ b/new_project/custom_addons/encoach_scoring/security/ir.model.access.csv @@ -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 diff --git a/new_project/custom_addons/encoach_scoring/views/student_progress_views.xml b/new_project/custom_addons/encoach_scoring/views/student_progress_views.xml new file mode 100644 index 000000000..59d781925 --- /dev/null +++ b/new_project/custom_addons/encoach_scoring/views/student_progress_views.xml @@ -0,0 +1,72 @@ + + + + + encoach.student.progress.form + encoach.student.progress + +
+
+ +
+ + + + + + + + + + + + + + + + +
+
+
+ + + encoach.student.progress.list + encoach.student.progress + + + + + + + + + + + + + + + + encoach.student.progress.search + encoach.student.progress + + + + + + + + + + + + + + + + Student Progress + encoach.student.progress + list,form + + +
diff --git a/new_project/custom_addons/encoach_signup/controllers/auth.py b/new_project/custom_addons/encoach_signup/controllers/auth.py index 1da938cc7..ca6c5a25d 100644 --- a/new_project/custom_addons/encoach_signup/controllers/auth.py +++ b/new_project/custom_addons/encoach_signup/controllers/auth.py @@ -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: