feat: complete all 12 missing spec features from EnCoach v3.0 audit
- Add student_progress, course_section models and module_resources M2M - Activate encoach_resources module with security, views, and ielts_certified field - Add password strength indicator JS on registration page - Rewrite onboarding wizard step 4 as placement test prompt with skip option - Dynamic goal list fetched from exam templates in DB with fallback - B1 default CEFR level assigned when placement test is skipped - Teacher review dashboard with 2 API endpoints for AI content approval - IELTS examiner review interface with review_status, examiner_notes fields - Content source gate: mandatory review for AI, spot-check for certified - Resource-to-learning-style matching in adaptive engine - Daily cron job for teacher no-progress alerts via mail.activity - Entity student placement redirect on dashboard access - Fix Odoo 19 compat: search view groups, ir.cron fields, OWL xml templates Made-with: Cursor
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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/<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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
14
new_project/custom_addons/encoach_adaptive/data/ir_cron.xml
Normal file
14
new_project/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>
|
||||
@@ -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)
|
||||
|
||||
@@ -1 +1,3 @@
|
||||
from .adaptive_engine import AdaptiveEngine
|
||||
from . import style_matcher
|
||||
from . import alert_service
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user