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:
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)
|
||||
Reference in New Issue
Block a user