feat: initial backend codebase — EnCoach v3

Complete Odoo 19 backend with 25 custom addons:
- encoach_core: user/entity/role management
- encoach_api: REST API + JWT auth
- encoach_ai: OpenAI integration, AI settings, generation
- encoach_ai_course: AI-powered English & IELTS course generation
- encoach_exam_template/session: exam creation, structures, sessions
- encoach_scoring: AI auto-grading + manual approval
- encoach_vector: pgvector RAG integration
- encoach_adaptive: adaptive learning engine
- encoach_placement: placement testing
- encoach_taxonomy/resources: content taxonomy & resource management
- Plus 14 more modules for courses, branding, portal, etc.

Includes docs: user guide, generation report, developer workflow.

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-11 15:44:20 +04:00
commit 982d4bca30
371 changed files with 35211 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
from . import templates
from . import ielts_exam
from . import custom_exam
from . import exam_structures

View File

@@ -0,0 +1,364 @@
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__)
def _section_to_dict(sec):
return {
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'question_count': sec.question_count or 0,
'time_limit_min': sec.time_limit_min or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
'question_ids': sec.question_ids.ids,
}
def _exam_to_dict(exam):
return {
'id': exam.id,
'title': exam.title,
'template_id': exam.template_id.id if exam.template_id else None,
'subject_id': exam.subject_id.id if exam.subject_id else None,
'entity_id': exam.entity_id.id if exam.entity_id else None,
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
'description': exam.description or '',
'total_time_min': exam.total_time_min or 0,
'pass_threshold': exam.pass_threshold or 0.0,
'results_release_mode': exam.results_release_mode or 'auto',
'randomize_questions': exam.randomize_questions,
'status': exam.status,
'sections': [_section_to_dict(s) for s in exam.section_ids],
}
class EncoachCustomExamController(http.Controller):
# ------------------------------------------------------------------
# POST /api/exam/custom/create
# ------------------------------------------------------------------
@http.route('/api/exam/custom/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create(self, **kw):
try:
body = _get_json_body()
title = (body.get('title') or '').strip()
if not title:
return _error_response('title is required', 400)
vals = {
'title': title,
'teacher_id': request.env.user.id,
'subject_id': body.get('subject_id') or False,
'template_id': body.get('template_id') or False,
'entity_id': body.get('entity_id') or False,
'description': body.get('description', ''),
'total_time_min': body.get('total_time_min', 0),
'pass_threshold': body.get('pass_threshold', 0.0),
'results_release_mode': body.get('results_release_mode', 'auto'),
'randomize_questions': body.get('randomize_questions', False),
'status': 'draft',
}
exam = request.env['encoach.exam.custom'].sudo().create(vals)
sections = body.get('sections', [])
Section = request.env['encoach.exam.custom.section'].sudo()
for idx, sec_data in enumerate(sections):
sec_vals = {
'exam_id': exam.id,
'title': sec_data.get('title', f'Section {idx + 1}'),
'skill': sec_data.get('skill', ''),
'question_count': sec_data.get('question_count', 0),
'time_limit_min': sec_data.get('time_limit_min', 0),
'scoring_method': sec_data.get('scoring_method', 'auto'),
'sequence': sec_data.get('sequence', (idx + 1) * 10),
}
section = Section.create(sec_vals)
q_ids = sec_data.get('question_ids', [])
if q_ids:
section.write({'question_ids': [(6, 0, q_ids)]})
exam.invalidate_recordset()
return _json_response(_exam_to_dict(exam), 201)
except Exception as e:
_logger.exception('custom exam create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/custom/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('custom exam get failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/exam/custom/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be updated', 400)
body = _get_json_body()
allowed = {'title', 'description', 'total_time_min', 'pass_threshold',
'results_release_mode', 'randomize_questions', 'subject_id'}
vals = {k: v for k, v in body.items() if k in allowed}
if vals:
exam.write(vals)
sections_data = body.get('sections')
if sections_data and isinstance(sections_data, list):
Section = request.env['encoach.exam.custom.section'].sudo()
existing_ids = set(exam.section_ids.ids)
incoming_ids = set()
for idx, sec_data in enumerate(sections_data):
sec_id = sec_data.get('id')
sec_vals = {
'title': sec_data.get('title', f'Section {idx + 1}'),
'skill': sec_data.get('skill', ''),
'question_count': sec_data.get('question_count', 0),
'time_limit_min': sec_data.get('time_limit_min', 0),
'scoring_method': sec_data.get('scoring_method', 'auto'),
'sequence': sec_data.get('sequence', (idx + 1) * 10),
}
if sec_id and sec_id in existing_ids:
section = Section.browse(sec_id)
section.write(sec_vals)
incoming_ids.add(sec_id)
else:
sec_vals['exam_id'] = exam.id
section = Section.create(sec_vals)
incoming_ids.add(section.id)
q_ids = sec_data.get('question_ids')
if q_ids is not None:
section.write({'question_ids': [(6, 0, q_ids)]})
to_delete = existing_ids - incoming_ids
if to_delete:
Section.browse(list(to_delete)).unlink()
exam.invalidate_recordset()
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('custom exam update failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# DELETE /api/exam/custom/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@jwt_required
def delete(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be deleted', 400)
exam.section_ids.unlink()
exam.unlink()
return _json_response({'deleted': True}, 204)
except Exception as e:
_logger.exception('custom exam delete failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/custom/<int:exam_id>/save-template
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>/save-template',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def save_template(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
structure = []
for sec in exam.section_ids.sorted('sequence'):
structure.append({
'title': sec.title,
'skill': sec.skill or '',
'question_count': sec.question_count or 0,
'time_limit_min': sec.time_limit_min or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
})
body = _get_json_body()
template_name = body.get('name', f'Template from: {exam.title}')
template = request.env['encoach.exam.template'].sudo().create({
'name': template_name,
'type': 'custom',
'teacher_id': request.env.user.id,
'subject_id': exam.subject_id.id if exam.subject_id else False,
'entity_id': exam.entity_id.id if exam.entity_id else False,
'structure': json.dumps(structure),
'total_time_min': exam.total_time_min or 0,
'pass_threshold': exam.pass_threshold or 0.0,
'results_release_mode': exam.results_release_mode or 'auto',
'randomize_questions': exam.randomize_questions,
})
return _json_response({
'id': template.id,
'name': template.name,
'type': template.type,
'structure': structure,
}, 201)
except Exception as e:
_logger.exception('save_template failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/custom/<int:exam_id>/validate
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>/validate',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def validate(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
errors = []
warnings = []
if not exam.section_ids:
errors.append('Exam has no sections')
for section in exam.section_ids:
actual = len(section.question_ids)
expected = section.question_count or 0
if actual == 0:
errors.append(
f'Section "{section.title}" has no questions assigned')
elif expected > 0 and actual < expected:
warnings.append(
f'Section "{section.title}" has {actual}/{expected} questions')
if not section.scoring_method:
warnings.append(
f'Section "{section.title}" has no scoring method set')
if not section.time_limit_min:
warnings.append(
f'Section "{section.title}" has no time limit set')
if not exam.total_time_min:
warnings.append('Exam has no total time limit set')
if not exam.pass_threshold:
warnings.append('Exam has no pass threshold set')
return _json_response({
'valid': len(errors) == 0,
'errors': errors,
'warnings': warnings,
})
except Exception as e:
_logger.exception('validate failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/custom/<int:exam_id>/assign
# ------------------------------------------------------------------
@http.route('/api/exam/custom/<int:exam_id>/assign',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def assign(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
body = _get_json_body()
student_ids = body.get('student_ids', [])
batch_id = body.get('batch_id')
access_start = body.get('access_start')
access_end = body.get('access_end')
Assignment = request.env['encoach.exam.assignment'].sudo()
created = 0
if student_ids:
for sid in student_ids:
Assignment.create({
'exam_id': exam.id,
'student_id': int(sid),
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
if batch_id:
try:
batch = request.env['op.batch'].sudo().browse(int(batch_id))
if batch.exists():
batch_students = request.env['op.student'].sudo().search(
[('batch_id', '=', batch.id)])
for student in batch_students:
user = student.user_id if hasattr(student, 'user_id') else None
if user:
Assignment.create({
'exam_id': exam.id,
'student_id': user.id,
'batch_id': batch.id,
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
except Exception:
_logger.warning('Batch lookup failed for batch_id=%s', batch_id)
return _json_response({'assignments_created': created})
except Exception as e:
_logger.exception('assign failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,87 @@
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
def _json_body():
try:
return json.loads(request.httprequest.data or '{}')
except Exception:
return {}
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
class ExamStructureController(http.Controller):
@http.route('/api/exam-structures', type='http', auth='user', methods=['GET'], csrf=False)
def list_structures(self, **kw):
domain = [('active', '=', True)]
entity_id = kw.get('entity_id')
if entity_id:
domain.append(('entity_id', '=', int(entity_id)))
limit = int(kw.get('limit', 50))
offset = int(kw.get('offset', 0))
records = request.env['encoach.exam.structure'].search(domain, limit=limit, offset=offset, order='create_date desc')
total = request.env['encoach.exam.structure'].search_count(domain)
items = []
for r in records:
modules = []
if r.modules:
try:
modules = json.loads(r.modules)
except Exception:
modules = []
items.append({
'id': r.id,
'name': r.name,
'entity_id': r.entity_id.id if r.entity_id else None,
'entity_name': r.entity_id.name if r.entity_id else None,
'industry': r.industry or '',
'modules': modules,
'config': json.loads(r.config) if r.config else {},
})
return _json_response({'items': items, 'total': total})
@http.route('/api/exam-structures', type='http', auth='user', methods=['POST'], csrf=False)
def create_structure(self, **kw):
body = _json_body()
name = body.get('name')
if not name:
return _json_response({'error': 'name is required'}, status=400)
vals = {
'name': name,
'industry': body.get('industry', ''),
'modules': json.dumps(body.get('modules', [])),
'config': json.dumps(body.get('config', {})),
}
entity_id = body.get('entity_id')
if entity_id:
vals['entity_id'] = int(entity_id)
record = request.env['encoach.exam.structure'].create(vals)
return _json_response({
'id': record.id,
'name': record.name,
'entity_id': record.entity_id.id if record.entity_id else None,
'industry': record.industry or '',
'modules': json.loads(record.modules) if record.modules else [],
})
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='user', methods=['DELETE'], csrf=False)
def delete_structure(self, structure_id, **kw):
record = request.env['encoach.exam.structure'].browse(structure_id)
if not record.exists():
return _json_response({'error': 'Structure not found'}, status=404)
record.unlink()
return _json_response({'success': True})

View File

@@ -0,0 +1,504 @@
import json
import logging
from odoo import http, fields
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__)
IELTS_SKILLS = ['listening', 'reading', 'writing', 'speaking']
def _section_to_dict(sec):
return {
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'question_count': sec.question_count or 0,
'time_limit_min': sec.time_limit_min or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
'question_ids': sec.question_ids.ids,
}
def _exam_to_dict(exam):
return {
'id': exam.id,
'title': exam.title,
'template_id': exam.template_id.id if exam.template_id else None,
'subject_id': exam.subject_id.id if exam.subject_id else None,
'entity_id': exam.entity_id.id if exam.entity_id else None,
'teacher_id': exam.teacher_id.id if exam.teacher_id else None,
'description': exam.description or '',
'total_time_min': exam.total_time_min or 0,
'pass_threshold': exam.pass_threshold or 0.0,
'results_release_mode': exam.results_release_mode or 'auto',
'randomize_questions': exam.randomize_questions,
'status': exam.status,
'sections': [_section_to_dict(s) for s in exam.section_ids],
}
def _question_to_dict(q):
options = None
if q.options:
try:
options = json.loads(q.options)
except (json.JSONDecodeError, TypeError):
options = q.options
return {
'id': q.id,
'skill': q.skill,
'question_type': q.question_type,
'stem': q.stem,
'options': options,
'marks': q.marks,
'difficulty': q.difficulty,
'status': q.status,
'ielts_certified': q.ielts_certified,
}
class EncoachIeltsExamController(http.Controller):
# ------------------------------------------------------------------
# POST /api/exam/ielts/create
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/create', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create(self, **kw):
try:
body = _get_json_body()
template_id = body.get('template_id')
title = (body.get('title') or '').strip()
entity_id = body.get('entity_id')
if not template_id or not title:
return _error_response('template_id and title are required', 400)
template = request.env['encoach.exam.template'].sudo().browse(
int(template_id))
if not template.exists():
return _error_response('Template not found', 404)
exam = request.env['encoach.exam.custom'].sudo().create({
'title': title,
'template_id': template.id,
'subject_id': template.subject_id.id if template.subject_id else False,
'entity_id': int(entity_id) if entity_id else False,
'teacher_id': request.env.user.id,
'total_time_min': template.total_time_min or 0,
'pass_threshold': template.pass_threshold or 0.0,
'results_release_mode': template.results_release_mode or 'auto',
'status': 'draft',
})
structure = None
if template.structure:
try:
structure = json.loads(template.structure)
except (json.JSONDecodeError, TypeError):
structure = None
Section = request.env['encoach.exam.custom.section'].sudo()
if structure and isinstance(structure, list):
for idx, sec_def in enumerate(structure):
Section.create({
'exam_id': exam.id,
'title': sec_def.get('title', f'Section {idx + 1}'),
'skill': sec_def.get('skill', ''),
'question_count': sec_def.get('question_count', 0),
'time_limit_min': sec_def.get('time_limit_min', 0),
'scoring_method': sec_def.get('scoring_method', 'auto'),
'sequence': sec_def.get('sequence', (idx + 1) * 10),
})
else:
for idx, skill in enumerate(IELTS_SKILLS):
Section.create({
'exam_id': exam.id,
'title': skill.capitalize(),
'skill': skill,
'question_count': 40 if skill in ('listening', 'reading') else 0,
'time_limit_min': {'listening': 30, 'reading': 60,
'writing': 60, 'speaking': 15}.get(skill, 30),
'scoring_method': 'auto' if skill in ('listening', 'reading') else 'rubric',
'sequence': (idx + 1) * 10,
})
exam.invalidate_recordset()
return _json_response(_exam_to_dict(exam), 201)
except Exception as e:
_logger.exception('ielts create failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('ielts get failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/exam/ielts/<int:exam_id>
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be updated', 400)
body = _get_json_body()
allowed = {'title', 'description', 'total_time_min', 'pass_threshold',
'results_release_mode', 'randomize_questions'}
vals = {k: v for k, v in body.items() if k in allowed}
if vals:
exam.write(vals)
return _json_response(_exam_to_dict(exam))
except Exception as e:
_logger.exception('ielts update failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>/content-pool-count
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/content-pool-count',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def content_pool_count(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
Question = request.env['encoach.question'].sudo()
base_domain = [('status', '=', 'active')]
if exam.subject_id:
base_domain.append(('subject_id', '=', exam.subject_id.id))
counts = {}
for skill in IELTS_SKILLS:
domain = base_domain + [('skill', '=', skill)]
counts[skill] = Question.search_count(domain)
return _json_response(counts)
except Exception as e:
_logger.exception('content_pool_count failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>/content-pool
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/content-pool',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def content_pool(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
domain = [('status', '=', 'active')]
if exam.subject_id:
domain.append(('subject_id', '=', exam.subject_id.id))
skill = kw.get('skill')
if skill:
domain.append(('skill', '=', skill))
difficulty = kw.get('difficulty')
if difficulty:
domain.append(('difficulty', '=', difficulty))
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Question = request.env['encoach.question'].sudo()
records, total = _paginate(Question, domain, page=page, size=size)
return _json_response({
'items': [_question_to_dict(q) for q in records],
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('content_pool failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/ielts/<int:exam_id>/auto-assemble
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/auto-assemble',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def auto_assemble(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
if exam.status != 'draft':
return _error_response('Only draft exams can be auto-assembled', 400)
Question = request.env['encoach.question'].sudo()
base_domain = [('status', '=', 'active')]
if exam.subject_id:
base_domain.append(('subject_id', '=', exam.subject_id.id))
assembled = []
for section in exam.section_ids:
needed = section.question_count or 10
domain = base_domain + [('skill', '=', section.skill)] if section.skill else base_domain
candidates = Question.search(domain, order='difficulty asc, id asc')
easy = candidates.filtered(lambda q: q.difficulty == 'easy')
medium = candidates.filtered(lambda q: q.difficulty == 'medium')
hard = candidates.filtered(lambda q: q.difficulty == 'hard')
easy_count = max(1, needed // 3)
hard_count = max(1, needed // 3)
medium_count = needed - easy_count - hard_count
selected_ids = []
selected_ids.extend(easy[:easy_count].ids)
selected_ids.extend(medium[:medium_count].ids)
selected_ids.extend(hard[:hard_count].ids)
if len(selected_ids) < needed:
remaining = candidates.filtered(lambda q: q.id not in selected_ids)
selected_ids.extend(remaining[:needed - len(selected_ids)].ids)
section.write({'question_ids': [(6, 0, selected_ids)]})
assembled.append({
'section_id': section.id,
'title': section.title,
'skill': section.skill or '',
'questions_assigned': len(selected_ids),
'question_ids': selected_ids,
})
return _json_response({'sections': assembled})
except Exception as e:
_logger.exception('auto_assemble failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/ielts/<int:exam_id>/suggest
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/suggest',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def suggest(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
body = _get_json_body()
section_id = body.get('section_id')
criteria = body.get('criteria', {})
section = request.env['encoach.exam.custom.section'].sudo().browse(
int(section_id)) if section_id else None
if section_id and (not section or not section.exists()):
return _error_response('Section not found', 404)
domain = [('status', '=', 'active')]
if exam.subject_id:
domain.append(('subject_id', '=', exam.subject_id.id))
if section and section.skill:
domain.append(('skill', '=', section.skill))
if criteria.get('difficulty'):
domain.append(('difficulty', '=', criteria['difficulty']))
if criteria.get('question_type'):
domain.append(('question_type', '=', criteria['question_type']))
if section:
current_ids = section.question_ids.ids
if current_ids:
domain.append(('id', 'not in', current_ids))
suggestions = request.env['encoach.question'].sudo().search(
domain, limit=int(criteria.get('limit', 10)))
return _json_response({
'suggestions': [_question_to_dict(q) for q in suggestions],
})
except Exception as e:
_logger.exception('suggest failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# PUT /api/exam/ielts/<int:exam_id>/sections/<int:section_id>/questions
# ------------------------------------------------------------------
@http.route(
'/api/exam/ielts/<int:exam_id>/sections/<int:section_id>/questions',
type='http', auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_section_questions(self, exam_id, section_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
section = request.env['encoach.exam.custom.section'].sudo().browse(
section_id)
if not section.exists() or section.exam_id.id != exam.id:
return _error_response('Section not found in this exam', 404)
body = _get_json_body()
question_ids = body.get('question_ids', [])
if not isinstance(question_ids, list):
return _error_response('question_ids must be a list', 400)
valid = request.env['encoach.question'].sudo().browse(question_ids)
valid_ids = valid.filtered(lambda q: q.exists()).ids
section.write({'question_ids': [(6, 0, valid_ids)]})
return _json_response(_section_to_dict(section))
except Exception as e:
_logger.exception('update_section_questions failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/ielts/<int:exam_id>/validate
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/validate',
type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def validate(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
errors = []
warnings = []
if not exam.section_ids:
errors.append('Exam has no sections')
for section in exam.section_ids:
actual = len(section.question_ids)
expected = section.question_count or 0
if actual == 0:
errors.append(
f'Section "{section.title}" has no questions assigned')
elif expected > 0 and actual < expected:
warnings.append(
f'Section "{section.title}" has {actual}/{expected} questions')
if not section.time_limit_min:
warnings.append(
f'Section "{section.title}" has no time limit set')
if not exam.total_time_min:
warnings.append('Exam has no total time limit set')
has_all_skills = {s.skill for s in exam.section_ids if s.skill}
for skill in IELTS_SKILLS:
if skill not in has_all_skills:
warnings.append(f'Missing IELTS section: {skill}')
return _json_response({
'valid': len(errors) == 0,
'errors': errors,
'warnings': warnings,
})
except Exception as e:
_logger.exception('validate failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/ielts/<int:exam_id>/assign
# ------------------------------------------------------------------
@http.route('/api/exam/ielts/<int:exam_id>/assign',
type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def assign(self, exam_id, **kw):
try:
exam = request.env['encoach.exam.custom'].sudo().browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
body = _get_json_body()
student_ids = body.get('student_ids', [])
batch_id = body.get('batch_id')
access_start = body.get('access_start')
access_end = body.get('access_end')
Assignment = request.env['encoach.exam.assignment'].sudo()
created = 0
if student_ids:
for sid in student_ids:
Assignment.create({
'exam_id': exam.id,
'student_id': int(sid),
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
if batch_id:
try:
batch = request.env['op.batch'].sudo().browse(int(batch_id))
if batch.exists():
batch_students = request.env['op.student'].sudo().search(
[('batch_id', '=', batch.id)])
for student in batch_students:
user = student.user_id if hasattr(student, 'user_id') else None
if user:
Assignment.create({
'exam_id': exam.id,
'student_id': user.id,
'batch_id': batch.id,
'access_start': access_start,
'access_end': access_end,
'status': 'assigned',
})
created += 1
except Exception:
_logger.warning('Batch lookup failed for batch_id=%s', batch_id)
return _json_response({'assignments_created': created})
except Exception as e:
_logger.exception('assign failed')
return _error_response(str(e), 500)

View File

@@ -0,0 +1,130 @@
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__)
def _template_to_dict(rec):
structure = None
if rec.structure:
try:
structure = json.loads(rec.structure)
except (json.JSONDecodeError, TypeError):
structure = rec.structure
return {
'id': rec.id,
'name': rec.name,
'code': rec.code or '',
'type': rec.type,
'editable': rec.editable,
'active': rec.active,
'subject_id': rec.subject_id.id if rec.subject_id else None,
'subject_name': rec.subject_id.name if rec.subject_id else None,
'entity_id': rec.entity_id.id if rec.entity_id else None,
'teacher_id': rec.teacher_id.id if rec.teacher_id else None,
'structure': structure,
'total_time_min': rec.total_time_min or 0,
'pass_threshold': rec.pass_threshold or 0.0,
'results_release_mode': rec.results_release_mode or 'auto',
'randomize_questions': rec.randomize_questions,
'description': rec.description or '',
}
class EncoachTemplatesController(http.Controller):
# ------------------------------------------------------------------
# GET /api/exam/templates
# ------------------------------------------------------------------
@http.route('/api/exam/templates', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_templates(self, **kw):
try:
domain = [('active', '=', True)]
tpl_type = kw.get('type')
if tpl_type in ('international', 'custom'):
domain.append(('type', '=', tpl_type))
subject_id = kw.get('subject_id')
if subject_id:
domain.append(('subject_id', '=', int(subject_id)))
page = int(kw.get('page', 1))
size = int(kw.get('size', 20))
Template = request.env['encoach.exam.template'].sudo()
records, total = _paginate(Template, domain, page=page, size=size)
return _json_response({
'items': [_template_to_dict(r) for r in records],
'total': total,
'page': page,
'size': size,
})
except Exception as e:
_logger.exception('list_templates failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# GET /api/exam/templates/<int:template_id>
# ------------------------------------------------------------------
@http.route('/api/exam/templates/<int:template_id>', type='http',
auth='none', methods=['GET'], csrf=False)
@jwt_required
def get_template(self, template_id, **kw):
try:
rec = request.env['encoach.exam.template'].sudo().browse(template_id)
if not rec.exists():
return _error_response('Template not found', 404)
return _json_response(_template_to_dict(rec))
except Exception as e:
_logger.exception('get_template failed')
return _error_response(str(e), 500)
# ------------------------------------------------------------------
# POST /api/exam/templates/custom
# ------------------------------------------------------------------
@http.route('/api/exam/templates/custom', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_custom_template(self, **kw):
try:
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _error_response('name is required', 400)
structure = body.get('structure')
if structure and not isinstance(structure, str):
structure = json.dumps(structure)
vals = {
'name': name,
'type': 'custom',
'teacher_id': request.env.user.id,
'subject_id': body.get('subject_id'),
'structure': structure,
'total_time_min': body.get('total_time_min', 0),
'pass_threshold': body.get('pass_threshold', 0.0),
'results_release_mode': body.get('results_release_mode', 'auto'),
'randomize_questions': body.get('randomize_questions', False),
'description': body.get('description', ''),
}
rec = request.env['encoach.exam.template'].sudo().create(vals)
return _json_response(_template_to_dict(rec), 201)
except Exception as e:
_logger.exception('create_custom_template failed')
return _error_response(str(e), 500)