352 lines
14 KiB
Python
352 lines
14 KiB
Python
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/<int:student_id>/signals
|
|
# ------------------------------------------------------------------
|
|
@http.route('/api/adaptive/student/<int:student_id>/signals', type='http',
|
|
auth='none', methods=['GET'], csrf=False)
|
|
@jwt_required
|
|
def student_signals(self, student_id, **kw):
|
|
try:
|
|
page = int(kw.get('page', 1))
|
|
size = int(kw.get('size', 20))
|
|
|
|
Event = request.env['encoach.adaptive.event'].sudo()
|
|
domain = [('student_id', '=', student_id)]
|
|
events, total = _paginate(Event, domain, page, size, order='created_at desc')
|
|
|
|
items = []
|
|
for ev in events:
|
|
items.append({
|
|
'id': ev.id,
|
|
'event_type': ev.event_type,
|
|
'signal_name': ev.signal_name or '',
|
|
'signal_value': ev.signal_value,
|
|
'decision': ev.decision or '',
|
|
'created_at': ev.created_at,
|
|
})
|
|
|
|
return _json_response({
|
|
'items': items,
|
|
'total': total,
|
|
'page': page,
|
|
'size': size,
|
|
})
|
|
|
|
except Exception as e:
|
|
_logger.exception('student signals failed')
|
|
return _error_response(str(e), 500)
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/adaptive/student/<int:student_id>/ability
|
|
# ------------------------------------------------------------------
|
|
@http.route('/api/adaptive/student/<int:student_id>/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/<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
|
|
# ------------------------------------------------------------------
|
|
@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)
|