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:
@@ -0,0 +1,4 @@
|
||||
from . import templates
|
||||
from . import ielts_exam
|
||||
from . import custom_exam
|
||||
from . import exam_structures
|
||||
364
custom_addons/encoach_exam_template/controllers/custom_exam.py
Normal file
364
custom_addons/encoach_exam_template/controllers/custom_exam.py
Normal 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)
|
||||
@@ -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})
|
||||
504
custom_addons/encoach_exam_template/controllers/ielts_exam.py
Normal file
504
custom_addons/encoach_exam_template/controllers/ielts_exam.py
Normal 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)
|
||||
130
custom_addons/encoach_exam_template/controllers/templates.py
Normal file
130
custom_addons/encoach_exam_template/controllers/templates.py
Normal 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)
|
||||
Reference in New Issue
Block a user