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',
|
'category': 'Education',
|
||||||
'summary': 'Proficiency tracking, learning plans, diagnostics, content cache',
|
'summary': 'Proficiency tracking, learning plans, diagnostics, content cache',
|
||||||
'author': 'EnCoach',
|
'author': 'EnCoach',
|
||||||
'depends': ['encoach_core', 'encoach_taxonomy'],
|
'depends': ['encoach_core', 'encoach_taxonomy', 'mail'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
|
'data/ir_cron.xml',
|
||||||
'views/adaptive_event_views.xml',
|
'views/adaptive_event_views.xml',
|
||||||
'views/adaptive_path_views.xml',
|
'views/adaptive_path_views.xml',
|
||||||
'views/adaptive_settings_views.xml',
|
'views/adaptive_settings_views.xml',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from odoo.http import request
|
|||||||
from odoo.addons.encoach_api.controllers.base import (
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -163,6 +164,54 @@ class EncoachAdaptiveController(http.Controller):
|
|||||||
_logger.exception('student signals failed')
|
_logger.exception('student signals failed')
|
||||||
return _error_response(str(e), 500)
|
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
|
# 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):
|
class EncoachAdaptiveSettings(models.Model):
|
||||||
@@ -13,3 +13,8 @@ class EncoachAdaptiveSettings(models.Model):
|
|||||||
module_skip_threshold = fields.Float(default=0.95)
|
module_skip_threshold = fields.Float(default=0.95)
|
||||||
no_progress_alert_days = fields.Integer(default=3)
|
no_progress_alert_days = fields.Integer(default=3)
|
||||||
max_retries = 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 .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)
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
'views/ai_generation_log_views.xml',
|
'views/ai_generation_log_views.xml',
|
||||||
'views/ai_ielts_log_views.xml',
|
'views/ai_ielts_log_views.xml',
|
||||||
'views/ai_course_menus.xml',
|
'views/ai_course_menus.xml',
|
||||||
|
'views/ai_review_views.xml',
|
||||||
|
'views/ielts_review_views.xml',
|
||||||
],
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
'application': False,
|
'application': False,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from odoo import http
|
from odoo import http, fields
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
from odoo.addons.encoach_api.controllers.base import (
|
from odoo.addons.encoach_api.controllers.base import (
|
||||||
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
jwt_required, _json_response, _error_response, _get_json_body, _paginate
|
||||||
@@ -262,3 +262,135 @@ class EncoachAiCourseController(http.Controller):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.exception('validation check failed')
|
_logger.exception('validation check failed')
|
||||||
return _error_response(str(e), 500)
|
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/<int:resource_id>
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/review/<int:resource_id>', 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/<int:log_id>
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@http.route('/api/ai-course/ielts-review/<int:log_id>', 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)
|
||||||
|
|||||||
@@ -24,3 +24,12 @@ class EncoachAiIeltsGenerationLog(models.Model):
|
|||||||
], default='generating', required=True)
|
], default='generating', required=True)
|
||||||
attempts = fields.Integer(default=0)
|
attempts = fields.Integer(default=0)
|
||||||
created_at = fields.Datetime(default=fields.Datetime.now)
|
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()
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
OpenAIService = None
|
OpenAIService = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
|
||||||
|
except ImportError:
|
||||||
|
ContentSourceGate = None
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -56,13 +61,20 @@ class EnglishPipeline:
|
|||||||
})
|
})
|
||||||
return {'error': str(e)}
|
return {'error': str(e)}
|
||||||
|
|
||||||
env['encoach.ai.generation.log'].create({
|
resource = env['encoach.ai.generation.log'].create({
|
||||||
'course_type': 'general_english',
|
'course_type': 'general_english',
|
||||||
'brief': json.dumps(gap_profile),
|
'brief': json.dumps(gap_profile),
|
||||||
'status': 'quality_check',
|
'status': 'quality_check',
|
||||||
'attempts': 1,
|
'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
|
return content
|
||||||
|
|
||||||
def quality_check(self, env, content, cefr_level):
|
def quality_check(self, env, content, cefr_level):
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ try:
|
|||||||
except ImportError:
|
except ImportError:
|
||||||
OpenAIService = None
|
OpenAIService = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from odoo.addons.encoach_quality_gate.services.content_gate import ContentSourceGate
|
||||||
|
except ImportError:
|
||||||
|
ContentSourceGate = None
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SKILL_PROMPTS = {
|
SKILL_PROMPTS = {
|
||||||
@@ -71,13 +76,20 @@ class IeltsPipeline:
|
|||||||
_logger.error("IELTS %s content generation failed: %s", skill, e)
|
_logger.error("IELTS %s content generation failed: %s", skill, e)
|
||||||
return {'error': str(e)}
|
return {'error': str(e)}
|
||||||
|
|
||||||
env['encoach.ai.ielts.generation.log'].create({
|
resource = env['encoach.ai.ielts.generation.log'].create({
|
||||||
'skill': skill,
|
'skill': skill,
|
||||||
'brief': brief_text,
|
'brief': brief_text,
|
||||||
'status': 'format_check',
|
'status': 'format_check',
|
||||||
'attempts': 1,
|
'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
|
return content
|
||||||
|
|
||||||
def format_check(self, env, content, skill):
|
def format_check(self, env, content, skill):
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_ai_review_queue_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.resource.ai.review.list</field>
|
||||||
|
<field name="model">encoach.resource</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="AI Content Review Queue">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="type"/>
|
||||||
|
<field name="cefr_level"/>
|
||||||
|
<field name="grammar_topic"/>
|
||||||
|
<field name="review_status" widget="badge" decoration-info="review_status == 'pending'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'"/>
|
||||||
|
<field name="create_date"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ai_review_queue" model="ir.actions.act_window">
|
||||||
|
<field name="name">AI Content Review</field>
|
||||||
|
<field name="res_model">encoach.resource</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="domain">[('ai_generated', '=', True)]</field>
|
||||||
|
<field name="context">{'search_default_pending_review': 1}</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_ielts_review_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.ai.ielts.generation.log.review.form</field>
|
||||||
|
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="IELTS Content Review">
|
||||||
|
<header>
|
||||||
|
<field name="review_status" widget="statusbar" statusbar_visible="draft,pending_review,approved"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="skill" readonly="1"/>
|
||||||
|
<field name="review_status"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="examiner_id"/>
|
||||||
|
<field name="reviewed_at"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Examiner Notes">
|
||||||
|
<field name="examiner_notes" nolabel="1"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_ielts_review_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.ai.ielts.generation.log.review.list</field>
|
||||||
|
<field name="model">encoach.ai.ielts.generation.log</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="IELTS Content Review Queue">
|
||||||
|
<field name="skill"/>
|
||||||
|
<field name="review_status" widget="badge" decoration-info="review_status == 'pending_review'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'" decoration-warning="review_status == 'revision_needed'"/>
|
||||||
|
<field name="examiner_id"/>
|
||||||
|
<field name="reviewed_at"/>
|
||||||
|
<field name="create_date"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_ielts_review_queue" model="ir.actions.act_window">
|
||||||
|
<field name="name">IELTS Content Review</field>
|
||||||
|
<field name="res_model">encoach.ai.ielts.generation.log</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
<field name="domain">[('review_status', '\!=', 'draft')]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -5,10 +5,11 @@
|
|||||||
'summary': 'Gap analysis, auto course generation, and adaptive progression',
|
'summary': 'Gap analysis, auto course generation, and adaptive progression',
|
||||||
'author': 'EnCoach',
|
'author': 'EnCoach',
|
||||||
'license': 'LGPL-3',
|
'license': 'LGPL-3',
|
||||||
'depends': ['encoach_core', 'encoach_taxonomy'],
|
'depends': ['encoach_core', 'encoach_taxonomy', 'encoach_resources'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/gap_profile_views.xml',
|
'views/gap_profile_views.xml',
|
||||||
|
'views/course_section_views.xml',
|
||||||
'views/course_module_views.xml',
|
'views/course_module_views.xml',
|
||||||
'views/gap_analysis_action.xml',
|
'views/gap_analysis_action.xml',
|
||||||
'views/course_gen_menus.xml',
|
'views/course_gen_menus.xml',
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
from . import gap_profile
|
from . import gap_profile
|
||||||
from . import course_extension
|
from . import course_extension
|
||||||
|
from . import course_section
|
||||||
from . import course_module
|
from . import course_module
|
||||||
|
|||||||
@@ -33,3 +33,5 @@ class EncoachCourseModule(models.Model):
|
|||||||
('skipped', 'Skipped'),
|
('skipped', 'Skipped'),
|
||||||
], default='locked', required=True)
|
], default='locked', required=True)
|
||||||
sequence = fields.Integer(default=10)
|
sequence = fields.Integer(default=10)
|
||||||
|
section_id = fields.Many2one('encoach.course.section', ondelete='set null')
|
||||||
|
resource_ids = fields.Many2many('encoach.resource', string='Resources')
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
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_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_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
|
||||||
|
|||||||
|
@@ -0,0 +1,68 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_encoach_course_section_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.course.section.form</field>
|
||||||
|
<field name="model">encoach.course.section</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Course Section">
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="skill"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Modules">
|
||||||
|
<field name="module_ids">
|
||||||
|
<list>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="cefr_target"/>
|
||||||
|
<field name="status" widget="badge" decoration-info="status == 'available'" decoration-success="status == 'completed'" decoration-warning="status == 'in_progress'"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_course_section_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.course.section.list</field>
|
||||||
|
<field name="model">encoach.course.section</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Course Sections">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="skill" widget="badge"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_course_section_search" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.course.section.search</field>
|
||||||
|
<field name="model">encoach.course.section</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Sections">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
|
||||||
|
<filter string="Skill" name="group_skill" context="{'group_by': 'skill'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_encoach_course_section" model="ir.actions.act_window">
|
||||||
|
<field name="name">Course Sections</field>
|
||||||
|
<field name="res_model">encoach.course.section</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
'assets': {
|
'assets': {
|
||||||
'web.assets_frontend': [
|
'web.assets_frontend': [
|
||||||
'encoach_portal/static/src/css/portal.css',
|
'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/js/onboarding_wizard.js',
|
||||||
'encoach_portal/static/src/xml/onboarding_wizard.xml',
|
'encoach_portal/static/src/xml/onboarding_wizard.xml',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -44,6 +44,16 @@ class EncoachPortal(CustomerPortal):
|
|||||||
@http.route('/my/dashboard', type='http', auth='user', website=True)
|
@http.route('/my/dashboard', type='http', auth='user', website=True)
|
||||||
def student_dashboard(self, **kw):
|
def student_dashboard(self, **kw):
|
||||||
user = request.env.user
|
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(
|
profile = request.env['encoach.student.profile'].sudo().search(
|
||||||
[('user_id', '=', user.id)], limit=1,
|
[('user_id', '=', user.id)], limit=1,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
/** @odoo-module **/
|
/** @odoo-module **/
|
||||||
|
|
||||||
import { Component, useState, useRef, onMounted } from "@odoo/owl";
|
import { Component, useState, useRef, onMounted, xml } from "@odoo/owl";
|
||||||
import { jsonrpc } from "@web/core/network/rpc_service";
|
|
||||||
import { registry } from "@web/core/registry";
|
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 = [
|
const STEPS = [
|
||||||
{ key: "goal", title: "Learning Goal", icon: "fa-bullseye" },
|
{ key: "goal", title: "Learning Goal", icon: "fa-bullseye" },
|
||||||
{ key: "level", title: "Target Level", icon: "fa-signal" },
|
{ key: "level", title: "Target Level", icon: "fa-signal" },
|
||||||
{ key: "prefs", title: "Study Preferences", icon: "fa-sliders" },
|
{ 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: "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: "general", label: "General English", icon: "fa-comments", desc: "Improve everyday English" },
|
||||||
{ id: "academic", label: "Academic English", icon: "fa-graduation-cap", desc: "University-level 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" },
|
{ 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 = [
|
const LEVELS = [
|
||||||
{ id: "A1", label: "A1 – Beginner" },
|
{ id: "A1", label: "A1 \u2013 Beginner" },
|
||||||
{ id: "A2", label: "A2 – Elementary" },
|
{ id: "A2", label: "A2 \u2013 Elementary" },
|
||||||
{ id: "B1", label: "B1 – Intermediate" },
|
{ id: "B1", label: "B1 \u2013 Intermediate" },
|
||||||
{ id: "B2", label: "B2 – Upper Intermediate" },
|
{ id: "B2", label: "B2 \u2013 Upper Intermediate" },
|
||||||
{ id: "C1", label: "C1 – Advanced" },
|
{ id: "C1", label: "C1 \u2013 Advanced" },
|
||||||
{ id: "C2", label: "C2 – Proficiency" },
|
{ id: "C2", label: "C2 \u2013 Proficiency" },
|
||||||
{ id: "band_5", label: "IELTS Band 5.0" },
|
{ id: "band_5", label: "IELTS Band 5.0" },
|
||||||
{ id: "band_6", label: "IELTS Band 6.0" },
|
{ id: "band_6", label: "IELTS Band 6.0" },
|
||||||
{ id: "band_7", label: "IELTS Band 7.0+" },
|
{ id: "band_7", label: "IELTS Band 7.0+" },
|
||||||
@@ -38,13 +58,158 @@ const STUDY_PREFS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export class OnboardingWizard extends Component {
|
export class OnboardingWizard extends Component {
|
||||||
static template = "encoach_portal.OnboardingWizard";
|
static template = xml`
|
||||||
|
<div class="ec-wizard" t-ref="root">
|
||||||
|
<div class="ec-wizard-progress mb-4">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<t t-foreach="steps" t-as="step" t-key="step.key">
|
||||||
|
<div t-attf-class="ec-wizard-step text-center flex-fill #{ step_index <= state.currentStep ? 'active' : '' } #{ step_index < state.currentStep ? 'completed' : '' }">
|
||||||
|
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-1 #{ step_index <= state.currentStep ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:36px;height:36px;">
|
||||||
|
<i t-attf-class="fa #{ step_index < state.currentStep ? 'fa-check' : step.icon }"/>
|
||||||
|
</span>
|
||||||
|
<small class="d-block" t-esc="step.title"/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
<div class="progress rounded-pill" style="height:6px;">
|
||||||
|
<div class="progress-bar bg-primary rounded-pill" t-attf-style="width: #{ progressPercent }%;"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.currentStep === 0" class="ec-wizard-panel">
|
||||||
|
<h4 class="fw-bold mb-1 text-center">What would you like to learn?</h4>
|
||||||
|
<p class="text-muted text-center mb-4">Choose your primary learning goal</p>
|
||||||
|
<div class="row g-3">
|
||||||
|
<t t-foreach="goals" t-as="goal" t-key="goal.id">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedGoal === goal.id ? 'border-primary shadow-sm' : 'border-light' }"
|
||||||
|
t-on-click="onClickGoal" t-att-data-id="goal.id" role="button">
|
||||||
|
<div class="card-body p-3 d-flex align-items-center">
|
||||||
|
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-3 me-3 #{ state.selectedGoal === goal.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:44px;height:44px;">
|
||||||
|
<i t-attf-class="fa #{ goal.icon }"/>
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h6 class="fw-bold mb-0" t-esc="goal.label"/>
|
||||||
|
<small class="text-muted" t-esc="goal.desc"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.currentStep === 1" class="ec-wizard-panel">
|
||||||
|
<h4 class="fw-bold mb-1 text-center">What level are you aiming for?</h4>
|
||||||
|
<p class="text-muted text-center mb-4">Select your target proficiency</p>
|
||||||
|
<div class="row g-2 justify-content-center">
|
||||||
|
<t t-foreach="levels" t-as="lvl" t-key="lvl.id">
|
||||||
|
<div class="col-sm-6 col-md-4">
|
||||||
|
<div t-attf-class="card border-2 rounded-3 ec-selectable-card #{ state.selectedLevel === lvl.id ? 'border-primary shadow-sm' : 'border-light' }"
|
||||||
|
t-on-click="onClickLevel" t-att-data-id="lvl.id" role="button">
|
||||||
|
<div class="card-body p-3 text-center">
|
||||||
|
<span class="fw-semibold" t-esc="lvl.label"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.currentStep === 2" class="ec-wizard-panel">
|
||||||
|
<h4 class="fw-bold mb-1 text-center">How do you prefer to study?</h4>
|
||||||
|
<p class="text-muted text-center mb-4">Pick the style that suits you best</p>
|
||||||
|
<div class="row g-3 justify-content-center">
|
||||||
|
<t t-foreach="studyPrefs" t-as="pref" t-key="pref.id">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div t-attf-class="card border-2 rounded-4 h-100 ec-selectable-card #{ state.selectedPref === pref.id ? 'border-primary shadow-sm' : 'border-light' }"
|
||||||
|
t-on-click="onClickPref" t-att-data-id="pref.id" role="button">
|
||||||
|
<div class="card-body p-4 text-center">
|
||||||
|
<span t-attf-class="d-inline-flex align-items-center justify-content-center rounded-circle mb-2 #{ state.selectedPref === pref.id ? 'bg-primary text-white' : 'bg-light text-muted' }" style="width:52px;height:52px;">
|
||||||
|
<i t-attf-class="fa fa-lg #{ pref.icon }"/>
|
||||||
|
</span>
|
||||||
|
<h6 class="fw-bold mb-1" t-esc="pref.label"/>
|
||||||
|
<small class="text-muted" t-esc="pref.desc"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
|
||||||
|
<h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
|
||||||
|
<p class="text-muted text-center mb-4">We recommend a quick placement test to personalise your learning path</p>
|
||||||
|
<div class="card border-0 bg-light rounded-4 mb-4">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-sm-4 text-center">
|
||||||
|
<small class="text-muted d-block mb-1">Goal</small>
|
||||||
|
<span class="fw-bold" t-esc="state.selectedGoal"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4 text-center">
|
||||||
|
<small class="text-muted d-block mb-1">Target Level</small>
|
||||||
|
<span class="fw-bold" t-esc="state.selectedLevel"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-4 text-center">
|
||||||
|
<small class="text-muted d-block mb-1">Study Style</small>
|
||||||
|
<span class="fw-bold" t-esc="state.selectedPref"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card border-2 border-primary rounded-4 mb-3">
|
||||||
|
<div class="card-body p-4 text-center">
|
||||||
|
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
|
||||||
|
<i class="fa fa-tasks fa-lg"/>
|
||||||
|
</span>
|
||||||
|
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
|
||||||
|
<p class="text-muted mb-0">20-30 minutes \u2014 covers Grammar, Vocabulary, Reading, and Speaking</p>
|
||||||
|
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<t t-if="state.error">
|
||||||
|
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between mt-4">
|
||||||
|
<button t-attf-class="btn btn-outline-secondary rounded-3 px-4 #{ isFirstStep ? 'invisible' : '' }"
|
||||||
|
t-on-click="prevStep">
|
||||||
|
<i class="fa fa-arrow-left me-2"/>Back
|
||||||
|
</button>
|
||||||
|
<t t-if="!isLastStep">
|
||||||
|
<button class="btn btn-primary rounded-3 px-4"
|
||||||
|
t-att-disabled="!canProceed"
|
||||||
|
t-on-click="nextStep">
|
||||||
|
Next<i class="fa fa-arrow-right ms-2"/>
|
||||||
|
</button>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-secondary rounded-3 px-4"
|
||||||
|
t-att-disabled="state.saving"
|
||||||
|
t-on-click="skipPlacement">
|
||||||
|
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/></t>
|
||||||
|
<t t-else="">Skip for now</t>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary rounded-3 px-4"
|
||||||
|
t-att-disabled="state.saving"
|
||||||
|
t-on-click="takePlacement">
|
||||||
|
<t t-if="state.saving"><span class="spinner-border spinner-border-sm me-2"/>Saving\u2026</t>
|
||||||
|
<t t-else=""><i class="fa fa-play me-2"/>Take Placement Test</t>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
static props = {};
|
static props = {};
|
||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
this.rootRef = useRef("root");
|
this.rootRef = useRef("root");
|
||||||
this.steps = STEPS;
|
this.steps = STEPS;
|
||||||
this.goals = GOALS;
|
this.goals = FALLBACK_GOALS;
|
||||||
this.levels = LEVELS;
|
this.levels = LEVELS;
|
||||||
this.studyPrefs = STUDY_PREFS;
|
this.studyPrefs = STUDY_PREFS;
|
||||||
|
|
||||||
@@ -55,37 +220,46 @@ export class OnboardingWizard extends Component {
|
|||||||
selectedPref: null,
|
selectedPref: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
goalsLoaded: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
const el = document.getElementById("ec_onboarding_wizard");
|
const el = document.getElementById("ec_onboarding_wizard");
|
||||||
if (el) {
|
if (el) {
|
||||||
this.userId = el.dataset.userId;
|
this.userId = el.dataset.userId;
|
||||||
this.profileId = el.dataset.profileId;
|
this.profileId = el.dataset.profileId;
|
||||||
}
|
}
|
||||||
|
await this._loadGoals();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get isFirstStep() {
|
async _loadGoals() {
|
||||||
return this.state.currentStep === 0;
|
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() {
|
get isFirstStep() { return this.state.currentStep === 0; }
|
||||||
return this.state.currentStep === STEPS.length - 1;
|
get isLastStep() { return this.state.currentStep === STEPS.length - 1; }
|
||||||
}
|
|
||||||
|
|
||||||
get canProceed() {
|
get canProceed() {
|
||||||
switch (this.state.currentStep) {
|
switch (this.state.currentStep) {
|
||||||
case 0:
|
case 0: return !!this.state.selectedGoal;
|
||||||
return !!this.state.selectedGoal;
|
case 1: return !!this.state.selectedLevel;
|
||||||
case 1:
|
case 2: return !!this.state.selectedPref;
|
||||||
return !!this.state.selectedLevel;
|
case 3: return true;
|
||||||
case 2:
|
default: return false;
|
||||||
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);
|
return Math.round(((this.state.currentStep + 1) / STEPS.length) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
selectGoal(goalId) {
|
onClickGoal(ev) {
|
||||||
this.state.selectedGoal = goalId;
|
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) {
|
nextStep() { if (this.canProceed && !this.isLastStep) this.state.currentStep++; }
|
||||||
this.state.selectedLevel = levelId;
|
prevStep() { if (!this.isFirstStep) this.state.currentStep--; }
|
||||||
}
|
|
||||||
|
|
||||||
selectPref(prefId) {
|
async submit(skipPlacement) {
|
||||||
this.state.selectedPref = prefId;
|
|
||||||
}
|
|
||||||
|
|
||||||
nextStep() {
|
|
||||||
if (this.canProceed && !this.isLastStep) {
|
|
||||||
this.state.currentStep++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
prevStep() {
|
|
||||||
if (!this.isFirstStep) {
|
|
||||||
this.state.currentStep--;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async submit() {
|
|
||||||
this.state.saving = true;
|
this.state.saving = true;
|
||||||
this.state.error = null;
|
this.state.error = null;
|
||||||
try {
|
try {
|
||||||
await jsonrpc("/encoach/onboarding/save", {
|
await rpc("/encoach/onboarding/save", {
|
||||||
goal: this.state.selectedGoal,
|
goal: this.state.selectedGoal,
|
||||||
target_level: this.state.selectedLevel,
|
target_level: this.state.selectedLevel,
|
||||||
study_preference: this.state.selectedPref,
|
study_preference: this.state.selectedPref,
|
||||||
|
skip_placement: !!skipPlacement,
|
||||||
});
|
});
|
||||||
window.location.href = "/my/dashboard";
|
window.location.href = skipPlacement ? "/my/dashboard" : "/my/placement";
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.state.error = "Something went wrong. Please try again.";
|
this.state.error = "Something went wrong. Please try again.";
|
||||||
this.state.saving = false;
|
this.state.saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
takePlacement() { this.submit(false); }
|
||||||
|
skipPlacement() { this.submit(true); }
|
||||||
}
|
}
|
||||||
|
|
||||||
function mountOnboardingWizard() {
|
function mountOnboardingWizard() {
|
||||||
const target = document.getElementById("ec_onboarding_wizard");
|
const target = document.getElementById("ec_onboarding_wizard");
|
||||||
if (!target) {
|
if (!target) return;
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { mount } = owl;
|
const { mount } = owl;
|
||||||
mount(OnboardingWizard, target);
|
mount(OnboardingWizard, target);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -85,11 +85,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Step 4: Confirmation -->
|
<!-- Step 4: Placement Test -->
|
||||||
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
|
<div t-if="state.currentStep === 3" class="ec-wizard-panel">
|
||||||
<h4 class="fw-bold mb-1 text-center">Confirm Your Choices</h4>
|
<h4 class="fw-bold mb-1 text-center">Take Your Placement Test</h4>
|
||||||
<p class="text-muted text-center mb-4">Review before we set up your learning path</p>
|
<p class="text-muted text-center mb-4">We recommend a quick placement test to personalise your learning path</p>
|
||||||
<div class="card border-0 bg-light rounded-4">
|
|
||||||
|
<div class="card border-0 bg-light rounded-4 mb-4">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-sm-4 text-center">
|
<div class="col-sm-4 text-center">
|
||||||
@@ -107,6 +108,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card border-2 border-primary rounded-4 mb-3">
|
||||||
|
<div class="card-body p-4 text-center">
|
||||||
|
<span class="d-inline-flex align-items-center justify-content-center rounded-circle bg-primary bg-opacity-10 text-primary mb-3" style="width:56px;height:56px;">
|
||||||
|
<i class="fa fa-tasks fa-lg"/>
|
||||||
|
</span>
|
||||||
|
<h5 class="fw-bold mb-2">Adaptive Placement Test</h5>
|
||||||
|
<p class="text-muted mb-0">20-30 minutes — covers Grammar, Vocabulary, Reading, and Speaking</p>
|
||||||
|
<p class="text-muted">Your results will unlock a personalised course tailored to your level</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<t t-if="state.error">
|
<t t-if="state.error">
|
||||||
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
|
<div class="alert alert-danger rounded-3 mt-3" t-esc="state.error"/>
|
||||||
</t>
|
</t>
|
||||||
@@ -126,16 +139,26 @@
|
|||||||
</button>
|
</button>
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-outline-secondary rounded-3 px-4"
|
||||||
|
t-att-disabled="state.saving"
|
||||||
|
t-on-click="skipPlacement">
|
||||||
|
<t t-if="state.saving">
|
||||||
|
<span class="spinner-border spinner-border-sm me-2"/>
|
||||||
|
</t>
|
||||||
|
<t t-else="">Skip for now</t>
|
||||||
|
</button>
|
||||||
<button class="btn btn-primary rounded-3 px-4"
|
<button class="btn btn-primary rounded-3 px-4"
|
||||||
t-att-disabled="state.saving"
|
t-att-disabled="state.saving"
|
||||||
t-on-click="submit">
|
t-on-click="takePlacement">
|
||||||
<t t-if="state.saving">
|
<t t-if="state.saving">
|
||||||
<span class="spinner-border spinner-border-sm me-2"/>Saving…
|
<span class="spinner-border spinner-border-sm me-2"/>Saving…
|
||||||
</t>
|
</t>
|
||||||
<t t-else="">
|
<t t-else="">
|
||||||
<i class="fa fa-check me-2"/>Get Started
|
<i class="fa fa-play me-2"/>Take Placement Test
|
||||||
</t>
|
</t>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</t>
|
</t>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
from .quality_checker import QualityChecker
|
from .quality_checker import QualityChecker
|
||||||
|
from . import content_gate
|
||||||
|
|||||||
@@ -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
|
||||||
1
new_project/custom_addons/encoach_resources/__init__.py
Normal file
1
new_project/custom_addons/encoach_resources/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
from . import models
|
||||||
16
new_project/custom_addons/encoach_resources/__manifest__.py
Normal file
16
new_project/custom_addons/encoach_resources/__manifest__.py
Normal file
@@ -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,
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
from . import resource
|
||||||
@@ -37,6 +37,7 @@ class EncoachResource(models.Model):
|
|||||||
vocab_band = fields.Char(size=50)
|
vocab_band = fields.Char(size=50)
|
||||||
ai_generated = fields.Boolean(default=False)
|
ai_generated = fields.Boolean(default=False)
|
||||||
approved = fields.Boolean(default=False)
|
approved = fields.Boolean(default=False)
|
||||||
|
ielts_certified = fields.Boolean(default=False)
|
||||||
|
|
||||||
def to_api_dict(self):
|
def to_api_dict(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|||||||
@@ -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
|
||||||
|
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_encoach_resource_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.resource.form</field>
|
||||||
|
<field name="model">encoach.resource</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Resource">
|
||||||
|
<header>
|
||||||
|
<field name="review_status" widget="statusbar" statusbar_visible="pending,approved,rejected"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="type"/>
|
||||||
|
<field name="difficulty"/>
|
||||||
|
<field name="duration_minutes"/>
|
||||||
|
<field name="cefr_level"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="subject_id"/>
|
||||||
|
<field name="topic_ids" widget="many2many_tags"/>
|
||||||
|
<field name="grammar_topic"/>
|
||||||
|
<field name="vocab_band"/>
|
||||||
|
<field name="ai_generated"/>
|
||||||
|
<field name="approved"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
<group string="Content">
|
||||||
|
<field name="url"/>
|
||||||
|
<field name="file" filename="name"/>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_resource_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.resource.list</field>
|
||||||
|
<field name="model">encoach.resource</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Resources">
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="type"/>
|
||||||
|
<field name="cefr_level"/>
|
||||||
|
<field name="difficulty"/>
|
||||||
|
<field name="duration_minutes"/>
|
||||||
|
<field name="review_status" widget="badge" decoration-info="review_status == 'pending'" decoration-success="review_status == 'approved'" decoration-danger="review_status == 'rejected'"/>
|
||||||
|
<field name="ai_generated"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_resource_search" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.resource.search</field>
|
||||||
|
<field name="model">encoach.resource</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Resources">
|
||||||
|
<field name="name"/>
|
||||||
|
<filter string="AI Generated" name="ai_generated" domain="[('ai_generated', '=', True)]"/>
|
||||||
|
<filter string="Pending Review" name="pending_review" domain="[('review_status', '=', 'pending')]"/>
|
||||||
|
<filter string="Approved" name="approved" domain="[('review_status', '=', 'approved')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Type" name="group_type" context="{'group_by': 'type'}"/>
|
||||||
|
<filter string="CEFR Level" name="group_cefr" context="{'group_by': 'cefr_level'}"/>
|
||||||
|
<filter string="Review Status" name="group_review" context="{'group_by': 'review_status'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_encoach_resource" model="ir.actions.act_window">
|
||||||
|
<field name="name">Resources</field>
|
||||||
|
<field name="res_model">encoach.resource</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -5,13 +5,14 @@
|
|||||||
'summary': 'Exam scoring, grading queue, feedback, and score release management',
|
'summary': 'Exam scoring, grading queue, feedback, and score release management',
|
||||||
'author': 'EnCoach',
|
'author': 'EnCoach',
|
||||||
'license': 'LGPL-3',
|
'license': 'LGPL-3',
|
||||||
'depends': ['encoach_core', 'encoach_exam_template'],
|
'depends': ['encoach_core', 'encoach_exam_template', 'encoach_course_gen', 'encoach_resources'],
|
||||||
'data': [
|
'data': [
|
||||||
'security/ir.model.access.csv',
|
'security/ir.model.access.csv',
|
||||||
'views/student_attempt_views.xml',
|
'views/student_attempt_views.xml',
|
||||||
'views/student_answer_views.xml',
|
'views/student_answer_views.xml',
|
||||||
'views/score_views.xml',
|
'views/score_views.xml',
|
||||||
'views/feedback_views.xml',
|
'views/feedback_views.xml',
|
||||||
|
'views/student_progress_views.xml',
|
||||||
'views/scoring_menus.xml',
|
'views/scoring_menus.xml',
|
||||||
],
|
],
|
||||||
'installable': True,
|
'installable': True,
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ from . import student_attempt
|
|||||||
from . import student_answer
|
from . import student_answer
|
||||||
from . import score
|
from . import score
|
||||||
from . import feedback
|
from . import feedback
|
||||||
|
from . import student_progress
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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_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_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_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
|
||||||
|
|||||||
|
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<odoo>
|
||||||
|
|
||||||
|
<record id="view_encoach_student_progress_form" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.student.progress.form</field>
|
||||||
|
<field name="model">encoach.student.progress</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Student Progress">
|
||||||
|
<header>
|
||||||
|
<field name="status" widget="statusbar" statusbar_visible="not_started,in_progress,completed"/>
|
||||||
|
</header>
|
||||||
|
<sheet>
|
||||||
|
<group>
|
||||||
|
<group>
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="module_id"/>
|
||||||
|
<field name="resource_id"/>
|
||||||
|
</group>
|
||||||
|
<group>
|
||||||
|
<field name="score"/>
|
||||||
|
<field name="time_spent_minutes"/>
|
||||||
|
<field name="completed_at"/>
|
||||||
|
<field name="entity_id"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_student_progress_list" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.student.progress.list</field>
|
||||||
|
<field name="model">encoach.student.progress</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<list string="Student Progress">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<field name="module_id"/>
|
||||||
|
<field name="resource_id"/>
|
||||||
|
<field name="status" widget="badge" decoration-info="status == 'in_progress'" decoration-success="status == 'completed'" decoration-warning="status == 'skipped'"/>
|
||||||
|
<field name="score"/>
|
||||||
|
<field name="time_spent_minutes"/>
|
||||||
|
<field name="completed_at"/>
|
||||||
|
</list>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="view_encoach_student_progress_search" model="ir.ui.view">
|
||||||
|
<field name="name">encoach.student.progress.search</field>
|
||||||
|
<field name="model">encoach.student.progress</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<search string="Search Progress">
|
||||||
|
<field name="student_id"/>
|
||||||
|
<field name="course_id"/>
|
||||||
|
<filter string="Completed" name="completed" domain="[('status', '=', 'completed')]"/>
|
||||||
|
<filter string="In Progress" name="in_progress" domain="[('status', '=', 'in_progress')]"/>
|
||||||
|
<separator/>
|
||||||
|
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||||
|
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
|
||||||
|
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
|
||||||
|
</search>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="action_encoach_student_progress" model="ir.actions.act_window">
|
||||||
|
<field name="name">Student Progress</field>
|
||||||
|
<field name="res_model">encoach.student.progress</field>
|
||||||
|
<field name="view_mode">list,form</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
</odoo>
|
||||||
@@ -198,15 +198,38 @@ class EncoachSignupController(http.Controller):
|
|||||||
@jwt_required
|
@jwt_required
|
||||||
def onboarding_goals(self, **kw):
|
def onboarding_goals(self, **kw):
|
||||||
try:
|
try:
|
||||||
|
# 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 = [
|
goals = [
|
||||||
{'id': 'ielts', 'name': 'IELTS Preparation',
|
{'id': 'ielts', 'name': 'IELTS Preparation', 'icon': 'fa-language'},
|
||||||
'icon': 'mdi-book-education'},
|
{'id': 'general_english', 'name': 'General English', 'icon': 'fa-comments'},
|
||||||
{'id': 'general_english', 'name': 'General English',
|
{'id': 'math', 'name': 'Mathematics', 'icon': 'fa-calculator'},
|
||||||
'icon': 'mdi-translate'},
|
{'id': 'it', 'name': 'Information Technology', 'icon': 'fa-laptop'},
|
||||||
{'id': 'math', 'name': 'Mathematics',
|
|
||||||
'icon': 'mdi-calculator-variant'},
|
|
||||||
{'id': 'it', 'name': 'Information Technology',
|
|
||||||
'icon': 'mdi-laptop'},
|
|
||||||
]
|
]
|
||||||
return _json_response({'goals': goals})
|
return _json_response({'goals': goals})
|
||||||
|
|
||||||
@@ -229,6 +252,8 @@ class EncoachSignupController(http.Controller):
|
|||||||
if not learning_goal:
|
if not learning_goal:
|
||||||
return _error_response('learning_goal is required', 400)
|
return _error_response('learning_goal is required', 400)
|
||||||
|
|
||||||
|
skip_placement = body.get('skip_placement', False)
|
||||||
|
|
||||||
profile_vals = {
|
profile_vals = {
|
||||||
'user_id': user.id,
|
'user_id': user.id,
|
||||||
'learning_goal': learning_goal,
|
'learning_goal': learning_goal,
|
||||||
@@ -238,6 +263,10 @@ class EncoachSignupController(http.Controller):
|
|||||||
'exam_date': body.get('exam_date'),
|
'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 = request.env['encoach.student.profile'].sudo()
|
||||||
profile = Profile.search([('user_id', '=', user.id)], limit=1)
|
profile = Profile.search([('user_id', '=', user.id)], limit=1)
|
||||||
if profile:
|
if profile:
|
||||||
@@ -253,6 +282,7 @@ class EncoachSignupController(http.Controller):
|
|||||||
return _json_response({
|
return _json_response({
|
||||||
'message': 'Onboarding complete',
|
'message': 'Onboarding complete',
|
||||||
'profile_id': profile.id,
|
'profile_id': profile.id,
|
||||||
|
'skip_placement': skip_placement,
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Reference in New Issue
Block a user