feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
This commit is contained in:
2
custom_addons/encoach_course_gen/__init__.py
Normal file
2
custom_addons/encoach_course_gen/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
27
custom_addons/encoach_course_gen/__manifest__.py
Normal file
27
custom_addons/encoach_course_gen/__manifest__.py
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
'name': 'EnCoach Course Generation',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Gap analysis, auto course generation, and adaptive progression',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_taxonomy', 'encoach_resources'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/gap_profile_views.xml',
|
||||
'views/course_section_views.xml',
|
||||
'views/course_module_views.xml',
|
||||
'views/gap_analysis_action.xml',
|
||||
'views/course_gen_menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'encoach_course_gen/static/src/js/gap_analysis.js',
|
||||
'encoach_course_gen/static/src/xml/gap_analysis.xml',
|
||||
'encoach_course_gen/static/src/css/gap_analysis.css',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
1
custom_addons/encoach_course_gen/controllers/__init__.py
Normal file
1
custom_addons/encoach_course_gen/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import course
|
||||
630
custom_addons/encoach_course_gen/controllers/course.py
Normal file
630
custom_addons/encoach_course_gen/controllers/course.py
Normal file
@@ -0,0 +1,630 @@
|
||||
import json
|
||||
import logging
|
||||
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
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
CEFR_ORDER = ['pre_a1', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2']
|
||||
|
||||
STEP_DOWN_THRESHOLD = 0.4
|
||||
PASS_THRESHOLD_DEFAULT = 0.7
|
||||
|
||||
|
||||
def _cefr_index(level):
|
||||
try:
|
||||
return CEFR_ORDER.index(level)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def _next_cefr(level):
|
||||
idx = _cefr_index(level)
|
||||
return CEFR_ORDER[min(idx + 1, len(CEFR_ORDER) - 1)]
|
||||
|
||||
|
||||
def _module_to_dict(mod):
|
||||
return {
|
||||
'id': mod.id,
|
||||
'name': mod.name,
|
||||
'cefr_target': mod.cefr_target or '',
|
||||
'auto_generated': mod.auto_generated,
|
||||
'completion_criteria': mod.completion_criteria or 'all_resources',
|
||||
'score_threshold': mod.score_threshold,
|
||||
'prerequisite_module_id': mod.prerequisite_module_id.id if mod.prerequisite_module_id else None,
|
||||
'status': mod.status,
|
||||
'sequence': mod.sequence,
|
||||
'generation_brief': mod.generation_brief or '',
|
||||
}
|
||||
|
||||
|
||||
def _course_to_dict(course, include_modules=True):
|
||||
data = {
|
||||
'id': course.id,
|
||||
'name': course.name,
|
||||
'code': course.code if hasattr(course, 'code') and course.code else '',
|
||||
'description': course.description if hasattr(course, 'description') else '',
|
||||
'generation_source': course.generation_source if hasattr(course, 'generation_source') else '',
|
||||
'progression_model': course.progression_model if hasattr(course, 'progression_model') else 'linear',
|
||||
'target_band': course.target_band if hasattr(course, 'target_band') else 0.0,
|
||||
'entity_id': course.entity_id.id if hasattr(course, 'entity_id') and course.entity_id else None,
|
||||
}
|
||||
if include_modules:
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
modules = Module.search([('course_id', '=', course.id)], order='sequence asc')
|
||||
data['modules'] = [_module_to_dict(m) for m in modules]
|
||||
return data
|
||||
|
||||
|
||||
class EncoachCourseController(http.Controller):
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/create
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/create', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def create(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
name = (body.get('name') or '').strip()
|
||||
if not name:
|
||||
return _error_response('name is required', 400)
|
||||
|
||||
Course = request.env['op.course'].sudo()
|
||||
course_vals = {
|
||||
'name': name,
|
||||
'code': body.get('code', name[:20].upper().replace(' ', '_')),
|
||||
}
|
||||
if body.get('subject_id'):
|
||||
course_vals['subject_id'] = int(body['subject_id'])
|
||||
if body.get('description') and hasattr(Course, 'description'):
|
||||
course_vals['description'] = body['description']
|
||||
if hasattr(Course, 'generation_source'):
|
||||
course_vals['generation_source'] = 'manual'
|
||||
if hasattr(Course, 'entity_id'):
|
||||
user = request.env.user.sudo()
|
||||
if user.entity_ids:
|
||||
course_vals['entity_id'] = user.entity_ids[0].id
|
||||
|
||||
course = Course.create(course_vals)
|
||||
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
modules_data = body.get('modules', [])
|
||||
for idx, mod_data in enumerate(modules_data):
|
||||
Module.create({
|
||||
'name': mod_data.get('name', f'Module {idx + 1}'),
|
||||
'course_id': course.id,
|
||||
'cefr_target': mod_data.get('cefr_target', ''),
|
||||
'completion_criteria': mod_data.get('completion_criteria', 'all_resources'),
|
||||
'score_threshold': mod_data.get('score_threshold', 0.0),
|
||||
'sequence': mod_data.get('sequence', (idx + 1) * 10),
|
||||
'status': 'available' if idx == 0 else 'locked',
|
||||
})
|
||||
|
||||
return _json_response(_course_to_dict(course), 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('course create failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/course/gap-analysis
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/gap-analysis', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def gap_analysis(self, **kw):
|
||||
try:
|
||||
uid = request.env.user.id
|
||||
GapProfile = request.env['encoach.gap.profile'].sudo()
|
||||
profile = GapProfile.search([
|
||||
('student_id', '=', uid),
|
||||
], limit=1, order='created_at desc')
|
||||
|
||||
if not profile:
|
||||
CatSession = request.env['encoach.cat.session'].sudo()
|
||||
session = CatSession.search([
|
||||
('student_id', '=', uid),
|
||||
('status', '=', 'completed'),
|
||||
], limit=1, order='completed_at desc')
|
||||
|
||||
if not session:
|
||||
return _error_response('No placement results or gap profile found', 404)
|
||||
|
||||
Ability = request.env['encoach.student.ability.model'].sudo()
|
||||
abilities = Ability.search([('student_id', '=', uid)])
|
||||
|
||||
skill_gaps = {}
|
||||
for ab in abilities:
|
||||
if ab.theta < 0.5:
|
||||
skill_gaps[ab.skill] = {
|
||||
'theta': ab.theta,
|
||||
'sem': ab.sem,
|
||||
'gap_severity': 'high' if ab.theta < -0.5 else 'medium' if ab.theta < 0.0 else 'low',
|
||||
}
|
||||
|
||||
Attempt = request.env['encoach.student.attempt'].sudo()
|
||||
placement_attempt = Attempt.search([
|
||||
('student_id', '=', uid),
|
||||
('is_placement', '=', True),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
question_type_weaknesses = {}
|
||||
topic_weaknesses = {}
|
||||
if placement_attempt:
|
||||
Answer = request.env['encoach.student.answer'].sudo()
|
||||
answers = Answer.search([('attempt_id', '=', placement_attempt.id)])
|
||||
type_stats = {}
|
||||
topic_stats = {}
|
||||
for ans in answers:
|
||||
q = ans.question_id
|
||||
qt = q.question_type or 'unknown'
|
||||
type_stats.setdefault(qt, {'correct': 0, 'total': 0})
|
||||
type_stats[qt]['total'] += 1
|
||||
if ans.is_correct:
|
||||
type_stats[qt]['correct'] += 1
|
||||
if q.topic_id:
|
||||
tid = q.topic_id.name or str(q.topic_id.id)
|
||||
topic_stats.setdefault(tid, {'correct': 0, 'total': 0})
|
||||
topic_stats[tid]['total'] += 1
|
||||
if ans.is_correct:
|
||||
topic_stats[tid]['correct'] += 1
|
||||
|
||||
for qt, stats in type_stats.items():
|
||||
pct = stats['correct'] / stats['total'] if stats['total'] else 0
|
||||
if pct < 0.6:
|
||||
question_type_weaknesses[qt] = round(pct, 2)
|
||||
|
||||
for topic, stats in topic_stats.items():
|
||||
pct = stats['correct'] / stats['total'] if stats['total'] else 0
|
||||
if pct < 0.6:
|
||||
topic_weaknesses[topic] = round(pct, 2)
|
||||
|
||||
user = request.env.user.sudo()
|
||||
entity_id = user.entity_ids[0].id if user.entity_ids else False
|
||||
|
||||
profile = GapProfile.create({
|
||||
'student_id': uid,
|
||||
'source_type': 'placement',
|
||||
'source_id': session.id,
|
||||
'skill_gaps': json.dumps(skill_gaps),
|
||||
'question_type_weaknesses': json.dumps(question_type_weaknesses),
|
||||
'topic_weaknesses': json.dumps(topic_weaknesses),
|
||||
'entity_id': entity_id,
|
||||
})
|
||||
|
||||
return _json_response({
|
||||
'gap_profile_id': profile.id,
|
||||
'student_id': profile.student_id.id,
|
||||
'source_type': profile.source_type,
|
||||
'skill_gaps': json.loads(profile.skill_gaps) if profile.skill_gaps else {},
|
||||
'question_type_weaknesses': json.loads(profile.question_type_weaknesses) if profile.question_type_weaknesses else {},
|
||||
'topic_weaknesses': json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {},
|
||||
'created_at': profile.created_at,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('gap_analysis failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/auto-generate
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/auto-generate', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def auto_generate(self, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
uid = request.env.user.id
|
||||
GapProfile = request.env['encoach.gap.profile'].sudo()
|
||||
|
||||
gap_profile_id = body.get('gap_profile_id')
|
||||
if gap_profile_id:
|
||||
profile = GapProfile.browse(int(gap_profile_id))
|
||||
if not profile.exists():
|
||||
return _error_response('Gap profile not found', 404)
|
||||
else:
|
||||
profile = GapProfile.search([
|
||||
('student_id', '=', uid),
|
||||
], limit=1, order='created_at desc')
|
||||
if not profile:
|
||||
return _error_response('No gap profile found. Run gap-analysis first.', 404)
|
||||
|
||||
skill_gaps = json.loads(profile.skill_gaps) if profile.skill_gaps else {}
|
||||
topic_weaknesses = json.loads(profile.topic_weaknesses) if profile.topic_weaknesses else {}
|
||||
|
||||
Ability = request.env['encoach.student.ability.model'].sudo()
|
||||
abilities = Ability.search([('student_id', '=', uid)])
|
||||
ability_map = {}
|
||||
for ab in abilities:
|
||||
ability_map[ab.skill] = ab.theta
|
||||
|
||||
user = request.env.user.sudo()
|
||||
entity_id = user.entity_ids[0].id if user.entity_ids else False
|
||||
|
||||
Course = request.env['op.course'].sudo()
|
||||
course_vals = {
|
||||
'name': f"Personalized Course for {user.name}",
|
||||
'code': f"AUTO_{uid}_{profile.id}",
|
||||
}
|
||||
if hasattr(Course, 'generation_source'):
|
||||
course_vals['generation_source'] = 'auto_gap'
|
||||
if hasattr(Course, 'gap_profile_id'):
|
||||
course_vals['gap_profile_id'] = profile.id
|
||||
if hasattr(Course, 'entity_id'):
|
||||
course_vals['entity_id'] = entity_id
|
||||
|
||||
course = Course.create(course_vals)
|
||||
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
seq = 10
|
||||
created_modules = []
|
||||
|
||||
sorted_gaps = sorted(skill_gaps.items(),
|
||||
key=lambda x: x[1].get('theta', 0) if isinstance(x[1], dict) else 0)
|
||||
|
||||
for skill, gap_info in sorted_gaps:
|
||||
theta = gap_info.get('theta', 0) if isinstance(gap_info, dict) else 0
|
||||
if theta < -0.5:
|
||||
cefr = 'a1'
|
||||
elif theta < 0.0:
|
||||
cefr = 'a2'
|
||||
elif theta < 0.5:
|
||||
cefr = 'b1'
|
||||
else:
|
||||
cefr = 'b2'
|
||||
|
||||
severity = gap_info.get('gap_severity', 'medium') if isinstance(gap_info, dict) else 'medium'
|
||||
mod = Module.create({
|
||||
'name': f"{skill.title()} Improvement ({severity})",
|
||||
'course_id': course.id,
|
||||
'cefr_target': cefr,
|
||||
'auto_generated': True,
|
||||
'generation_brief': json.dumps({
|
||||
'skill': skill,
|
||||
'theta': theta,
|
||||
'severity': severity,
|
||||
}),
|
||||
'completion_criteria': 'score_threshold',
|
||||
'score_threshold': PASS_THRESHOLD_DEFAULT,
|
||||
'sequence': seq,
|
||||
'status': 'available' if seq == 10 else 'locked',
|
||||
})
|
||||
created_modules.append(mod)
|
||||
seq += 10
|
||||
|
||||
for topic_name, accuracy in sorted(topic_weaknesses.items(), key=lambda x: x[1]):
|
||||
mod = Module.create({
|
||||
'name': f"Topic Review: {topic_name}",
|
||||
'course_id': course.id,
|
||||
'cefr_target': 'b1',
|
||||
'auto_generated': True,
|
||||
'generation_brief': json.dumps({
|
||||
'topic': topic_name,
|
||||
'accuracy': accuracy,
|
||||
}),
|
||||
'completion_criteria': 'all_resources',
|
||||
'sequence': seq,
|
||||
'status': 'locked',
|
||||
})
|
||||
created_modules.append(mod)
|
||||
seq += 10
|
||||
|
||||
if not created_modules:
|
||||
Module.create({
|
||||
'name': 'General Practice',
|
||||
'course_id': course.id,
|
||||
'cefr_target': 'b1',
|
||||
'auto_generated': True,
|
||||
'completion_criteria': 'all_resources',
|
||||
'sequence': 10,
|
||||
'status': 'available',
|
||||
})
|
||||
|
||||
for i in range(1, len(created_modules)):
|
||||
created_modules[i].write({
|
||||
'prerequisite_module_id': created_modules[i - 1].id,
|
||||
})
|
||||
|
||||
return _json_response(_course_to_dict(course), 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('auto_generate failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/course/<int:course_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>', type='http', auth='none',
|
||||
methods=['GET'], csrf=False)
|
||||
@jwt_required
|
||||
def get(self, course_id, **kw):
|
||||
try:
|
||||
Course = request.env['op.course'].sudo()
|
||||
course = Course.browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
|
||||
data = _course_to_dict(course)
|
||||
|
||||
uid = request.env.user.id
|
||||
Progress = request.env.get('encoach.course.progress')
|
||||
if Progress is not None:
|
||||
Progress = Progress.sudo()
|
||||
for mod in data.get('modules', []):
|
||||
progress_recs = Progress.search([
|
||||
('student_id', '=', uid),
|
||||
('module_id', '=', mod['id']),
|
||||
])
|
||||
mod['completed_resources'] = len(
|
||||
progress_recs.filtered(lambda p: p.completed if hasattr(p, 'completed') else False)
|
||||
)
|
||||
|
||||
return _json_response(data)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('course get failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUT /api/course/<int:course_id>
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>', type='http', auth='none',
|
||||
methods=['PUT'], csrf=False)
|
||||
@jwt_required
|
||||
def update(self, course_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
Course = request.env['op.course'].sudo()
|
||||
course = Course.browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
|
||||
allowed_fields = {'name', 'description', 'code'}
|
||||
vals = {}
|
||||
for key in allowed_fields:
|
||||
if key in body and hasattr(course, key):
|
||||
vals[key] = body[key]
|
||||
|
||||
if vals:
|
||||
course.write(vals)
|
||||
|
||||
module_order = body.get('module_order')
|
||||
if module_order and isinstance(module_order, list):
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
for idx, mod_id in enumerate(module_order):
|
||||
mod = Module.browse(int(mod_id))
|
||||
if mod.exists() and mod.course_id.id == course.id:
|
||||
mod.write({'sequence': (idx + 1) * 10})
|
||||
|
||||
return _json_response(_course_to_dict(course))
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('course update failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/<int:course_id>/progress
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>/progress', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def progress(self, course_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
module_id = body.get('module_id')
|
||||
resource_id = body.get('resource_id')
|
||||
completed = body.get('completed', False)
|
||||
|
||||
if not module_id:
|
||||
return _error_response('module_id is required', 400)
|
||||
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
mod = Module.browse(int(module_id))
|
||||
if not mod.exists() or mod.course_id.id != course_id:
|
||||
return _error_response('Module not found in this course', 404)
|
||||
|
||||
uid = request.env.user.id
|
||||
|
||||
if mod.status == 'locked':
|
||||
return _error_response('Module is locked', 400)
|
||||
|
||||
if mod.status in ('locked', 'available'):
|
||||
mod.write({'status': 'in_progress'})
|
||||
|
||||
Progress = request.env.get('encoach.course.progress')
|
||||
if Progress is not None and resource_id:
|
||||
Progress = Progress.sudo()
|
||||
existing = Progress.search([
|
||||
('student_id', '=', uid),
|
||||
('module_id', '=', mod.id),
|
||||
('resource_id', '=', int(resource_id)),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write({'completed': completed})
|
||||
else:
|
||||
Progress.create({
|
||||
'student_id': uid,
|
||||
'module_id': mod.id,
|
||||
'resource_id': int(resource_id),
|
||||
'completed': completed,
|
||||
})
|
||||
|
||||
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
|
||||
total_modules = len(all_modules)
|
||||
completed_modules = len(all_modules.filtered(lambda m: m.status == 'completed'))
|
||||
|
||||
module_status = mod.status
|
||||
if mod.completion_criteria == 'all_resources' and Progress is not None:
|
||||
Progress = request.env['encoach.course.progress'].sudo()
|
||||
mod_progress = Progress.search([
|
||||
('student_id', '=', uid),
|
||||
('module_id', '=', mod.id),
|
||||
])
|
||||
all_done = all(
|
||||
getattr(p, 'completed', False) for p in mod_progress
|
||||
) if mod_progress else False
|
||||
if all_done and mod_progress:
|
||||
mod.write({'status': 'completed'})
|
||||
module_status = 'completed'
|
||||
completed_modules += 1
|
||||
self._unlock_next_module(mod, all_modules)
|
||||
|
||||
overall_pct = round((completed_modules / total_modules) * 100, 1) if total_modules else 0.0
|
||||
|
||||
return _json_response({
|
||||
'module_id': mod.id,
|
||||
'module_status': module_status,
|
||||
'overall_progress_pct': overall_pct,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('progress failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
def _unlock_next_module(self, current_mod, all_modules):
|
||||
"""Unlock the next sequential module when the current one is completed."""
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
next_mods = Module.search([
|
||||
('course_id', '=', current_mod.course_id.id),
|
||||
('prerequisite_module_id', '=', current_mod.id),
|
||||
('status', '=', 'locked'),
|
||||
])
|
||||
for nm in next_mods:
|
||||
nm.write({'status': 'available'})
|
||||
|
||||
if not next_mods:
|
||||
found_current = False
|
||||
for m in all_modules:
|
||||
if found_current and m.status == 'locked':
|
||||
m.write({'status': 'available'})
|
||||
break
|
||||
if m.id == current_mod.id:
|
||||
found_current = True
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/<int:course_id>/checkpoint
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>/checkpoint', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def checkpoint(self, course_id, **kw):
|
||||
try:
|
||||
body = _get_json_body()
|
||||
module_id = body.get('module_id')
|
||||
score = body.get('score')
|
||||
|
||||
if not module_id or score is None:
|
||||
return _error_response('module_id and score are required', 400)
|
||||
|
||||
score = float(score)
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
mod = Module.browse(int(module_id))
|
||||
if not mod.exists() or mod.course_id.id != course_id:
|
||||
return _error_response('Module not found in this course', 404)
|
||||
|
||||
threshold = mod.score_threshold or PASS_THRESHOLD_DEFAULT
|
||||
passed = score >= threshold
|
||||
|
||||
all_modules = Module.search([('course_id', '=', course_id)], order='sequence asc')
|
||||
next_module_id = None
|
||||
action = 'continue'
|
||||
|
||||
if passed:
|
||||
mod.write({'status': 'completed'})
|
||||
self._unlock_next_module(mod, all_modules)
|
||||
|
||||
next_mods = Module.search([
|
||||
('course_id', '=', course_id),
|
||||
('status', 'in', ['available', 'locked']),
|
||||
('sequence', '>', mod.sequence),
|
||||
], limit=1, order='sequence asc')
|
||||
if next_mods:
|
||||
next_module_id = next_mods[0].id
|
||||
action = 'next_module'
|
||||
else:
|
||||
action = 'course_complete'
|
||||
elif score < STEP_DOWN_THRESHOLD:
|
||||
action = 'remediation'
|
||||
else:
|
||||
action = 'retry'
|
||||
|
||||
return _json_response({
|
||||
'module_id': mod.id,
|
||||
'score': score,
|
||||
'threshold': threshold,
|
||||
'passed': passed,
|
||||
'next_module_id': next_module_id,
|
||||
'action': action,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('checkpoint failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /api/course/<int:course_id>/post-test
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/course/<int:course_id>/post-test', type='http', auth='none',
|
||||
methods=['POST'], csrf=False)
|
||||
@jwt_required
|
||||
def post_test(self, course_id, **kw):
|
||||
try:
|
||||
Course = request.env['op.course'].sudo()
|
||||
course = Course.browse(course_id)
|
||||
if not course.exists():
|
||||
return _error_response('Course not found', 404)
|
||||
|
||||
Module = request.env['encoach.course.module'].sudo()
|
||||
all_modules = Module.search([('course_id', '=', course_id)])
|
||||
incomplete = all_modules.filtered(lambda m: m.status != 'completed')
|
||||
if incomplete:
|
||||
return _error_response(
|
||||
f'{len(incomplete)} module(s) not completed. Finish all modules first.', 400
|
||||
)
|
||||
|
||||
subject_id = course.subject_id.id if hasattr(course, 'subject_id') and course.subject_id else False
|
||||
|
||||
ExamCustom = request.env['encoach.exam.custom'].sudo()
|
||||
exam = None
|
||||
if subject_id:
|
||||
exam = ExamCustom.search([
|
||||
('subject_id', '=', subject_id),
|
||||
('status', '=', 'published'),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not exam:
|
||||
exam = ExamCustom.search([
|
||||
('status', '=', 'published'),
|
||||
], limit=1, order='id desc')
|
||||
|
||||
if not exam:
|
||||
return _error_response('No published exam found for post-test', 404)
|
||||
|
||||
Assignment = request.env['encoach.exam.assignment'].sudo()
|
||||
uid = request.env.user.id
|
||||
|
||||
existing = Assignment.search([
|
||||
('exam_id', '=', exam.id),
|
||||
('student_id', '=', uid),
|
||||
('status', '=', 'assigned'),
|
||||
], limit=1)
|
||||
if existing:
|
||||
return _json_response({'exam_assignment_id': existing.id})
|
||||
|
||||
assignment = Assignment.create({
|
||||
'exam_id': exam.id,
|
||||
'student_id': uid,
|
||||
'status': 'assigned',
|
||||
})
|
||||
|
||||
return _json_response({'exam_assignment_id': assignment.id}, 201)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception('post_test failed')
|
||||
return _error_response(str(e), 500)
|
||||
4
custom_addons/encoach_course_gen/models/__init__.py
Normal file
4
custom_addons/encoach_course_gen/models/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from . import gap_profile
|
||||
from . import course_extension
|
||||
from . import course_section
|
||||
from . import course_module
|
||||
21
custom_addons/encoach_course_gen/models/course_extension.py
Normal file
21
custom_addons/encoach_course_gen/models/course_extension.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class OpCourseExtension(models.Model):
|
||||
_inherit = 'op.course'
|
||||
|
||||
generation_source = fields.Selection([
|
||||
('manual', 'Manual'),
|
||||
('auto_gap', 'Auto from Gap'),
|
||||
('ai_english', 'AI English'),
|
||||
('ai_ielts', 'AI IELTS'),
|
||||
])
|
||||
gap_profile_id = fields.Many2one('encoach.gap.profile', ondelete='set null')
|
||||
progression_model = fields.Selection([
|
||||
('linear', 'Linear'),
|
||||
('parallel', 'Parallel'),
|
||||
('adaptive', 'Adaptive'),
|
||||
], default='linear')
|
||||
target_band = fields.Float()
|
||||
study_hours_week = fields.Integer()
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
37
custom_addons/encoach_course_gen/models/course_module.py
Normal file
37
custom_addons/encoach_course_gen/models/course_module.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachCourseModule(models.Model):
|
||||
_name = 'encoach.course.module'
|
||||
_description = 'Course Module'
|
||||
|
||||
name = fields.Char(size=200, required=True)
|
||||
course_id = fields.Many2one('op.course', required=True, ondelete='cascade')
|
||||
cefr_target = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
('a1', 'A1'),
|
||||
('a2', 'A2'),
|
||||
('b1', 'B1'),
|
||||
('b2', 'B2'),
|
||||
('c1', 'C1'),
|
||||
('c2', 'C2'),
|
||||
])
|
||||
auto_generated = fields.Boolean(default=False)
|
||||
generation_brief = fields.Text()
|
||||
completion_criteria = fields.Selection([
|
||||
('all_resources', 'All Resources'),
|
||||
('score_threshold', 'Score Threshold'),
|
||||
('teacher_approval', 'Teacher Approval'),
|
||||
], default='all_resources')
|
||||
score_threshold = fields.Float()
|
||||
prerequisite_module_id = fields.Many2one('encoach.course.module', ondelete='set null')
|
||||
status = fields.Selection([
|
||||
('locked', 'Locked'),
|
||||
('available', 'Available'),
|
||||
('in_progress', 'In Progress'),
|
||||
('completed', 'Completed'),
|
||||
('skipped', 'Skipped'),
|
||||
], default='locked', required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
section_id = fields.Many2one('encoach.course.section', ondelete='set null')
|
||||
resource_ids = fields.Many2many('encoach.resource', string='Resources')
|
||||
21
custom_addons/encoach_course_gen/models/course_section.py
Normal file
21
custom_addons/encoach_course_gen/models/course_section.py
Normal file
@@ -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')
|
||||
18
custom_addons/encoach_course_gen/models/gap_profile.py
Normal file
18
custom_addons/encoach_course_gen/models/gap_profile.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachGapProfile(models.Model):
|
||||
_name = 'encoach.gap.profile'
|
||||
_description = 'Student Gap Profile'
|
||||
|
||||
student_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
source_type = fields.Selection([
|
||||
('placement', 'Placement'),
|
||||
('exam', 'Exam'),
|
||||
], required=True)
|
||||
source_id = fields.Integer()
|
||||
skill_gaps = fields.Text()
|
||||
question_type_weaknesses = fields.Text()
|
||||
topic_weaknesses = fields.Text()
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
created_at = fields.Datetime(default=fields.Datetime.now)
|
||||
@@ -0,0 +1,4 @@
|
||||
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_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,40 @@
|
||||
.o_gap_analysis .ga-bar-track {
|
||||
height: 28px;
|
||||
background: #e9ecef;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
.o_gap_analysis .ga-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 0.5rem;
|
||||
transition: width 0.6s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding-right: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
.o_gap_analysis .ga-bar-label {
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.o_gap_analysis .ga-bar-row {
|
||||
padding: 4px 0;
|
||||
}
|
||||
.o_gap_analysis .ga-student-card {
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_gap_analysis .ga-student-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1) !important;
|
||||
}
|
||||
.o_gap_analysis .card {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.o_gap_analysis .cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
162
custom_addons/encoach_course_gen/static/src/js/gap_analysis.js
Normal file
162
custom_addons/encoach_course_gen/static/src/js/gap_analysis.js
Normal file
@@ -0,0 +1,162 @@
|
||||
/** @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 GapAnalysisVisualization extends Component {
|
||||
static template = "encoach_course_gen.GapAnalysis";
|
||||
static components = { Layout };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
studentId: null,
|
||||
student: null,
|
||||
gapProfile: null,
|
||||
skillGaps: [],
|
||||
weakTopics: [],
|
||||
recommendations: [],
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
const ctx = this.props.action?.context || {};
|
||||
this.state.studentId = ctx.student_id || ctx.active_id || null;
|
||||
if (this.state.studentId) {
|
||||
await this.loadGapData();
|
||||
} else {
|
||||
await this.loadStudentList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadStudentList() {
|
||||
try {
|
||||
this.state.studentList = await this.orm.searchRead(
|
||||
"res.users",
|
||||
[["account_source", "!=", false]],
|
||||
["name", "email", "login"],
|
||||
{ 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.loadGapData();
|
||||
}
|
||||
|
||||
async loadGapData() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const [profiles, students] = await Promise.all([
|
||||
this.orm.searchRead(
|
||||
"encoach.gap.profile",
|
||||
[["student_id", "=", this.state.studentId]],
|
||||
["student_id", "source_type", "skill_gaps", "question_type_weaknesses", "topic_weaknesses", "created_at"],
|
||||
{ limit: 1, 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.gapProfile = profiles.length ? profiles[0] : null;
|
||||
|
||||
if (this.state.gapProfile) {
|
||||
this._parseGapProfile(this.state.gapProfile);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Gap analysis load error:", e);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_parseGapProfile(profile) {
|
||||
this.state.skillGaps = this._safeParseJson(profile.skill_gaps, []);
|
||||
if (!Array.isArray(this.state.skillGaps)) {
|
||||
const obj = this.state.skillGaps;
|
||||
this.state.skillGaps = Object.entries(obj).map(([skill, data]) => ({
|
||||
skill,
|
||||
current: typeof data === "object" ? (data.current || 0) : data,
|
||||
target: typeof data === "object" ? (data.target || 100) : 100,
|
||||
gap: typeof data === "object" ? (data.gap || (data.target || 100) - (data.current || 0)) : (100 - data),
|
||||
}));
|
||||
}
|
||||
|
||||
const topicRaw = this._safeParseJson(profile.topic_weaknesses, []);
|
||||
if (Array.isArray(topicRaw)) {
|
||||
this.state.weakTopics = topicRaw;
|
||||
} else if (typeof topicRaw === "object") {
|
||||
this.state.weakTopics = Object.entries(topicRaw).map(([name, score]) => ({
|
||||
name,
|
||||
score: typeof score === "number" ? score : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
this.state.recommendations = this._generateRecommendations();
|
||||
}
|
||||
|
||||
_safeParseJson(val, fallback) {
|
||||
if (!val) return fallback;
|
||||
if (typeof val === "object") return val;
|
||||
try { return JSON.parse(val); } catch { return fallback; }
|
||||
}
|
||||
|
||||
_generateRecommendations() {
|
||||
const recs = [];
|
||||
for (const gap of this.state.skillGaps) {
|
||||
const pct = gap.target > 0 ? (gap.current / gap.target) * 100 : 0;
|
||||
if (pct < 50) {
|
||||
recs.push({ icon: "fa-exclamation-triangle", color: "danger", text: `Critical gap in ${gap.skill} — intensive practice recommended` });
|
||||
} else if (pct < 75) {
|
||||
recs.push({ icon: "fa-arrow-up", color: "warning", text: `${gap.skill} needs improvement — targeted exercises suggested` });
|
||||
}
|
||||
}
|
||||
if (this.state.weakTopics.length > 0) {
|
||||
recs.push({ icon: "fa-book", color: "info", text: `Focus on ${this.state.weakTopics.length} weak topic(s) identified in the analysis` });
|
||||
}
|
||||
if (recs.length === 0) {
|
||||
recs.push({ icon: "fa-thumbs-up", color: "success", text: "Student performance is on track across all areas" });
|
||||
}
|
||||
return recs;
|
||||
}
|
||||
|
||||
barWidth(current, target) {
|
||||
if (!target || target <= 0) return 0;
|
||||
return Math.min(100, Math.round((current / target) * 100));
|
||||
}
|
||||
|
||||
barColor(current, target) {
|
||||
const pct = target > 0 ? (current / target) * 100 : 0;
|
||||
if (pct >= 75) return "bg-success";
|
||||
if (pct >= 50) return "bg-warning";
|
||||
return "bg-danger";
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.state.studentId = null;
|
||||
this.state.student = null;
|
||||
this.state.gapProfile = null;
|
||||
this.state.skillGaps = [];
|
||||
this.state.weakTopics = [];
|
||||
this.state.recommendations = [];
|
||||
this.state.loading = true;
|
||||
this.loadStudentList();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("encoach_gap_analysis", GapAnalysisVisualization);
|
||||
170
custom_addons/encoach_course_gen/static/src/xml/gap_analysis.xml
Normal file
170
custom_addons/encoach_course_gen/static/src/xml/gap_analysis.xml
Normal file
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="encoach_course_gen.GapAnalysis">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<div class="o_gap_analysis">
|
||||
<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">Analyzing gaps...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Student Selector -->
|
||||
<t t-elif="!state.studentId and state.studentList">
|
||||
<h2 class="mb-3">Gap Analysis</h2>
|
||||
<p class="text-muted mb-4">Select a student to view their skill gap analysis.</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 ga-student-card"
|
||||
t-on-click="() => this.selectStudent(st.id)">
|
||||
<div class="card-body text-center">
|
||||
<div class="rounded-circle bg-primary 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-primary"/>
|
||||
</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>
|
||||
|
||||
<!-- Gap Analysis View -->
|
||||
<t t-elif="state.student">
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<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">Gap Analysis</h2>
|
||||
<small class="text-muted">
|
||||
Student: <strong t-esc="state.student.name"/>
|
||||
<t t-if="state.gapProfile">
|
||||
— Source: <span class="badge bg-secondary" t-esc="state.gapProfile.source_type"/>
|
||||
</t>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="!state.gapProfile">
|
||||
<div class="alert alert-info">
|
||||
<i class="fa fa-info-circle me-2"/>
|
||||
No gap profile found for this student. A placement test or exam is required first.
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<!-- Skill Gap Bars -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-bar-chart me-2 text-primary"/>
|
||||
Skill Gaps
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="state.skillGaps.length">
|
||||
<t t-foreach="state.skillGaps" t-as="gap" t-key="gap.skill">
|
||||
<div class="ga-bar-row mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<span class="fw-bold text-capitalize" t-esc="gap.skill"/>
|
||||
<span class="small text-muted">
|
||||
<t t-esc="gap.current"/> / <t t-esc="gap.target"/>
|
||||
</span>
|
||||
</div>
|
||||
<div class="ga-bar-track">
|
||||
<div class="ga-bar-fill"
|
||||
t-att-class="barColor(gap.current, gap.target)"
|
||||
t-att-style="'width:' + barWidth(gap.current, gap.target) + '%'">
|
||||
<span class="ga-bar-label" t-if="barWidth(gap.current, gap.target) > 15">
|
||||
<t t-esc="barWidth(gap.current, gap.target)"/>%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted mb-0">No skill gap data available.</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Weak Topics -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-warning me-2 text-warning"/>
|
||||
Weak Topics
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="state.weakTopics.length">
|
||||
<t t-foreach="state.weakTopics" t-as="topic" t-key="topic_index">
|
||||
<div class="d-flex justify-content-between align-items-center py-2 border-bottom">
|
||||
<span>
|
||||
<i class="fa fa-circle text-danger me-2" style="font-size:0.5rem; vertical-align:middle"/>
|
||||
<t t-esc="topic.name || topic"/>
|
||||
</span>
|
||||
<span class="badge bg-danger bg-opacity-75" t-if="topic.score !== undefined">
|
||||
<t t-esc="topic.score"/>%
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted text-center mb-0 py-3">
|
||||
<i class="fa fa-check-circle text-success me-1"/>
|
||||
No weak topics identified
|
||||
</p>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recommendations -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-lightbulb-o me-2 text-info"/>
|
||||
Recommended Actions
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-foreach="state.recommendations" t-as="rec" t-key="rec_index">
|
||||
<div class="d-flex align-items-start py-2 border-bottom">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center me-3 flex-shrink-0"
|
||||
t-att-class="'bg-' + rec.color + ' bg-opacity-10'"
|
||||
style="width:36px; height:36px">
|
||||
<i class="fa" t-att-class="rec.icon + ' text-' + rec.color"/>
|
||||
</div>
|
||||
<span class="small" t-esc="rec.text"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<t t-elif="!state.studentId">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fa fa-line-chart 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>
|
||||
28
custom_addons/encoach_course_gen/views/course_gen_menus.xml
Normal file
28
custom_addons/encoach_course_gen/views/course_gen_menus.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_all_courses" model="ir.actions.act_window">
|
||||
<field name="name">All Courses</field>
|
||||
<field name="res_model">op.course</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_all_courses"
|
||||
name="All Courses"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_course_gen.action_all_courses"
|
||||
sequence="5"/>
|
||||
|
||||
<menuitem id="menu_course_modules"
|
||||
name="Course Modules"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_course_gen.action_course_module"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_gap_analysis"
|
||||
name="Gap Analysis"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="encoach_course_gen.action_gap_profile"
|
||||
sequence="20"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,74 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_course_module_form" model="ir.ui.view">
|
||||
<field name="name">encoach.course.module.form</field>
|
||||
<field name="model">encoach.course.module</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Course Module">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="locked,available,in_progress,completed"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="course_id"/>
|
||||
<field name="cefr_target"/>
|
||||
<field name="auto_generated"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="completion_criteria"/>
|
||||
<field name="score_threshold" invisible="completion_criteria != 'score_threshold'"/>
|
||||
<field name="prerequisite_module_id"/>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="generation_brief"/>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_course_module_list" model="ir.ui.view">
|
||||
<field name="name">encoach.course.module.list</field>
|
||||
<field name="model">encoach.course.module</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Course Modules">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="course_id"/>
|
||||
<field name="cefr_target"/>
|
||||
<field name="status" widget="badge" decoration-info="status == 'available'" decoration-success="status == 'completed'" decoration-warning="status == 'in_progress'"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_course_module_search" model="ir.ui.view">
|
||||
<field name="name">encoach.course.module.search</field>
|
||||
<field name="model">encoach.course.module</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Course Modules">
|
||||
<field name="name"/>
|
||||
<field name="course_id"/>
|
||||
<separator/>
|
||||
<filter string="Locked" name="locked" domain="[('status', '=', 'locked')]"/>
|
||||
<filter string="Available" name="available" domain="[('status', '=', 'available')]"/>
|
||||
<filter string="In Progress" name="in_progress" domain="[('status', '=', 'in_progress')]"/>
|
||||
<filter string="Completed" name="completed" domain="[('status', '=', 'completed')]"/>
|
||||
<filter string="Skipped" name="skipped" domain="[('status', '=', 'skipped')]"/>
|
||||
<separator/>
|
||||
<filter string="Course" name="group_course" context="{'group_by': 'course_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_course_module" model="ir.actions.act_window">
|
||||
<field name="name">Course Modules</field>
|
||||
<field name="res_model">encoach.course.module</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_gap_analysis_viz" model="ir.actions.client">
|
||||
<field name="name">Gap Analysis</field>
|
||||
<field name="tag">encoach_gap_analysis</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_gap_analysis_viz"
|
||||
name="Gap Analysis (Visual)"
|
||||
parent="encoach_core.menu_encoach_courses"
|
||||
action="action_gap_analysis_viz"
|
||||
sequence="25"/>
|
||||
|
||||
</odoo>
|
||||
72
custom_addons/encoach_course_gen/views/gap_profile_views.xml
Normal file
72
custom_addons/encoach_course_gen/views/gap_profile_views.xml
Normal file
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="view_gap_profile_form" model="ir.ui.view">
|
||||
<field name="name">encoach.gap.profile.form</field>
|
||||
<field name="model">encoach.gap.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Gap Profile">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="student_id"/>
|
||||
<field name="source_type"/>
|
||||
<field name="source_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="entity_id"/>
|
||||
<field name="created_at"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Skill Gaps" name="skill_gaps">
|
||||
<field name="skill_gaps"/>
|
||||
</page>
|
||||
<page string="Question Type Weaknesses" name="question_type_weaknesses">
|
||||
<field name="question_type_weaknesses"/>
|
||||
</page>
|
||||
<page string="Topic Weaknesses" name="topic_weaknesses">
|
||||
<field name="topic_weaknesses"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_gap_profile_list" model="ir.ui.view">
|
||||
<field name="name">encoach.gap.profile.list</field>
|
||||
<field name="model">encoach.gap.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Gap Profiles">
|
||||
<field name="student_id"/>
|
||||
<field name="source_type"/>
|
||||
<field name="entity_id"/>
|
||||
<field name="created_at"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_gap_profile_search" model="ir.ui.view">
|
||||
<field name="name">encoach.gap.profile.search</field>
|
||||
<field name="model">encoach.gap.profile</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Gap Profiles">
|
||||
<field name="student_id"/>
|
||||
<separator/>
|
||||
<filter string="Placement" name="placement" domain="[('source_type', '=', 'placement')]"/>
|
||||
<filter string="Exam" name="exam" domain="[('source_type', '=', 'exam')]"/>
|
||||
<separator/>
|
||||
<filter string="Student" name="group_student" context="{'group_by': 'student_id'}"/>
|
||||
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_gap_profile" model="ir.actions.act_window">
|
||||
<field name="name">Gap Analysis</field>
|
||||
<field name="res_model">encoach.gap.profile</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user