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