Files
encoach_backend_v4/custom_addons/encoach_course_gen/controllers/course.py
Yamen Ahmad 907a5c0e92 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
2026-04-10 17:26:42 +04:00

631 lines
26 KiB
Python

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)