feat: initial backend codebase — EnCoach v3
Complete Odoo 19 backend with 25 custom addons: - encoach_core: user/entity/role management - encoach_api: REST API + JWT auth - encoach_ai: OpenAI integration, AI settings, generation - encoach_ai_course: AI-powered English & IELTS course generation - encoach_exam_template/session: exam creation, structures, sessions - encoach_scoring: AI auto-grading + manual approval - encoach_vector: pgvector RAG integration - encoach_adaptive: adaptive learning engine - encoach_placement: placement testing - encoach_taxonomy/resources: content taxonomy & resource management - Plus 14 more modules for courses, branding, portal, etc. Includes docs: user guide, generation report, developer workflow. Made-with: Cursor
This commit is contained in:
3
custom_addons/encoach_adaptive/__init__.py
Normal file
3
custom_addons/encoach_adaptive/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import services
|
||||
26
custom_addons/encoach_adaptive/__manifest__.py
Normal file
26
custom_addons/encoach_adaptive/__manifest__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
'name': 'EnCoach Adaptive Learning',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Proficiency tracking, learning plans, diagnostics, content cache',
|
||||
'author': 'EnCoach',
|
||||
'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',
|
||||
'views/signal_timeline_action.xml',
|
||||
'views/adaptive_menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'encoach_adaptive/static/src/js/signal_timeline.js',
|
||||
'encoach_adaptive/static/src/xml/signal_timeline.xml',
|
||||
'encoach_adaptive/static/src/css/signal_timeline.css',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
1
custom_addons/encoach_adaptive/controllers/__init__.py
Normal file
1
custom_addons/encoach_adaptive/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import adaptive
|
||||
332
custom_addons/encoach_adaptive/controllers/adaptive.py
Normal file
332
custom_addons/encoach_adaptive/controllers/adaptive.py
Normal file
@@ -0,0 +1,332 @@
|
||||
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)
|
||||
|
||||
total_students = len(Path.search([]).mapped('student_id'))
|
||||
active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c))
|
||||
|
||||
signals_today = Event.search_count([
|
||||
('event_type', '=', 'signal'),
|
||||
('created_at', '>=', today_start),
|
||||
])
|
||||
|
||||
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': 0.0,
|
||||
'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)
|
||||
14
custom_addons/encoach_adaptive/data/ir_cron.xml
Normal file
14
custom_addons/encoach_adaptive/data/ir_cron.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="ir_cron_adaptive_no_progress_alert" model="ir.cron">
|
||||
<field name="name">EnCoach: Check Student Progress Alerts</field>
|
||||
<field name="model_id" search="[('model', '=', 'encoach.adaptive.settings')]"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_check_no_progress()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
3
custom_addons/encoach_adaptive/models/__init__.py
Normal file
3
custom_addons/encoach_adaptive/models/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import adaptive_event
|
||||
from . import adaptive_path
|
||||
from . import adaptive_settings
|
||||
19
custom_addons/encoach_adaptive/models/adaptive_event.py
Normal file
19
custom_addons/encoach_adaptive/models/adaptive_event.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachAdaptiveEvent(models.Model):
|
||||
_name = 'encoach.adaptive.event'
|
||||
_description = 'Adaptive Learning Event'
|
||||
_order = 'created_at desc'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
course_id = fields.Many2one('op.course', ondelete='set null')
|
||||
event_type = fields.Selection([
|
||||
('signal', 'Signal'),
|
||||
('decision', 'Decision'),
|
||||
], required=True)
|
||||
signal_name = fields.Char(size=100)
|
||||
signal_value = fields.Float()
|
||||
decision = fields.Char(size=200)
|
||||
context = fields.Text()
|
||||
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||
16
custom_addons/encoach_adaptive/models/adaptive_path.py
Normal file
16
custom_addons/encoach_adaptive/models/adaptive_path.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachAdaptivePath(models.Model):
|
||||
_name = 'encoach.adaptive.path'
|
||||
_description = 'Adaptive Learning Path'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
course_id = fields.Many2one('op.course', ondelete='set null')
|
||||
module_queue = fields.Text()
|
||||
source = fields.Selection([
|
||||
('placement', 'Placement'),
|
||||
('exam', 'Exam'),
|
||||
('ai_generated', 'AI Generated'),
|
||||
])
|
||||
next_generation_brief = fields.Text()
|
||||
20
custom_addons/encoach_adaptive/models/adaptive_settings.py
Normal file
20
custom_addons/encoach_adaptive/models/adaptive_settings.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from odoo import api, models, fields
|
||||
|
||||
|
||||
class EncoachAdaptiveSettings(models.Model):
|
||||
_name = 'encoach.adaptive.settings'
|
||||
_description = 'Adaptive Engine Settings'
|
||||
|
||||
teacher_id = fields.Many2one('res.users', ondelete='cascade')
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='cascade')
|
||||
step_up_threshold = fields.Float(default=0.85)
|
||||
step_down_threshold = fields.Float(default=0.50)
|
||||
micro_lesson_trigger = fields.Integer(default=2)
|
||||
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)
|
||||
@@ -0,0 +1,4 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_adaptive_event_user,encoach.adaptive.event.user,model_encoach_adaptive_event,base.group_user,1,1,1,1
|
||||
access_adaptive_path_user,encoach.adaptive.path.user,model_encoach_adaptive_path,base.group_user,1,1,1,1
|
||||
access_adaptive_settings_user,encoach.adaptive.settings.user,model_encoach_adaptive_settings,base.group_user,1,1,1,1
|
||||
|
3
custom_addons/encoach_adaptive/services/__init__.py
Normal file
3
custom_addons/encoach_adaptive/services/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .adaptive_engine import AdaptiveEngine
|
||||
from . import style_matcher
|
||||
from . import alert_service
|
||||
149
custom_addons/encoach_adaptive/services/adaptive_engine.py
Normal file
149
custom_addons/encoach_adaptive/services/adaptive_engine.py
Normal file
@@ -0,0 +1,149 @@
|
||||
import logging
|
||||
import json
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdaptiveEngine:
|
||||
"""4-phase adaptive learning engine.
|
||||
|
||||
Phase 1: Module-level up/down stepping
|
||||
Phase 2: Micro-lesson injection
|
||||
Phase 3: Module skipping
|
||||
Phase 4: No-progress alerts
|
||||
"""
|
||||
|
||||
DEFAULT_SETTINGS = {
|
||||
'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,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_settings(env, teacher_id=None, entity_id=None):
|
||||
"""Get adaptive settings for teacher/entity or defaults."""
|
||||
Settings = env['encoach.adaptive.settings'].sudo()
|
||||
settings = None
|
||||
if teacher_id:
|
||||
settings = Settings.search([('teacher_id', '=', teacher_id)], limit=1)
|
||||
if not settings and entity_id:
|
||||
settings = Settings.search([('entity_id', '=', entity_id), ('teacher_id', '=', False)], limit=1)
|
||||
|
||||
if settings:
|
||||
return {
|
||||
'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,
|
||||
}
|
||||
return dict(AdaptiveEngine.DEFAULT_SETTINGS)
|
||||
|
||||
@staticmethod
|
||||
def process_checkpoint(env, student_id, course_id, module_id, score, settings=None):
|
||||
"""Process a module checkpoint and make adaptive decisions."""
|
||||
if not settings:
|
||||
settings = AdaptiveEngine.DEFAULT_SETTINGS
|
||||
|
||||
Event = env['encoach.adaptive.event'].sudo()
|
||||
Module = env['encoach.course.module'].sudo()
|
||||
module = Module.browse(module_id)
|
||||
|
||||
decision = None
|
||||
signals = []
|
||||
|
||||
signals.append({
|
||||
'signal_name': 'checkpoint_score',
|
||||
'signal_value': score,
|
||||
})
|
||||
|
||||
if score >= settings['step_up_threshold']:
|
||||
decision = 'step_up'
|
||||
module.write({'status': 'completed'})
|
||||
next_module = Module.search([
|
||||
('course_id', '=', course_id),
|
||||
('sequence', '>', module.sequence),
|
||||
('status', '=', 'locked'),
|
||||
], limit=1, order='sequence')
|
||||
if next_module:
|
||||
next_module.write({'status': 'available'})
|
||||
|
||||
elif score < settings['step_down_threshold']:
|
||||
decision = 'step_down'
|
||||
|
||||
else:
|
||||
decision = 'continue'
|
||||
module.write({'status': 'completed'})
|
||||
next_module = Module.search([
|
||||
('course_id', '=', course_id),
|
||||
('sequence', '>', module.sequence),
|
||||
('status', '=', 'locked'),
|
||||
], limit=1, order='sequence')
|
||||
if next_module:
|
||||
next_module.write({'status': 'available'})
|
||||
|
||||
if score >= settings['module_skip_threshold']:
|
||||
skip_modules = Module.search([
|
||||
('course_id', '=', course_id),
|
||||
('sequence', '>', module.sequence),
|
||||
('status', '=', 'locked'),
|
||||
], limit=2, order='sequence')
|
||||
for sm in skip_modules:
|
||||
sm.write({'status': 'skipped'})
|
||||
if skip_modules:
|
||||
decision = 'skip_ahead'
|
||||
signals.append({'signal_name': 'module_skip', 'signal_value': len(skip_modules)})
|
||||
|
||||
for sig in signals:
|
||||
Event.create({
|
||||
'student_id': student_id,
|
||||
'course_id': course_id,
|
||||
'event_type': 'signal',
|
||||
'signal_name': sig['signal_name'],
|
||||
'signal_value': sig['signal_value'],
|
||||
})
|
||||
|
||||
Event.create({
|
||||
'student_id': student_id,
|
||||
'course_id': course_id,
|
||||
'event_type': 'decision',
|
||||
'decision': decision,
|
||||
'context': json.dumps({'module_id': module_id, 'score': score}),
|
||||
})
|
||||
|
||||
return {
|
||||
'decision': decision,
|
||||
'score': score,
|
||||
'signals': signals,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def check_no_progress(env, student_id, course_id, settings=None):
|
||||
"""Phase 4: Check if student has stalled."""
|
||||
if not settings:
|
||||
settings = AdaptiveEngine.DEFAULT_SETTINGS
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = datetime.now() - timedelta(days=settings['no_progress_alert_days'])
|
||||
|
||||
Event = env['encoach.adaptive.event'].sudo()
|
||||
recent_events = Event.search_count([
|
||||
('student_id', '=', student_id),
|
||||
('course_id', '=', course_id),
|
||||
('created_at', '>=', cutoff.strftime('%Y-%m-%d %H:%M:%S')),
|
||||
])
|
||||
|
||||
if recent_events == 0:
|
||||
Event.create({
|
||||
'student_id': student_id,
|
||||
'course_id': course_id,
|
||||
'event_type': 'signal',
|
||||
'signal_name': 'no_progress_alert',
|
||||
'signal_value': settings['no_progress_alert_days'],
|
||||
})
|
||||
return True
|
||||
return False
|
||||
67
custom_addons/encoach_adaptive/services/alert_service.py
Normal file
67
custom_addons/encoach_adaptive/services/alert_service.py
Normal 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)
|
||||
35
custom_addons/encoach_adaptive/services/style_matcher.py
Normal file
35
custom_addons/encoach_adaptive/services/style_matcher.py
Normal 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)
|
||||
@@ -0,0 +1,54 @@
|
||||
.o_signal_timeline .st-timeline {
|
||||
position: relative;
|
||||
padding-left: 48px;
|
||||
}
|
||||
.o_signal_timeline .st-timeline::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 19px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #dee2e6;
|
||||
}
|
||||
.o_signal_timeline .st-timeline-item {
|
||||
position: relative;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.o_signal_timeline .st-timeline-marker {
|
||||
position: absolute;
|
||||
left: -48px;
|
||||
top: 8px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
z-index: 1;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.o_signal_timeline .st-timeline-content {
|
||||
padding-left: 4px;
|
||||
}
|
||||
.o_signal_timeline .st-timeline-content .card {
|
||||
border-radius: 0.5rem;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.o_signal_timeline .st-timeline-content .card:hover {
|
||||
box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.08) !important;
|
||||
}
|
||||
.o_signal_timeline .st-student-card {
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_signal_timeline .st-student-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
.o_signal_timeline .cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
152
custom_addons/encoach_adaptive/static/src/js/signal_timeline.js
Normal file
152
custom_addons/encoach_adaptive/static/src/js/signal_timeline.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Layout } from "@web/search/layout";
|
||||
|
||||
class SignalTimeline extends Component {
|
||||
static template = "encoach_adaptive.SignalTimeline";
|
||||
static components = { Layout };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
studentId: null,
|
||||
student: null,
|
||||
events: [],
|
||||
filteredEvents: [],
|
||||
filters: { eventType: "", dateFrom: "", dateTo: "" },
|
||||
totalCount: 0,
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
const ctx = this.props.action?.context || {};
|
||||
this.state.studentId = ctx.student_id || ctx.active_id || null;
|
||||
if (this.state.studentId) {
|
||||
await this.loadTimeline();
|
||||
} else {
|
||||
await this.loadStudentList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadStudentList() {
|
||||
try {
|
||||
this.state.studentList = await this.orm.searchRead(
|
||||
"res.users",
|
||||
[["account_source", "!=", false]],
|
||||
["name", "email"],
|
||||
{ limit: 50, order: "name" },
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to load students:", e);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async selectStudent(studentId) {
|
||||
this.state.studentId = studentId;
|
||||
this.state.loading = true;
|
||||
await this.loadTimeline();
|
||||
}
|
||||
|
||||
async loadTimeline() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const domain = [["student_id", "=", this.state.studentId]];
|
||||
if (this.state.filters.eventType) {
|
||||
domain.push(["event_type", "=", this.state.filters.eventType]);
|
||||
}
|
||||
if (this.state.filters.dateFrom) {
|
||||
domain.push(["created_at", ">=", this.state.filters.dateFrom]);
|
||||
}
|
||||
if (this.state.filters.dateTo) {
|
||||
domain.push(["created_at", "<=", this.state.filters.dateTo + " 23:59:59"]);
|
||||
}
|
||||
|
||||
const [events, students] = await Promise.all([
|
||||
this.orm.searchRead(
|
||||
"encoach.adaptive.event",
|
||||
domain,
|
||||
["student_id", "course_id", "event_type", "signal_name", "signal_value", "decision", "context", "created_at"],
|
||||
{ limit: 100, order: "created_at desc" },
|
||||
),
|
||||
this.orm.searchRead(
|
||||
"res.users",
|
||||
[["id", "=", this.state.studentId]],
|
||||
["name", "email"],
|
||||
),
|
||||
]);
|
||||
|
||||
this.state.student = students.length ? students[0] : null;
|
||||
this.state.events = events;
|
||||
this.state.filteredEvents = events;
|
||||
this.state.totalCount = events.length;
|
||||
} catch (e) {
|
||||
console.error("Timeline load error:", e);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onFilterChange(field, ev) {
|
||||
this.state.filters[field] = ev.target.value;
|
||||
if (this.state.studentId) {
|
||||
this.loadTimeline();
|
||||
}
|
||||
}
|
||||
|
||||
clearFilters() {
|
||||
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
|
||||
if (this.state.studentId) {
|
||||
this.loadTimeline();
|
||||
}
|
||||
}
|
||||
|
||||
eventIcon(eventType) {
|
||||
return eventType === "signal" ? "fa-bolt" : "fa-gavel";
|
||||
}
|
||||
|
||||
eventColor(eventType) {
|
||||
return eventType === "signal" ? "primary" : "success";
|
||||
}
|
||||
|
||||
formatDate(dt) {
|
||||
if (!dt) return "";
|
||||
return dt.replace("T", " ").substring(0, 19);
|
||||
}
|
||||
|
||||
parseContext(ctx) {
|
||||
if (!ctx) return null;
|
||||
try {
|
||||
return typeof ctx === "string" ? JSON.parse(ctx) : ctx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
get signalCount() {
|
||||
return this.state.filteredEvents.filter(e => e.event_type === "signal").length;
|
||||
}
|
||||
|
||||
get decisionCount() {
|
||||
return this.state.filteredEvents.filter(e => e.event_type === "decision").length;
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.state.studentId = null;
|
||||
this.state.student = null;
|
||||
this.state.events = [];
|
||||
this.state.filteredEvents = [];
|
||||
this.state.filters = { eventType: "", dateFrom: "", dateTo: "" };
|
||||
this.state.loading = true;
|
||||
this.loadStudentList();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("encoach_signal_timeline", SignalTimeline);
|
||||
@@ -0,0 +1,177 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="encoach_adaptive.SignalTimeline">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<div class="o_signal_timeline">
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||
<p class="mt-2 text-muted">Loading timeline...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Student Selector -->
|
||||
<t t-elif="!state.studentId and state.studentList">
|
||||
<h2 class="mb-3">Adaptive Signal Timeline</h2>
|
||||
<p class="text-muted mb-4">Select a student to view their adaptive learning events.</p>
|
||||
<div class="row g-3">
|
||||
<t t-foreach="state.studentList" t-as="st" t-key="st.id">
|
||||
<div class="col-lg-3 col-md-4 col-sm-6">
|
||||
<div class="card shadow-sm h-100 cursor-pointer st-student-card"
|
||||
t-on-click="() => this.selectStudent(st.id)">
|
||||
<div class="card-body text-center">
|
||||
<div class="rounded-circle bg-info bg-opacity-10 d-inline-flex align-items-center justify-content-center mb-2" style="width:48px;height:48px">
|
||||
<i class="fa fa-user text-info"/>
|
||||
</div>
|
||||
<h6 class="card-title mb-0" t-esc="st.name"/>
|
||||
<small class="text-muted" t-esc="st.email"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Timeline View -->
|
||||
<t t-elif="state.student">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
|
||||
t-if="!this.props.action?.context?.student_id">
|
||||
<i class="fa fa-arrow-left me-1"/> Back
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="mb-0">Adaptive Timeline</h2>
|
||||
<small class="text-muted">
|
||||
Student: <strong t-esc="state.student.name"/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">Event Type</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('eventType', ev)">
|
||||
<option value="">All Events</option>
|
||||
<option value="signal">Signals</option>
|
||||
<option value="decision">Decisions</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">From Date</label>
|
||||
<input type="date" class="form-control form-control-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('dateFrom', ev)"/>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">To Date</label>
|
||||
<input type="date" class="form-control form-control-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('dateTo', ev)"/>
|
||||
</div>
|
||||
<div class="col-md-3 text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="clearFilters">
|
||||
<i class="fa fa-refresh me-1"/> Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Badges -->
|
||||
<div class="d-flex gap-3 mb-4">
|
||||
<div class="bg-light rounded px-3 py-2 d-flex align-items-center">
|
||||
<i class="fa fa-list me-2 text-secondary"/>
|
||||
<strong class="me-1" t-esc="state.totalCount"/> events
|
||||
</div>
|
||||
<div class="bg-primary bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
|
||||
<i class="fa fa-bolt me-2 text-primary"/>
|
||||
<strong class="me-1" t-esc="signalCount"/> signals
|
||||
</div>
|
||||
<div class="bg-success bg-opacity-10 rounded px-3 py-2 d-flex align-items-center">
|
||||
<i class="fa fa-gavel me-2 text-success"/>
|
||||
<strong class="me-1" t-esc="decisionCount"/> decisions
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline -->
|
||||
<div class="st-timeline">
|
||||
<t t-foreach="state.filteredEvents" t-as="ev" t-key="ev.id">
|
||||
<div class="st-timeline-item">
|
||||
<div class="st-timeline-marker"
|
||||
t-att-class="'bg-' + eventColor(ev.event_type)">
|
||||
<i class="fa" t-att-class="eventIcon(ev.event_type)"/>
|
||||
</div>
|
||||
<div class="st-timeline-content">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body py-2 px-3">
|
||||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||||
<div>
|
||||
<span class="badge me-1"
|
||||
t-att-class="'bg-' + eventColor(ev.event_type)"
|
||||
t-esc="ev.event_type"/>
|
||||
<strong t-if="ev.signal_name" t-esc="ev.signal_name"/>
|
||||
<strong t-elif="ev.decision">Decision</strong>
|
||||
</div>
|
||||
<small class="text-muted" t-esc="formatDate(ev.created_at)"/>
|
||||
</div>
|
||||
|
||||
<!-- Signal details -->
|
||||
<div t-if="ev.event_type === 'signal'" class="mt-1">
|
||||
<span class="text-muted small">Value: </span>
|
||||
<span class="fw-bold" t-esc="ev.signal_value"/>
|
||||
<t t-if="ev.course_id">
|
||||
<span class="text-muted small ms-3">Course: </span>
|
||||
<span t-esc="ev.course_id[1]"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Decision details -->
|
||||
<div t-if="ev.event_type === 'decision'" class="mt-1">
|
||||
<span class="small" t-esc="ev.decision"/>
|
||||
<t t-if="ev.course_id">
|
||||
<span class="text-muted small ms-3">Course: </span>
|
||||
<span t-esc="ev.course_id[1]"/>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Context (if any) -->
|
||||
<div t-if="parseContext(ev.context)" class="mt-1">
|
||||
<small class="text-muted">
|
||||
<i class="fa fa-info-circle me-1"/>
|
||||
<t t-foreach="Object.entries(parseContext(ev.context) || {})" t-as="entry" t-key="entry[0]">
|
||||
<span class="me-2">
|
||||
<span class="fw-bold" t-esc="entry[0]"/>: <t t-esc="entry[1]"/>
|
||||
</span>
|
||||
</t>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="!state.filteredEvents.length">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fa fa-clock-o fa-3x mb-2 d-block"/>
|
||||
<p>No adaptive events found for this student.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-elif="!state.studentId">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fa fa-clock-o fa-3x mb-3 d-block"/>
|
||||
<p>No student selected. Open from a student record or select one above.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_adaptive_event_form" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.event.form</field>
|
||||
<field name="model">encoach.adaptive.event</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adaptive Event">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<field name="event_type"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="signal_name"/>
|
||||
<field name="signal_value"/>
|
||||
<field name="decision"/>
|
||||
<field name="created_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="context"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_adaptive_event_list" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.event.list</field>
|
||||
<field name="model">encoach.adaptive.event</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Adaptive Events">
|
||||
<field name="student_id"/>
|
||||
<field name="event_type" widget="badge"/>
|
||||
<field name="signal_name"/>
|
||||
<field name="signal_value"/>
|
||||
<field name="decision"/>
|
||||
<field name="created_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_adaptive_event_search" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.event.search</field>
|
||||
<field name="model">encoach.adaptive.event</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Adaptive Events">
|
||||
<field name="student_id"/>
|
||||
<separator/>
|
||||
<filter string="Signals" name="signal" domain="[('event_type', '=', 'signal')]"/>
|
||||
<filter string="Decisions" name="decision" domain="[('event_type', '=', 'decision')]"/>
|
||||
<separator/>
|
||||
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_adaptive_event" model="ir.actions.act_window">
|
||||
<field name="name">Adaptive Events</field>
|
||||
<field name="res_model">encoach.adaptive.event</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
22
custom_addons/encoach_adaptive/views/adaptive_menus.xml
Normal file
22
custom_addons/encoach_adaptive/views/adaptive_menus.xml
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_adaptive_events"
|
||||
name="Adaptive Events"
|
||||
parent="encoach_core.menu_encoach_config"
|
||||
action="encoach_adaptive.action_adaptive_event"
|
||||
sequence="60"/>
|
||||
|
||||
<menuitem id="menu_adaptive_paths"
|
||||
name="Adaptive Paths"
|
||||
parent="encoach_core.menu_encoach_config"
|
||||
action="encoach_adaptive.action_adaptive_path"
|
||||
sequence="70"/>
|
||||
|
||||
<menuitem id="menu_adaptive_settings"
|
||||
name="Adaptive Settings"
|
||||
parent="encoach_core.menu_encoach_config"
|
||||
action="encoach_adaptive.action_adaptive_settings"
|
||||
sequence="80"/>
|
||||
|
||||
</odoo>
|
||||
46
custom_addons/encoach_adaptive/views/adaptive_path_views.xml
Normal file
46
custom_addons/encoach_adaptive/views/adaptive_path_views.xml
Normal file
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_adaptive_path_form" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.path.form</field>
|
||||
<field name="model">encoach.adaptive.path</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adaptive Path">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<field name="source"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="module_queue"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="next_generation_brief"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_adaptive_path_list" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.path.list</field>
|
||||
<field name="model">encoach.adaptive.path</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Adaptive Paths">
|
||||
<field name="student_id"/>
|
||||
<field name="course_id"/>
|
||||
<field name="source"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_adaptive_path" model="ir.actions.act_window">
|
||||
<field name="name">Adaptive Paths</field>
|
||||
<field name="res_model">encoach.adaptive.path</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_adaptive_settings_form" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.settings.form</field>
|
||||
<field name="model">encoach.adaptive.settings</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Adaptive Settings">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="teacher_id"/>
|
||||
<field name="entity_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="step_up_threshold"/>
|
||||
<field name="step_down_threshold"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="micro_lesson_trigger"/>
|
||||
<field name="module_skip_threshold"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="no_progress_alert_days"/>
|
||||
<field name="max_retries"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_adaptive_settings_list" model="ir.ui.view">
|
||||
<field name="name">encoach.adaptive.settings.list</field>
|
||||
<field name="model">encoach.adaptive.settings</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Adaptive Settings">
|
||||
<field name="teacher_id"/>
|
||||
<field name="entity_id"/>
|
||||
<field name="step_up_threshold"/>
|
||||
<field name="step_down_threshold"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_adaptive_settings" model="ir.actions.act_window">
|
||||
<field name="name">Adaptive Settings</field>
|
||||
<field name="res_model">encoach.adaptive.settings</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_signal_timeline" model="ir.actions.client">
|
||||
<field name="name">Signal Timeline</field>
|
||||
<field name="tag">encoach_signal_timeline</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_signal_timeline"
|
||||
name="Signal Timeline"
|
||||
parent="encoach_core.menu_encoach_config"
|
||||
action="action_signal_timeline"
|
||||
sequence="55"/>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user