feat(v3): restructure project + add complete frontend
- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
This commit is contained in:
2
custom_addons/encoach_exam_template/__init__.py
Normal file
2
custom_addons/encoach_exam_template/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
39
custom_addons/encoach_exam_template/__manifest__.py
Normal file
39
custom_addons/encoach_exam_template/__manifest__.py
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
'name': 'EnCoach Exam Template',
|
||||
'version': '19.0.1.0',
|
||||
'category': 'Education',
|
||||
'summary': 'Exam templates, content pool (passages, audio, questions, prompts, cards, rubrics)',
|
||||
'author': 'EnCoach',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['encoach_core', 'encoach_taxonomy'],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/ielts_templates.xml',
|
||||
'data/sample_passages.xml',
|
||||
'data/sample_questions.xml',
|
||||
'views/exam_template_views.xml',
|
||||
'views/passage_views.xml',
|
||||
'views/audio_file_views.xml',
|
||||
'views/question_views.xml',
|
||||
'views/writing_prompt_views.xml',
|
||||
'views/speaking_card_views.xml',
|
||||
'views/rubric_views.xml',
|
||||
'views/exam_custom_views.xml',
|
||||
'views/exam_assignment_views.xml',
|
||||
'views/content_pool_action.xml',
|
||||
'views/exam_validation_action.xml',
|
||||
'views/exam_template_menus.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'encoach_exam_template/static/src/js/content_pool.js',
|
||||
'encoach_exam_template/static/src/xml/content_pool.xml',
|
||||
'encoach_exam_template/static/src/css/content_pool.css',
|
||||
'encoach_exam_template/static/src/js/exam_validation.js',
|
||||
'encoach_exam_template/static/src/xml/exam_validation.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import templates
|
||||
from . import ielts_exam
|
||||
from . import custom_exam
|
||||
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)
|
||||
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)
|
||||
117
custom_addons/encoach_exam_template/data/ielts_templates.xml
Normal file
117
custom_addons/encoach_exam_template/data/ielts_templates.xml
Normal file
@@ -0,0 +1,117 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- IELTS Academic Template -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="template_ielts_academic" model="encoach.exam.template">
|
||||
<field name="name">IELTS Academic</field>
|
||||
<field name="code">ielts_academic</field>
|
||||
<field name="type">international</field>
|
||||
<field name="editable" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="total_time_min">170</field>
|
||||
<field name="results_release_mode">manual_approval</field>
|
||||
<field name="description">The International English Language Testing System (IELTS) Academic test measures English language proficiency for academic purposes. It is recognized by over 11,000 organizations worldwide including universities, employers, immigration authorities, and professional bodies. The test assesses all four language skills: listening, reading, writing, and speaking.</field>
|
||||
<field name="structure">{
|
||||
"sections": [
|
||||
{"skill": "listening", "parts": 4, "questions": 40, "time_min": 30},
|
||||
{"skill": "reading", "parts": 3, "questions": 40, "time_min": 60},
|
||||
{"skill": "writing", "parts": 2, "questions": 2, "time_min": 60},
|
||||
{"skill": "speaking", "parts": 3, "questions": 0, "time_min": 15}
|
||||
]
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- IELTS General Training Template -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="template_ielts_general" model="encoach.exam.template">
|
||||
<field name="name">IELTS General Training</field>
|
||||
<field name="code">ielts_general</field>
|
||||
<field name="type">international</field>
|
||||
<field name="editable" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="total_time_min">170</field>
|
||||
<field name="results_release_mode">manual_approval</field>
|
||||
<field name="description">The IELTS General Training test measures English language proficiency in a practical, everyday context. It is suitable for those planning to undertake non-academic training, gain work experience, or immigrate to an English-speaking country. The reading section draws from notices, advertisements, company handbooks, and newspapers.</field>
|
||||
<field name="structure">{
|
||||
"sections": [
|
||||
{"skill": "listening", "parts": 4, "questions": 40, "time_min": 30},
|
||||
{"skill": "reading", "parts": 5, "questions": 40, "time_min": 60},
|
||||
{"skill": "writing", "parts": 2, "questions": 2, "time_min": 60},
|
||||
{"skill": "speaking", "parts": 3, "questions": 0, "time_min": 15}
|
||||
]
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TOEFL iBT Template -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="template_toefl" model="encoach.exam.template">
|
||||
<field name="name">TOEFL iBT</field>
|
||||
<field name="code">toefl</field>
|
||||
<field name="type">international</field>
|
||||
<field name="editable" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="total_time_min">200</field>
|
||||
<field name="results_release_mode">manual_approval</field>
|
||||
<field name="description">The Test of English as a Foreign Language Internet-Based Test (TOEFL iBT) measures the ability to use and understand English at the university level. It evaluates how well reading, listening, speaking, and writing skills are combined to perform academic tasks. Scores are accepted by more than 12,500 institutions in over 160 countries.</field>
|
||||
<field name="structure">{
|
||||
"sections": [
|
||||
{"skill": "reading", "parts": 3, "questions": 30, "time_min": 54},
|
||||
{"skill": "listening", "parts": 3, "questions": 28, "time_min": 41},
|
||||
{"skill": "speaking", "parts": 4, "questions": 4, "time_min": 17},
|
||||
{"skill": "writing", "parts": 2, "questions": 2, "time_min": 50}
|
||||
]
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- STEP Template -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="template_step" model="encoach.exam.template">
|
||||
<field name="name">STEP (Standardized Test of English Proficiency)</field>
|
||||
<field name="code">step</field>
|
||||
<field name="type">international</field>
|
||||
<field name="editable" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="total_time_min">150</field>
|
||||
<field name="pass_threshold">60.0</field>
|
||||
<field name="results_release_mode">auto</field>
|
||||
<field name="description">The Standardized Test of English Proficiency (STEP) is administered by the Saudi National Center for Assessment. It measures English language competency for Saudi students and professionals across listening comprehension, reading comprehension, grammar, and vocabulary. Results are used for university admissions, employment, and scholarship applications within Saudi Arabia.</field>
|
||||
<field name="structure">{
|
||||
"sections": [
|
||||
{"skill": "listening", "parts": 2, "questions": 20, "time_min": 25},
|
||||
{"skill": "reading", "parts": 4, "questions": 40, "time_min": 65},
|
||||
{"skill": "grammar", "parts": 2, "questions": 30, "time_min": 30},
|
||||
{"skill": "vocabulary", "parts": 1, "questions": 10, "time_min": 15}
|
||||
]
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- IC3 Template -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="template_ic3" model="encoach.exam.template">
|
||||
<field name="name">IC3 (Internet and Computing Core Certification)</field>
|
||||
<field name="code">ic3</field>
|
||||
<field name="type">international</field>
|
||||
<field name="editable" eval="False"/>
|
||||
<field name="active" eval="True"/>
|
||||
<field name="total_time_min">150</field>
|
||||
<field name="pass_threshold">70.0</field>
|
||||
<field name="results_release_mode">auto</field>
|
||||
<field name="description">The Internet and Computing Core Certification (IC3) is a global benchmark for basic computer literacy. It validates foundational knowledge across computing fundamentals, key software applications, and internet and networking concepts required for academic and professional environments. The certification is administered by Certiport and recognized internationally.</field>
|
||||
<field name="structure">{
|
||||
"sections": [
|
||||
{"skill": "computing_fundamentals", "parts": 1, "questions": 45, "time_min": 50},
|
||||
{"skill": "key_applications", "parts": 1, "questions": 45, "time_min": 50},
|
||||
{"skill": "living_online", "parts": 1, "questions": 45, "time_min": 50}
|
||||
]
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
90
custom_addons/encoach_exam_template/data/sample_passages.xml
Normal file
90
custom_addons/encoach_exam_template/data/sample_passages.xml
Normal file
@@ -0,0 +1,90 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Passage 1: Easy — Technology -->
|
||||
<!-- CEFR B1 | ~380 words | Academic Reading Section 1 -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="passage_technology" model="encoach.passage">
|
||||
<field name="exam_type">academic</field>
|
||||
<field name="section_num">1</field>
|
||||
<field name="topic_category">Technology</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="approved" eval="True"/>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="cefr_level">b1</field>
|
||||
<field name="body_text">The Rise of Artificial Intelligence in Everyday Life
|
||||
|
||||
Artificial intelligence, once confined to science fiction novels and academic research laboratories, has become an integral part of daily life for millions of people worldwide. From the moment we wake up to an alarm set by a smart assistant to the personalised news feed we scroll through over breakfast, AI systems are quietly working behind the scenes to make our routines more convenient and efficient.
|
||||
|
||||
One of the most visible applications of AI is in virtual assistants such as Siri, Alexa, and Google Assistant. These systems use natural language processing to understand spoken commands and provide relevant responses. Whether it is setting a reminder, playing a song, or answering a factual question, these assistants have become household staples in many countries. Studies show that over 40 percent of adults now use voice-activated assistants at least once a day.
|
||||
|
||||
Recommendation algorithms represent another pervasive form of AI. Streaming platforms like Netflix and Spotify analyse viewing and listening habits to suggest content that users are likely to enjoy. Online retailers employ similar technology to recommend products based on browsing history and past purchases. While these systems can feel remarkably intuitive, they work by identifying patterns in enormous datasets and applying statistical models to predict preferences.
|
||||
|
||||
In healthcare, AI is making significant strides. Machine learning algorithms can now analyse medical images with accuracy that rivals trained radiologists in certain tasks. Diagnostic tools powered by AI help doctors identify conditions ranging from skin cancer to diabetic retinopathy at earlier stages. However, these tools are designed to assist rather than replace medical professionals, and their outputs still require clinical interpretation.
|
||||
|
||||
Transportation is another sector being transformed by artificial intelligence. Navigation applications use real-time traffic data and predictive models to suggest optimal routes. Ride-sharing services employ AI to match passengers with drivers and calculate dynamic pricing. Meanwhile, autonomous vehicle technology continues to advance, with several companies conducting trials of self-driving cars on public roads.
|
||||
|
||||
Despite these benefits, the growing presence of AI raises important questions. Concerns about data privacy, algorithmic bias, and the potential displacement of jobs have prompted calls for stronger regulation and ethical guidelines. As AI becomes more capable, society must grapple with how to harness its potential while mitigating its risks.</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Passage 2: Medium — Environment -->
|
||||
<!-- CEFR B2 | ~540 words | Academic Reading Section 2 -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="passage_environment" model="encoach.passage">
|
||||
<field name="exam_type">academic</field>
|
||||
<field name="section_num">2</field>
|
||||
<field name="topic_category">Environment</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="approved" eval="True"/>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="cefr_level">b2</field>
|
||||
<field name="body_text">Ocean Acidification: The Silent Threat to Marine Ecosystems
|
||||
|
||||
While the atmospheric consequences of rising carbon dioxide levels have received considerable public attention, a parallel crisis is unfolding beneath the surface of the world's oceans. Ocean acidification, sometimes referred to as "climate change's equally evil twin," occurs when seawater absorbs excess carbon dioxide from the atmosphere, triggering chemical reactions that lower the water's pH. Since the beginning of the Industrial Revolution, the oceans have absorbed approximately 30 percent of all human-produced CO2 emissions, causing surface water pH to drop by 0.1 units. Although this figure may appear modest, it represents a 26 percent increase in hydrogen ion concentration, fundamentally altering ocean chemistry on a global scale.
|
||||
|
||||
The process is deceptively straightforward. When CO2 dissolves in seawater, it reacts with water molecules to form carbonic acid, which then dissociates into hydrogen ions and bicarbonate. The surplus hydrogen ions bond with carbonate ions, reducing their availability in the water. This is particularly consequential because carbonate ions are essential building blocks for the calcium carbonate structures produced by a wide range of marine organisms, including corals, molluscs, sea urchins, and certain species of plankton.
|
||||
|
||||
Coral reefs, which support roughly 25 percent of all marine species despite covering less than one percent of the ocean floor, are among the most vulnerable ecosystems. As carbonate ion concentrations decline, corals struggle to build and maintain their skeletal frameworks. Research conducted at multiple reef sites has demonstrated that calcification rates have already decreased by 14 percent compared to pre-industrial levels. In severely acidified conditions, existing coral structures may begin to dissolve, threatening the intricate habitats upon which countless species depend.
|
||||
|
||||
The implications extend beyond reef-building organisms. Pteropods, tiny sea snails that form a critical component of Arctic and Antarctic food webs, produce delicate shells made of aragonite, a particularly soluble form of calcium carbonate. Laboratory studies have shown that exposure to projected end-of-century pH levels causes visible shell dissolution within 48 hours. Since pteropods serve as a primary food source for species ranging from juvenile salmon to baleen whales, their decline could trigger cascading effects throughout marine food chains.
|
||||
|
||||
Perhaps most troubling is the speed at which these changes are occurring. Geological evidence suggests that current acidification rates are at least ten times faster than any natural event in the past 300 million years, leaving marine organisms with little evolutionary time to adapt. Some species may shift their geographic ranges toward more favourable conditions, while others may face population declines or local extinction.
|
||||
|
||||
Mitigating ocean acidification ultimately requires reducing CO2 emissions at their source. However, several complementary strategies are being explored, including the cultivation of seagrass beds and kelp forests, which absorb CO2 through photosynthesis and create localised refugia of higher pH. Researchers are also investigating selective breeding programmes aimed at identifying and propagating acid-resistant genetic variants of key species. While these approaches show promise, scientists emphasise that they cannot substitute for the fundamental changes in energy production and consumption needed to address the root cause of the problem.</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Passage 3: Hard — Research Methodology -->
|
||||
<!-- CEFR C1 | ~640 words | Academic Reading Section 3 -->
|
||||
<!-- ============================================================ -->
|
||||
<record id="passage_research" model="encoach.passage">
|
||||
<field name="exam_type">academic</field>
|
||||
<field name="section_num">3</field>
|
||||
<field name="topic_category">Research Methodology</field>
|
||||
<field name="difficulty">hard</field>
|
||||
<field name="status">active</field>
|
||||
<field name="approved" eval="True"/>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="cefr_level">c1</field>
|
||||
<field name="body_text">Randomised Controlled Trials: Methodological Rigour and Its Limitations
|
||||
|
||||
The randomised controlled trial (RCT) is widely regarded as the gold standard of evidence-based research, occupying the highest tier in most hierarchies of scientific evidence. By randomly assigning participants to experimental and control groups, RCTs minimise the influence of confounding variables and selection bias, thereby isolating the effect of the intervention under investigation. This methodological strength has made RCTs the preferred design for evaluating pharmaceutical treatments, surgical procedures, and, increasingly, educational and social policy interventions. Yet despite their epistemological advantages, RCTs are subject to a range of practical, ethical, and conceptual limitations that researchers and policymakers must carefully consider.
|
||||
|
||||
One fundamental challenge concerns external validity, or the extent to which findings from a controlled trial can be generalised to broader populations and real-world settings. RCTs typically employ stringent inclusion and exclusion criteria to create homogeneous study samples, which enhances internal validity but may produce results that are not representative of the heterogeneous populations encountered in clinical practice. A landmark analysis by Rothwell (2005) found that fewer than 20 percent of patients seen in routine care would have met the eligibility criteria for the trials whose results were used to guide their treatment. This discrepancy raises legitimate questions about the applicability of trial-derived evidence to individual patients with multiple comorbidities, varying genetic backgrounds, and diverse socioeconomic circumstances.
|
||||
|
||||
Ethical constraints further limit the scope of RCTs. The principle of equipoise demands that genuine uncertainty exist regarding the relative merits of the interventions being compared; it is ethically impermissible to randomly withhold a treatment that is believed to be superior. In emergency medicine, obtaining informed consent from patients in acute distress presents additional dilemmas that have prompted the development of exception-from-informed-consent protocols, which themselves remain controversial. Moreover, certain research questions — such as the long-term health effects of environmental pollutants or the consequences of adverse childhood experiences — cannot ethically be addressed through experimental manipulation.
|
||||
|
||||
The issue of blinding introduces another layer of complexity. Double-blind designs, in which neither participants nor investigators are aware of group assignments, are considered ideal for minimising performance and detection bias. However, effective blinding is often difficult or impossible to achieve, particularly in trials of surgical interventions, psychotherapy, or lifestyle modifications. When participants can discern their group allocation, placebo effects and expectation biases may contaminate outcomes, and the Hawthorne effect — whereby individuals modify their behaviour simply because they know they are being observed — can influence results in both experimental and control groups.
|
||||
|
||||
The increasing recognition of these limitations has spurred methodological innovation. Pragmatic trials, which relax eligibility criteria and are conducted in real-world clinical settings, represent one response to concerns about external validity. Adaptive trial designs allow modifications to sample size, dosage, or even study hypotheses based on interim analyses, potentially improving efficiency without compromising statistical rigour. Bayesian approaches offer an alternative framework for incorporating prior evidence and updating probability estimates as data accumulate.
|
||||
|
||||
Nevertheless, these innovations do not eliminate the fundamental tension between the controlled conditions that give RCTs their inferential power and the messy complexity of the environments in which their findings must ultimately be applied. A judicious approach to evidence-based practice requires not only high-quality RCTs but also complementary forms of evidence — including observational studies, qualitative research, and clinical expertise — synthesised through systematic reviews and interpreted with sensitivity to the specific context of application.</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
770
custom_addons/encoach_exam_template/data/sample_questions.xml
Normal file
770
custom_addons/encoach_exam_template/data/sample_questions.xml
Normal file
@@ -0,0 +1,770 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- RUBRICS -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="rubric_writing_academic" model="encoach.rubric">
|
||||
<field name="name">IELTS Academic Writing Assessment Rubric</field>
|
||||
<field name="skill">writing</field>
|
||||
<field name="exam_type">academic</field>
|
||||
<field name="criteria">{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "Task Achievement",
|
||||
"weight": 25,
|
||||
"descriptors": {
|
||||
"9": "Fully addresses all parts of the task. Presents a fully developed position with relevant, extended, and well-supported ideas.",
|
||||
"7": "Addresses all parts of the task. Presents a clear position throughout the response with relevant main ideas that are extended and supported.",
|
||||
"5": "Addresses the task only partially. The format may be inappropriate in places. A position is expressed but development is not always clear.",
|
||||
"3": "Does not adequately address any part of the task. Does not express a clear position. Few ideas are presented with little development."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Coherence and Cohesion",
|
||||
"weight": 25,
|
||||
"descriptors": {
|
||||
"9": "Uses cohesion in such a way that it attracts no attention. Skilfully manages paragraphing.",
|
||||
"7": "Logically organises information and ideas. Uses a range of cohesive devices appropriately although there may be some under- or over-use.",
|
||||
"5": "Presents information with some organisation but there may be a lack of overall progression. Makes inadequate or inaccurate use of cohesive devices.",
|
||||
"3": "Does not organise ideas logically. Relationships between ideas may be unclear or absent."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Lexical Resource",
|
||||
"weight": 25,
|
||||
"descriptors": {
|
||||
"9": "Uses a wide range of vocabulary with very natural and sophisticated control of lexical features. Rare minor errors occur only as slips.",
|
||||
"7": "Uses a sufficient range of vocabulary to allow some flexibility and precision. Uses less common lexical items with some awareness of style and collocation.",
|
||||
"5": "Uses a limited range of vocabulary but this is minimally adequate for the task. May make noticeable errors in spelling or word formation that may cause some difficulty for the reader.",
|
||||
"3": "Uses only a very limited range of words and expressions with very limited control of word formation or spelling."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Grammatical Range and Accuracy",
|
||||
"weight": 25,
|
||||
"descriptors": {
|
||||
"9": "Uses a wide range of structures with full flexibility and accuracy. Rare minor errors occur only as slips.",
|
||||
"7": "Uses a variety of complex structures. Produces frequent error-free sentences. Has good control of grammar and punctuation but may make a few errors.",
|
||||
"5": "Uses only a limited range of structures. Attempts complex sentences but these tend to be less accurate than simple sentences. Errors can cause some difficulty for the reader.",
|
||||
"3": "Attempts sentence forms but errors in grammar and punctuation predominate and distort the meaning."
|
||||
}
|
||||
}
|
||||
]
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
<record id="rubric_speaking_ielts" model="encoach.rubric">
|
||||
<field name="name">IELTS Speaking Assessment Rubric</field>
|
||||
<field name="skill">speaking</field>
|
||||
<field name="exam_type">academic</field>
|
||||
<field name="criteria">{
|
||||
"criteria": [
|
||||
{
|
||||
"name": "Fluency and Coherence",
|
||||
"weight": 25,
|
||||
"descriptors": {
|
||||
"9": "Speaks fluently with only rare repetition or self-correction. Any hesitation is content-related rather than to find words or grammar. Develops topics fully and coherently.",
|
||||
"7": "Speaks at length without noticeable effort or loss of coherence. May demonstrate language-related hesitation at times. Uses a range of connectives and discourse markers with some flexibility.",
|
||||
"5": "Usually maintains flow of speech but uses repetition, self-correction or slow speech to keep going. May over-use certain connectives and discourse markers.",
|
||||
"3": "Speaks with long pauses. Has limited ability to link simple sentences. Gives only simple responses and is frequently unable to convey a basic message."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Lexical Resource",
|
||||
"weight": 25,
|
||||
"descriptors": {
|
||||
"9": "Uses vocabulary with full flexibility and precision in all topics. Uses idiomatic language naturally and accurately.",
|
||||
"7": "Uses vocabulary resource flexibly to discuss a variety of topics. Uses some less common and idiomatic vocabulary with some awareness of style and collocation.",
|
||||
"5": "Manages to talk about familiar and unfamiliar topics but uses vocabulary with limited flexibility. Attempts to use paraphrase but with mixed success.",
|
||||
"3": "Uses simple vocabulary to convey personal information. Has insufficient vocabulary for less familiar topics."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Grammatical Range and Accuracy",
|
||||
"weight": 25,
|
||||
"descriptors": {
|
||||
"9": "Uses a full range of structures naturally and appropriately. Produces consistently accurate structures apart from slips characteristic of native speaker speech.",
|
||||
"7": "Uses a range of complex structures with some flexibility. Frequently produces error-free sentences though some grammatical mistakes persist.",
|
||||
"5": "Produces basic sentence forms with reasonable accuracy. Uses a limited range of more complex structures but these usually contain errors.",
|
||||
"3": "Attempts basic sentence forms but with limited success. Subordinate structures are rare. Errors are frequent and may lead to misunderstanding."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Pronunciation",
|
||||
"weight": 25,
|
||||
"descriptors": {
|
||||
"9": "Uses a full range of pronunciation features with precision and subtlety. Sustains flexible use of features throughout. Is effortless to understand.",
|
||||
"7": "Shows all the positive features of Band 6 and some but not all positive features of Band 8. Generally easy to understand throughout.",
|
||||
"5": "Shows all the positive features of Band 4 and some but not all positive features of Band 6. Pronunciation is generally intelligible but mispronunciation of individual words reduces clarity at times.",
|
||||
"3": "Speech is often unintelligible. Attempts some pronunciation features but control is inconsistent. Mispronunciations are frequent and cause difficulty for the listener."
|
||||
}
|
||||
}
|
||||
]
|
||||
}</field>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- WRITING PROMPTS -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="writing_prompt_task1_graph" model="encoach.writing.prompt">
|
||||
<field name="exam_type">academic</field>
|
||||
<field name="task">task1</field>
|
||||
<field name="writing_type">Line Graph</field>
|
||||
<field name="prompt_text">The graph below shows the percentage of households with internet access in four different countries between 2000 and 2020.
|
||||
|
||||
Summarise the information by selecting and reporting the main features, and make comparisons where relevant.
|
||||
|
||||
Write at least 150 words.</field>
|
||||
<field name="rubric_id" ref="rubric_writing_academic"/>
|
||||
<field name="min_words">150</field>
|
||||
<field name="model_answer">The line graph illustrates the proportion of households that had internet access in four countries — the United States, the United Kingdom, Japan, and Brazil — over a twenty-year period from 2000 to 2020.
|
||||
|
||||
Overall, all four nations experienced significant growth in internet penetration, though the rate and timing of adoption varied considerably. The US and UK consistently maintained the highest levels of access throughout the period.
|
||||
|
||||
In 2000, approximately 40 percent of American households were connected to the internet, making it the leading country. The UK followed closely at around 25 percent, while Japan stood at roughly 20 percent and Brazil at just 5 percent. By 2010, the US and UK had both surpassed 70 percent, and Japan had risen sharply to approximately 75 percent, briefly overtaking the other nations.
|
||||
|
||||
By 2020, all four countries had achieved substantial coverage, with the US, UK, and Japan each exceeding 90 percent. Brazil showed the most dramatic relative growth, climbing from 5 percent to approximately 75 percent, although it remained the lowest among the four countries.</field>
|
||||
<field name="approved" eval="True"/>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="writing_prompt_task2_essay" model="encoach.writing.prompt">
|
||||
<field name="exam_type">academic</field>
|
||||
<field name="task">task2</field>
|
||||
<field name="writing_type">Opinion Essay</field>
|
||||
<field name="prompt_text">Some people believe that the increasing use of technology in education has made students less creative and independent in their thinking. Others argue that technology enhances learning and fosters new forms of creativity.
|
||||
|
||||
Discuss both views and give your own opinion.
|
||||
|
||||
Write at least 250 words.</field>
|
||||
<field name="rubric_id" ref="rubric_writing_academic"/>
|
||||
<field name="min_words">250</field>
|
||||
<field name="model_answer">The role of technology in education is a subject of ongoing debate. While some commentators contend that digital tools stifle creativity and foster dependence, others maintain that technology opens new avenues for innovative thinking. This essay will examine both perspectives before presenting a personal view.
|
||||
|
||||
Those who criticise the growing presence of technology in classrooms argue that it encourages passive consumption of information. Students can now access answers instantly through search engines, which may reduce the motivation to think critically or engage in sustained problem-solving. Furthermore, the standardised nature of many educational software programmes can impose rigid learning pathways that leave little room for divergent thinking or experimentation.
|
||||
|
||||
On the other hand, proponents of educational technology point to the unprecedented creative possibilities it offers. Digital tools such as video editing software, coding platforms, and collaborative design applications enable students to produce work that would have been impossible using traditional methods alone. Online learning environments also expose students to diverse perspectives from around the world, broadening their intellectual horizons and stimulating cross-cultural dialogue.
|
||||
|
||||
In my view, technology is neither inherently beneficial nor detrimental to creativity in education. Its impact depends largely on how it is integrated into the curriculum and how teachers guide students in its use. When technology is employed thoughtfully — as a tool for exploration rather than a substitute for reflection — it can complement traditional methods and enhance both creativity and independent thinking. Educators must therefore be equipped with the training and resources to harness technology's potential while preserving the critical thinking skills that remain essential to meaningful learning.</field>
|
||||
<field name="approved" eval="True"/>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- SPEAKING CARDS -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="speaking_card_part1" model="encoach.speaking.card">
|
||||
<field name="part">1</field>
|
||||
<field name="topic">Work and Studies</field>
|
||||
<field name="questions">[
|
||||
"Do you work or are you a student?",
|
||||
"What do you like most about your job or studies?",
|
||||
"How did you choose your current field of work or study?",
|
||||
"Would you like to change your job or field of study in the future? Why or why not?"
|
||||
]</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="rubric_id" ref="rubric_speaking_ielts"/>
|
||||
<field name="model_response">Sample response for Q1: I'm currently working as a software developer at a technology company. I've been in this role for about three years now.
|
||||
|
||||
Sample response for Q2: What I enjoy most is the problem-solving aspect. Every day presents a new challenge, and there's a great sense of satisfaction when you find an elegant solution to a complex issue. I also appreciate the collaborative nature of my work — bouncing ideas off colleagues often leads to better outcomes.
|
||||
|
||||
Sample response for Q3: I was always interested in computers growing up. When it came time to choose a university course, computer science felt like a natural fit. I did an internship during my second year and that really confirmed my passion for programming.
|
||||
|
||||
Sample response for Q4: I'd like to stay in technology but perhaps move towards a more leadership-oriented role. I find that I enjoy mentoring junior developers, so managing a team could be an interesting next step for me.</field>
|
||||
<field name="approved" eval="True"/>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="speaking_card_part2" model="encoach.speaking.card">
|
||||
<field name="part">2</field>
|
||||
<field name="topic">Describe a Place You Have Visited That Left a Strong Impression</field>
|
||||
<field name="bullet_points">[
|
||||
"Where the place is",
|
||||
"When you visited it",
|
||||
"What you did there",
|
||||
"Explain why it left a strong impression on you"
|
||||
]</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="rubric_id" ref="rubric_speaking_ielts"/>
|
||||
<field name="model_response">I'd like to talk about my visit to Kyoto, Japan, which I visited about two years ago during the autumn season.
|
||||
|
||||
Kyoto is located in the Kansai region of Japan and it was the imperial capital for over a thousand years. I spent five days there as part of a longer trip through Japan.
|
||||
|
||||
While I was there, I visited several of the famous temples and shrines, including Kinkaku-ji, the Golden Pavilion, and the Fushimi Inari shrine with its thousands of orange torii gates. I also took part in a traditional tea ceremony and spent an afternoon wandering through the Arashiyama bamboo grove, which was absolutely breathtaking.
|
||||
|
||||
What made the experience so memorable was the contrast between old and new. You could walk from a centuries-old zen garden into a modern shopping district within minutes. The autumn colours were spectacular — the maple trees had turned vivid shades of red and gold, and the whole city seemed to glow. But more than the visual beauty, it was the sense of calm and mindfulness that pervaded every interaction. Even in busy tourist areas, there was an underlying tranquility that I found deeply refreshing. It genuinely changed my perspective on how a city can balance preservation of heritage with modern development.</field>
|
||||
<field name="approved" eval="True"/>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="speaking_card_part3" model="encoach.speaking.card">
|
||||
<field name="part">3</field>
|
||||
<field name="topic">Travel and Cultural Understanding</field>
|
||||
<field name="questions">[
|
||||
"How important is it for people to travel to other countries?",
|
||||
"Do you think tourism has a positive or negative effect on local communities?",
|
||||
"In what ways has globalisation changed the way people travel?",
|
||||
"Some people say that international travel promotes understanding between cultures. Do you agree?",
|
||||
"How do you think travel will change in the next twenty years?"
|
||||
]</field>
|
||||
<field name="linked_card_id" ref="speaking_card_part2"/>
|
||||
<field name="difficulty">hard</field>
|
||||
<field name="rubric_id" ref="rubric_speaking_ielts"/>
|
||||
<field name="model_response">Sample response for Q1: I believe international travel is extremely valuable, though I wouldn't say it's essential for everyone. When you immerse yourself in a different culture, you gain a perspective that simply can't be replicated through books or documentaries. You develop empathy and a broader understanding of how diverse human experiences really are.
|
||||
|
||||
Sample response for Q2: This is a complex issue. Tourism can bring significant economic benefits — job creation, infrastructure development, and cultural exchange. However, overtourism can damage local environments, inflate living costs, and sometimes reduce cultural sites to superficial attractions. I think the key lies in sustainable tourism practices that prioritise the wellbeing of local communities.
|
||||
|
||||
Sample response for Q3: Globalisation has made travel far more accessible and affordable. Budget airlines, online booking platforms, and social media have opened up destinations that were once obscure. However, this accessibility has also led to a degree of homogenisation — you can find the same chain hotels and restaurants almost anywhere. There's a tension between convenience and authentic cultural experience.
|
||||
|
||||
Sample response for Q4: I do agree, to an extent. Direct exposure to different ways of life can break down stereotypes and build genuine understanding. However, the depth of that understanding depends on the traveller's openness and the nature of the trip. A superficial holiday may reinforce stereotypes rather than challenge them.
|
||||
|
||||
Sample response for Q5: I think we'll see a stronger emphasis on sustainable and eco-friendly travel. Technology will play a larger role — virtual reality tours, AI-powered translation tools, and perhaps even carbon-neutral aviation. Remote work may also blur the lines between travel and living, with more people becoming long-term nomads rather than short-term tourists.</field>
|
||||
<field name="approved" eval="True"/>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- LISTENING QUESTIONS (5) -->
|
||||
<!-- MCQ type, various difficulties, with IRT parameters -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="q_listen_01" model="encoach.question">
|
||||
<field name="skill">listening</field>
|
||||
<field name="source_type">audio</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">What time does the university library close on Saturdays?</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "4:00 PM"},
|
||||
{"key": "B", "text": "5:30 PM"},
|
||||
{"key": "C", "text": "6:00 PM"},
|
||||
{"key": "D", "text": "8:00 PM"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">0.85</field>
|
||||
<field name="irt_b">-1.2</field>
|
||||
<field name="irt_c">0.22</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_listen_02" model="encoach.question">
|
||||
<field name="skill">listening</field>
|
||||
<field name="source_type">audio</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">How much does the monthly gym membership cost for students?</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "15 pounds"},
|
||||
{"key": "B", "text": "20 pounds"},
|
||||
{"key": "C", "text": "25 pounds"},
|
||||
{"key": "D", "text": "30 pounds"}
|
||||
]</field>
|
||||
<field name="correct_answer">C</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">0.90</field>
|
||||
<field name="irt_b">-1.0</field>
|
||||
<field name="irt_c">0.20</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_listen_03" model="encoach.question">
|
||||
<field name="skill">listening</field>
|
||||
<field name="source_type">audio</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">What is the main purpose of the new campus wellness centre?</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "To provide free medical consultations"},
|
||||
{"key": "B", "text": "To offer mental health support and stress management workshops"},
|
||||
{"key": "C", "text": "To serve as additional study space during exam periods"},
|
||||
{"key": "D", "text": "To host social events for international students"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.15</field>
|
||||
<field name="irt_b">0.1</field>
|
||||
<field name="irt_c">0.18</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_listen_04" model="encoach.question">
|
||||
<field name="skill">listening</field>
|
||||
<field name="source_type">audio</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">According to the discussion between the two students, the main limitation of their research project was</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "insufficient funding for data collection"},
|
||||
{"key": "B", "text": "the small sample size of the participant group"},
|
||||
{"key": "C", "text": "the lack of available academic literature"},
|
||||
{"key": "D", "text": "time constraints imposed by the university"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.30</field>
|
||||
<field name="irt_b">0.3</field>
|
||||
<field name="irt_c">0.20</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_listen_05" model="encoach.question">
|
||||
<field name="skill">listening</field>
|
||||
<field name="source_type">audio</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">The lecturer suggests that the primary factor driving the decline in pollinator populations is</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "widespread use of neonicotinoid pesticides"},
|
||||
{"key": "B", "text": "the cumulative effect of habitat fragmentation and monoculture farming"},
|
||||
{"key": "C", "text": "climate change altering seasonal flowering patterns"},
|
||||
{"key": "D", "text": "the introduction of non-native bee species competing for resources"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">hard</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.70</field>
|
||||
<field name="irt_b">1.1</field>
|
||||
<field name="irt_c">0.15</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- READING QUESTIONS (5) -->
|
||||
<!-- Mix of MCQ, TFNG, gap_fill — referencing passages -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="q_read_01" model="encoach.question">
|
||||
<field name="skill">reading</field>
|
||||
<field name="source_type">passage</field>
|
||||
<field name="source_id" eval="ref('passage_technology')"/>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">What is the main idea of the passage about artificial intelligence?</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "AI technology is too dangerous to be used in everyday life"},
|
||||
{"key": "B", "text": "AI has become widely integrated into many aspects of daily routines"},
|
||||
{"key": "C", "text": "Virtual assistants are the only significant application of AI"},
|
||||
{"key": "D", "text": "Most people are unaware that AI exists in their devices"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">0.80</field>
|
||||
<field name="irt_b">-1.5</field>
|
||||
<field name="irt_c">0.25</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_read_02" model="encoach.question">
|
||||
<field name="skill">reading</field>
|
||||
<field name="source_type">passage</field>
|
||||
<field name="source_id" eval="ref('passage_environment')"/>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">According to the passage, ocean acidification is caused by</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "rising sea temperatures due to global warming"},
|
||||
{"key": "B", "text": "seawater absorbing excess carbon dioxide from the atmosphere"},
|
||||
{"key": "C", "text": "industrial chemicals being released directly into the oceans"},
|
||||
{"key": "D", "text": "the natural erosion of underwater limestone formations"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.10</field>
|
||||
<field name="irt_b">-0.3</field>
|
||||
<field name="irt_c">0.22</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_read_03" model="encoach.question">
|
||||
<field name="skill">reading</field>
|
||||
<field name="source_type">passage</field>
|
||||
<field name="source_id" eval="ref('passage_technology')"/>
|
||||
<field name="question_type">tfng</field>
|
||||
<field name="stem">Over 40 percent of adults use voice-activated assistants at least once a day.</field>
|
||||
<field name="options">[
|
||||
{"key": "T", "text": "True"},
|
||||
{"key": "F", "text": "False"},
|
||||
{"key": "NG", "text": "Not Given"}
|
||||
]</field>
|
||||
<field name="correct_answer">T</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">0.90</field>
|
||||
<field name="irt_b">-0.8</field>
|
||||
<field name="irt_c">0.30</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_read_04" model="encoach.question">
|
||||
<field name="skill">reading</field>
|
||||
<field name="source_type">passage</field>
|
||||
<field name="source_id" eval="ref('passage_environment')"/>
|
||||
<field name="question_type">tfng</field>
|
||||
<field name="stem">Pteropod shells dissolve within 24 hours when exposed to projected end-of-century pH levels.</field>
|
||||
<field name="options">[
|
||||
{"key": "T", "text": "True"},
|
||||
{"key": "F", "text": "False"},
|
||||
{"key": "NG", "text": "Not Given"}
|
||||
]</field>
|
||||
<field name="correct_answer">F</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.25</field>
|
||||
<field name="irt_b">0.2</field>
|
||||
<field name="irt_c">0.28</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_read_05" model="encoach.question">
|
||||
<field name="skill">reading</field>
|
||||
<field name="source_type">passage</field>
|
||||
<field name="source_id" eval="ref('passage_research')"/>
|
||||
<field name="question_type">gap_fill</field>
|
||||
<field name="stem">According to Rothwell's analysis, fewer than ___ percent of patients in routine care would have met the eligibility criteria for relevant clinical trials.</field>
|
||||
<field name="correct_answer">20</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">hard</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.60</field>
|
||||
<field name="irt_b">1.3</field>
|
||||
<field name="irt_c">0.10</field>
|
||||
<field name="ielts_certified" eval="True"/>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- GRAMMAR QUESTIONS (5) -->
|
||||
<!-- MCQ type, testing tenses, conditionals, passive, subjunctive -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="q_gram_01" model="encoach.question">
|
||||
<field name="skill">grammar</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">She ___ to the office by bus every morning.</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "is going"},
|
||||
{"key": "B", "text": "goes"},
|
||||
{"key": "C", "text": "go"},
|
||||
{"key": "D", "text": "gone"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">0.75</field>
|
||||
<field name="irt_b">-1.8</field>
|
||||
<field name="irt_c">0.22</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_gram_02" model="encoach.question">
|
||||
<field name="skill">grammar</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">They ___ the research project by the end of last month.</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "completed"},
|
||||
{"key": "B", "text": "had completed"},
|
||||
{"key": "C", "text": "have completed"},
|
||||
{"key": "D", "text": "were completing"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">0.85</field>
|
||||
<field name="irt_b">-1.0</field>
|
||||
<field name="irt_c">0.20</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_gram_03" model="encoach.question">
|
||||
<field name="skill">grammar</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">If I had known about the schedule change, I ___ on time for the meeting.</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "would arrive"},
|
||||
{"key": "B", "text": "would have arrived"},
|
||||
{"key": "C", "text": "will arrive"},
|
||||
{"key": "D", "text": "arrived"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.20</field>
|
||||
<field name="irt_b">0.0</field>
|
||||
<field name="irt_c">0.18</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_gram_04" model="encoach.question">
|
||||
<field name="skill">grammar</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">The annual report ___ by the finance committee and submitted to the board yesterday.</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "reviewed"},
|
||||
{"key": "B", "text": "was reviewed"},
|
||||
{"key": "C", "text": "has been reviewed"},
|
||||
{"key": "D", "text": "is reviewed"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.10</field>
|
||||
<field name="irt_b">-0.2</field>
|
||||
<field name="irt_c">0.20</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_gram_05" model="encoach.question">
|
||||
<field name="skill">grammar</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">It is essential that every participant ___ the consent form before the experiment begins.</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "signs"},
|
||||
{"key": "B", "text": "sign"},
|
||||
{"key": "C", "text": "signed"},
|
||||
{"key": "D", "text": "will sign"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">hard</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.55</field>
|
||||
<field name="irt_b">0.9</field>
|
||||
<field name="irt_c">0.15</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- VOCABULARY QUESTIONS (5) -->
|
||||
<!-- MCQ type, academic vocabulary, various difficulties -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="q_vocab_01" model="encoach.question">
|
||||
<field name="skill">vocabulary</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">Choose the word closest in meaning to "abundant".</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "scarce"},
|
||||
{"key": "B", "text": "plentiful"},
|
||||
{"key": "C", "text": "expensive"},
|
||||
{"key": "D", "text": "artificial"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">0.80</field>
|
||||
<field name="irt_b">-1.4</field>
|
||||
<field name="irt_c">0.22</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_vocab_02" model="encoach.question">
|
||||
<field name="skill">vocabulary</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">The word "detrimental" in the passage is closest in meaning to</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "beneficial"},
|
||||
{"key": "B", "text": "irrelevant"},
|
||||
{"key": "C", "text": "harmful"},
|
||||
{"key": "D", "text": "surprising"}
|
||||
]</field>
|
||||
<field name="correct_answer">C</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">0.85</field>
|
||||
<field name="irt_b">-1.2</field>
|
||||
<field name="irt_c">0.20</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_vocab_03" model="encoach.question">
|
||||
<field name="skill">vocabulary</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">In academic writing, the word "paradigm" most commonly refers to</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "a statistical method used in data analysis"},
|
||||
{"key": "B", "text": "a widely accepted framework of theories and assumptions"},
|
||||
{"key": "C", "text": "a type of experimental research design"},
|
||||
{"key": "D", "text": "a formal citation style used in publications"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.15</field>
|
||||
<field name="irt_b">0.1</field>
|
||||
<field name="irt_c">0.18</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_vocab_04" model="encoach.question">
|
||||
<field name="skill">vocabulary</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">Choose the word that best completes the sentence: "The study yielded ___ results that challenged several long-held assumptions."</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "negligible"},
|
||||
{"key": "B", "text": "empirical"},
|
||||
{"key": "C", "text": "ambiguous"},
|
||||
{"key": "D", "text": "compelling"}
|
||||
]</field>
|
||||
<field name="correct_answer">D</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.25</field>
|
||||
<field name="irt_b">0.4</field>
|
||||
<field name="irt_c">0.20</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_vocab_05" model="encoach.question">
|
||||
<field name="skill">vocabulary</field>
|
||||
<field name="question_type">mcq</field>
|
||||
<field name="stem">The term "epistemological" most closely relates to</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "the study of moral principles and ethical behaviour"},
|
||||
{"key": "B", "text": "the philosophical investigation of knowledge and justified belief"},
|
||||
{"key": "C", "text": "the classification of biological organisms"},
|
||||
{"key": "D", "text": "the analysis of statistical distributions in large datasets"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">1.0</field>
|
||||
<field name="difficulty">hard</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.80</field>
|
||||
<field name="irt_b">1.5</field>
|
||||
<field name="irt_c">0.12</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MATH QUESTIONS (3) -->
|
||||
<!-- Numerical and expression types for mathematics assessment -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="q_math_01" model="encoach.question">
|
||||
<field name="skill">math</field>
|
||||
<field name="question_type">numerical</field>
|
||||
<field name="stem">A factory produces 240 units per hour. Due to a process improvement, production increases by 15%. How many units does the factory now produce in an 8-hour shift?</field>
|
||||
<field name="correct_answer">2208</field>
|
||||
<field name="marks">2.0</field>
|
||||
<field name="difficulty">easy</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">0.75</field>
|
||||
<field name="irt_b">-1.0</field>
|
||||
<field name="irt_c">0.10</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_math_02" model="encoach.question">
|
||||
<field name="skill">math</field>
|
||||
<field name="question_type">numerical</field>
|
||||
<field name="stem">A right circular cone has a base radius of 6 cm and a slant height of 10 cm. Calculate the total surface area of the cone in cm². Give your answer to the nearest whole number. (Use π = 3.14159)</field>
|
||||
<field name="correct_answer">301</field>
|
||||
<field name="marks">3.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.30</field>
|
||||
<field name="irt_b">0.4</field>
|
||||
<field name="irt_c">0.10</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_math_03" model="encoach.question">
|
||||
<field name="skill">math</field>
|
||||
<field name="question_type">expression</field>
|
||||
<field name="stem">Simplify the following expression completely:
|
||||
|
||||
(x² - 9) / (x² + 5x + 6)
|
||||
|
||||
Express your answer as a single fraction in lowest terms.</field>
|
||||
<field name="correct_answer">(x - 3) / (x + 2)</field>
|
||||
<field name="marks">3.0</field>
|
||||
<field name="difficulty">hard</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.65</field>
|
||||
<field name="irt_b">1.2</field>
|
||||
<field name="irt_c">0.10</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- IT QUESTIONS (2) -->
|
||||
<!-- code_output and sql_query types for IT assessment -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="q_it_01" model="encoach.question">
|
||||
<field name="skill">it</field>
|
||||
<field name="question_type">code_output</field>
|
||||
<field name="stem">What is the output of the following Python code?
|
||||
|
||||
```
|
||||
values = [10, 20, 30, 40, 50]
|
||||
result = [v * 2 for v in values if v > 20]
|
||||
print(sum(result))
|
||||
```</field>
|
||||
<field name="options">[
|
||||
{"key": "A", "text": "120"},
|
||||
{"key": "B", "text": "240"},
|
||||
{"key": "C", "text": "300"},
|
||||
{"key": "D", "text": "180"}
|
||||
]</field>
|
||||
<field name="correct_answer">B</field>
|
||||
<field name="marks">2.0</field>
|
||||
<field name="difficulty">medium</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.20</field>
|
||||
<field name="irt_b">0.2</field>
|
||||
<field name="irt_c">0.20</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="q_it_02" model="encoach.question">
|
||||
<field name="skill">it</field>
|
||||
<field name="question_type">sql_query</field>
|
||||
<field name="stem">Given the following tables:
|
||||
|
||||
employees(id, name, department_id, salary)
|
||||
departments(id, name)
|
||||
|
||||
Write a SQL query that returns the department name and the average salary for each department, but only for departments where the average salary exceeds 50000. Order the results by average salary in descending order.</field>
|
||||
<field name="correct_answer">SELECT d.name, AVG(e.salary) AS avg_salary FROM employees e JOIN departments d ON e.department_id = d.id GROUP BY d.name HAVING AVG(e.salary) > 50000 ORDER BY avg_salary DESC;</field>
|
||||
<field name="marks">3.0</field>
|
||||
<field name="difficulty">hard</field>
|
||||
<field name="status">active</field>
|
||||
<field name="irt_a">1.75</field>
|
||||
<field name="irt_b">1.4</field>
|
||||
<field name="irt_c">0.10</field>
|
||||
<field name="format_validated" eval="True"/>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
10
custom_addons/encoach_exam_template/models/__init__.py
Normal file
10
custom_addons/encoach_exam_template/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from . import rubric
|
||||
from . import exam_template
|
||||
from . import passage
|
||||
from . import audio_file
|
||||
from . import question
|
||||
from . import writing_prompt
|
||||
from . import speaking_card
|
||||
from . import exam_custom
|
||||
from . import exam_custom_section
|
||||
from . import exam_assignment
|
||||
30
custom_addons/encoach_exam_template/models/audio_file.py
Normal file
30
custom_addons/encoach_exam_template/models/audio_file.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachAudioFile(models.Model):
|
||||
_name = 'encoach.audio.file'
|
||||
_description = 'Listening Audio File'
|
||||
|
||||
exam_type = fields.Selection([
|
||||
('academic', 'Academic'),
|
||||
('general_training', 'General Training'),
|
||||
('general_english', 'General English'),
|
||||
], required=True)
|
||||
part = fields.Integer(required=True)
|
||||
context_type = fields.Selection([
|
||||
('conversation', 'Conversation'),
|
||||
('monologue', 'Monologue'),
|
||||
], required=True)
|
||||
topic = fields.Char(size=200, required=True)
|
||||
audio_url = fields.Char(size=500, required=True)
|
||||
transcript = fields.Text()
|
||||
difficulty = fields.Selection([
|
||||
('easy', 'Easy'),
|
||||
('medium', 'Medium'),
|
||||
('hard', 'Hard'),
|
||||
], required=True)
|
||||
ai_generated = fields.Boolean(default=False)
|
||||
approved = fields.Boolean(default=False)
|
||||
ielts_certified = fields.Boolean(default=False)
|
||||
generation_brief = fields.Text()
|
||||
validation_errors = fields.Text()
|
||||
@@ -0,0 +1,18 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachExamAssignment(models.Model):
|
||||
_name = 'encoach.exam.assignment'
|
||||
_description = 'Exam Assignment'
|
||||
|
||||
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
||||
student_id = fields.Many2one('res.users', ondelete='cascade')
|
||||
batch_id = fields.Many2one('op.batch', ondelete='set null')
|
||||
access_start = fields.Datetime()
|
||||
access_end = fields.Datetime()
|
||||
status = fields.Selection([
|
||||
('assigned', 'Assigned'),
|
||||
('started', 'Started'),
|
||||
('completed', 'Completed'),
|
||||
('expired', 'Expired'),
|
||||
], default='assigned', required=True)
|
||||
26
custom_addons/encoach_exam_template/models/exam_custom.py
Normal file
26
custom_addons/encoach_exam_template/models/exam_custom.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachExamCustom(models.Model):
|
||||
_name = 'encoach.exam.custom'
|
||||
_description = 'Custom Exam'
|
||||
|
||||
title = fields.Char(size=200, required=True)
|
||||
template_id = fields.Many2one('encoach.exam.template', ondelete='set null')
|
||||
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
teacher_id = fields.Many2one('res.users', ondelete='set null')
|
||||
description = fields.Text()
|
||||
total_time_min = fields.Integer()
|
||||
pass_threshold = fields.Float()
|
||||
results_release_mode = fields.Selection([
|
||||
('auto', 'Auto'),
|
||||
('manual_approval', 'Manual Approval'),
|
||||
], default='auto')
|
||||
randomize_questions = fields.Boolean(default=False)
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('published', 'Published'),
|
||||
('archived', 'Archived'),
|
||||
], default='draft', required=True)
|
||||
section_ids = fields.One2many('encoach.exam.custom.section', 'exam_id')
|
||||
@@ -0,0 +1,24 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachExamCustomSection(models.Model):
|
||||
_name = 'encoach.exam.custom.section'
|
||||
_description = 'Custom Exam Section'
|
||||
|
||||
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
|
||||
title = fields.Char(size=200, required=True)
|
||||
skill = fields.Char(size=100)
|
||||
question_count = fields.Integer()
|
||||
time_limit_min = fields.Integer()
|
||||
scoring_method = fields.Selection([
|
||||
('auto', 'Auto'),
|
||||
('rubric', 'Rubric'),
|
||||
('mixed', 'Mixed'),
|
||||
], default='auto')
|
||||
sequence = fields.Integer(default=10)
|
||||
question_ids = fields.Many2many(
|
||||
'encoach.question',
|
||||
'exam_custom_section_question_rel',
|
||||
'section_id',
|
||||
'question_id',
|
||||
)
|
||||
27
custom_addons/encoach_exam_template/models/exam_template.py
Normal file
27
custom_addons/encoach_exam_template/models/exam_template.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachExamTemplate(models.Model):
|
||||
_name = 'encoach.exam.template'
|
||||
_description = 'Exam Template'
|
||||
|
||||
name = fields.Char(size=200, required=True)
|
||||
code = fields.Char(size=50)
|
||||
type = fields.Selection([
|
||||
('international', 'International'),
|
||||
('custom', 'Custom'),
|
||||
], required=True)
|
||||
editable = fields.Boolean(default=True)
|
||||
active = fields.Boolean(default=True)
|
||||
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
|
||||
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
|
||||
teacher_id = fields.Many2one('res.users', ondelete='set null')
|
||||
structure = fields.Text()
|
||||
total_time_min = fields.Integer()
|
||||
pass_threshold = fields.Float()
|
||||
results_release_mode = fields.Selection([
|
||||
('auto', 'Auto'),
|
||||
('manual_approval', 'Manual Approval'),
|
||||
], default='auto')
|
||||
randomize_questions = fields.Boolean(default=False)
|
||||
description = fields.Text()
|
||||
46
custom_addons/encoach_exam_template/models/passage.py
Normal file
46
custom_addons/encoach_exam_template/models/passage.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class EncoachPassage(models.Model):
|
||||
_name = 'encoach.passage'
|
||||
_description = 'Reading Passage'
|
||||
|
||||
exam_type = fields.Selection([
|
||||
('academic', 'Academic'),
|
||||
('general_training', 'General Training'),
|
||||
('general_english', 'General English'),
|
||||
], required=True)
|
||||
section_num = fields.Integer(required=True)
|
||||
topic_category = fields.Char(size=100, required=True)
|
||||
body_text = fields.Text(required=True)
|
||||
difficulty = fields.Selection([
|
||||
('easy', 'Easy'),
|
||||
('medium', 'Medium'),
|
||||
('hard', 'Hard'),
|
||||
], required=True)
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('active', 'Active'),
|
||||
('retired', 'Retired'),
|
||||
('flagged', 'Flagged'),
|
||||
], default='draft', required=True)
|
||||
word_count = fields.Integer(compute='_compute_word_count', store=True)
|
||||
ai_generated = fields.Boolean(default=False)
|
||||
approved = fields.Boolean(default=False)
|
||||
ielts_certified = fields.Boolean(default=False)
|
||||
generation_brief = fields.Text()
|
||||
validation_errors = fields.Text()
|
||||
cefr_level = fields.Selection([
|
||||
('pre_a1', 'Pre-A1'),
|
||||
('a1', 'A1'),
|
||||
('a2', 'A2'),
|
||||
('b1', 'B1'),
|
||||
('b2', 'B2'),
|
||||
('c1', 'C1'),
|
||||
('c2', 'C2'),
|
||||
])
|
||||
|
||||
@api.depends('body_text')
|
||||
def _compute_word_count(self):
|
||||
for record in self:
|
||||
record.word_count = len(record.body_text.split()) if record.body_text else 0
|
||||
69
custom_addons/encoach_exam_template/models/question.py
Normal file
69
custom_addons/encoach_exam_template/models/question.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachQuestion(models.Model):
|
||||
_name = 'encoach.question'
|
||||
_description = 'Question Item'
|
||||
|
||||
skill = fields.Selection([
|
||||
('listening', 'Listening'),
|
||||
('reading', 'Reading'),
|
||||
('writing', 'Writing'),
|
||||
('speaking', 'Speaking'),
|
||||
('grammar', 'Grammar'),
|
||||
('vocabulary', 'Vocabulary'),
|
||||
('math', 'Mathematics'),
|
||||
('it', 'Information Technology'),
|
||||
], required=True)
|
||||
source_type = fields.Selection([
|
||||
('passage', 'Passage'),
|
||||
('audio', 'Audio'),
|
||||
('writing_prompt', 'Writing Prompt'),
|
||||
('speaking_card', 'Speaking Card'),
|
||||
])
|
||||
source_id = fields.Integer()
|
||||
question_type = fields.Selection([
|
||||
('mcq', 'MCQ'),
|
||||
('mcq_multi', 'MCQ Multiple'),
|
||||
('tfng', 'True/False/Not Given'),
|
||||
('ynng', 'Yes/No/Not Given'),
|
||||
('gap_fill', 'Gap Fill'),
|
||||
('short_answer', 'Short Answer'),
|
||||
('form_completion', 'Form Completion'),
|
||||
('note_completion', 'Note Completion'),
|
||||
('map_labelling', 'Map Labelling'),
|
||||
('matching', 'Matching'),
|
||||
('summary_completion', 'Summary Completion'),
|
||||
('heading_matching', 'Heading Matching'),
|
||||
('matching_features', 'Matching Features'),
|
||||
('numerical', 'Numerical'),
|
||||
('expression', 'Expression'),
|
||||
('code_completion', 'Code Completion'),
|
||||
('code_output', 'Code Output'),
|
||||
('sql_query', 'SQL Query'),
|
||||
('matrix', 'Matrix'),
|
||||
('graph', 'Graph'),
|
||||
], required=True)
|
||||
stem = fields.Text(required=True)
|
||||
options = fields.Text()
|
||||
correct_answer = fields.Text()
|
||||
marks = fields.Float(default=1.0)
|
||||
difficulty = fields.Selection([
|
||||
('easy', 'Easy'),
|
||||
('medium', 'Medium'),
|
||||
('hard', 'Hard'),
|
||||
], required=True)
|
||||
status = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('active', 'Active'),
|
||||
('retired', 'Retired'),
|
||||
('flagged', 'Flagged'),
|
||||
], default='draft')
|
||||
irt_a = fields.Float(default=1.0)
|
||||
irt_b = fields.Float(default=0.0)
|
||||
irt_c = fields.Float(default=0.25)
|
||||
ai_generated = fields.Boolean(default=False)
|
||||
ielts_certified = fields.Boolean(default=False)
|
||||
format_validated = fields.Boolean(default=False)
|
||||
subject_id = fields.Many2one('encoach.subject', ondelete='set null')
|
||||
topic_id = fields.Many2one('encoach.topic', ondelete='set null')
|
||||
18
custom_addons/encoach_exam_template/models/rubric.py
Normal file
18
custom_addons/encoach_exam_template/models/rubric.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachRubric(models.Model):
|
||||
_name = 'encoach.rubric'
|
||||
_description = 'Scoring Rubric'
|
||||
|
||||
name = fields.Char(size=200, required=True)
|
||||
skill = fields.Selection([
|
||||
('writing', 'Writing'),
|
||||
('speaking', 'Speaking'),
|
||||
], required=True)
|
||||
criteria = fields.Text(required=True)
|
||||
exam_type = fields.Selection([
|
||||
('academic', 'Academic'),
|
||||
('general_training', 'General Training'),
|
||||
('general_english', 'General English'),
|
||||
])
|
||||
24
custom_addons/encoach_exam_template/models/speaking_card.py
Normal file
24
custom_addons/encoach_exam_template/models/speaking_card.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachSpeakingCard(models.Model):
|
||||
_name = 'encoach.speaking.card'
|
||||
_description = 'Speaking Cue Card'
|
||||
|
||||
part = fields.Integer(required=True)
|
||||
topic = fields.Char(size=200, required=True)
|
||||
questions = fields.Text()
|
||||
bullet_points = fields.Text()
|
||||
linked_card_id = fields.Many2one('encoach.speaking.card', ondelete='set null')
|
||||
rubric_id = fields.Many2one('encoach.rubric', ondelete='set null')
|
||||
difficulty = fields.Selection([
|
||||
('easy', 'Easy'),
|
||||
('medium', 'Medium'),
|
||||
('hard', 'Hard'),
|
||||
], required=True)
|
||||
model_response = fields.Text()
|
||||
ai_generated = fields.Boolean(default=False)
|
||||
approved = fields.Boolean(default=False)
|
||||
ielts_certified = fields.Boolean(default=False)
|
||||
generation_brief = fields.Text()
|
||||
validation_errors = fields.Text()
|
||||
27
custom_addons/encoach_exam_template/models/writing_prompt.py
Normal file
27
custom_addons/encoach_exam_template/models/writing_prompt.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class EncoachWritingPrompt(models.Model):
|
||||
_name = 'encoach.writing.prompt'
|
||||
_description = 'Writing Prompt'
|
||||
|
||||
exam_type = fields.Selection([
|
||||
('academic', 'Academic'),
|
||||
('general_training', 'General Training'),
|
||||
('general_english', 'General English'),
|
||||
], required=True)
|
||||
task = fields.Selection([
|
||||
('task1', 'Task 1'),
|
||||
('task2', 'Task 2'),
|
||||
], required=True)
|
||||
writing_type = fields.Char(size=100)
|
||||
prompt_text = fields.Text(required=True)
|
||||
visual_url = fields.Char(size=500)
|
||||
rubric_id = fields.Many2one('encoach.rubric', ondelete='set null')
|
||||
min_words = fields.Integer()
|
||||
model_answer = fields.Text()
|
||||
ai_generated = fields.Boolean(default=False)
|
||||
approved = fields.Boolean(default=False)
|
||||
ielts_certified = fields.Boolean(default=False)
|
||||
generation_brief = fields.Text()
|
||||
validation_errors = fields.Text()
|
||||
@@ -0,0 +1,11 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_encoach_exam_template_user,encoach.exam.template.user,model_encoach_exam_template,base.group_user,1,1,1,1
|
||||
access_encoach_passage_user,encoach.passage.user,model_encoach_passage,base.group_user,1,1,1,1
|
||||
access_encoach_audio_file_user,encoach.audio.file.user,model_encoach_audio_file,base.group_user,1,1,1,1
|
||||
access_encoach_question_user,encoach.question.user,model_encoach_question,base.group_user,1,1,1,1
|
||||
access_encoach_writing_prompt_user,encoach.writing.prompt.user,model_encoach_writing_prompt,base.group_user,1,1,1,1
|
||||
access_encoach_speaking_card_user,encoach.speaking.card.user,model_encoach_speaking_card,base.group_user,1,1,1,1
|
||||
access_encoach_rubric_user,encoach.rubric.user,model_encoach_rubric,base.group_user,1,1,1,1
|
||||
access_encoach_exam_custom_user,encoach.exam.custom.user,model_encoach_exam_custom,base.group_user,1,1,1,1
|
||||
access_encoach_exam_custom_section_user,encoach.exam.custom.section.user,model_encoach_exam_custom_section,base.group_user,1,1,1,1
|
||||
access_encoach_exam_assignment_user,encoach.exam.assignment.user,model_encoach_exam_assignment,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,36 @@
|
||||
.o_content_pool .cp-split-panel {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
min-height: 65vh;
|
||||
}
|
||||
.o_content_pool .cp-question-list {
|
||||
flex: 0 0 60%;
|
||||
min-width: 0;
|
||||
}
|
||||
.o_content_pool .cp-selected-panel {
|
||||
flex: 0 0 calc(40% - 1rem);
|
||||
min-width: 0;
|
||||
}
|
||||
.o_content_pool .cp-scroll {
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.o_content_pool .cp-scroll-selected {
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.o_content_pool .cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_content_pool .table-hover tbody tr:hover {
|
||||
background-color: rgba(13, 110, 253, 0.04);
|
||||
}
|
||||
.o_content_pool .table-hover tbody tr.table-active {
|
||||
background-color: rgba(13, 110, 253, 0.08);
|
||||
}
|
||||
.o_content_pool .form-check-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
.o_content_pool .card {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Layout } from "@web/search/layout";
|
||||
|
||||
class ContentPoolBrowser extends Component {
|
||||
static template = "encoach_exam_template.ContentPool";
|
||||
static components = { Layout };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
filters: { skill: "", difficulty: "", status: "active", search: "" },
|
||||
questions: [],
|
||||
selectedQuestions: [],
|
||||
totalCount: 0,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
onWillStart(() => this.loadQuestions());
|
||||
}
|
||||
|
||||
async loadQuestions() {
|
||||
this.state.loading = true;
|
||||
const domain = this._buildDomain();
|
||||
try {
|
||||
const [questions, count] = await Promise.all([
|
||||
this.orm.searchRead(
|
||||
"encoach.question",
|
||||
domain,
|
||||
["skill", "question_type", "stem", "difficulty", "marks", "irt_b", "subject_id", "topic_id", "status"],
|
||||
{
|
||||
limit: this.state.pageSize,
|
||||
offset: (this.state.page - 1) * this.state.pageSize,
|
||||
order: "skill, difficulty",
|
||||
},
|
||||
),
|
||||
this.orm.searchCount("encoach.question", domain),
|
||||
]);
|
||||
this.state.questions = questions;
|
||||
this.state.totalCount = count;
|
||||
} catch (e) {
|
||||
console.error("Content pool load error:", e);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_buildDomain() {
|
||||
const domain = [];
|
||||
const f = this.state.filters;
|
||||
if (f.status) domain.push(["status", "=", f.status]);
|
||||
if (f.skill) domain.push(["skill", "=", f.skill]);
|
||||
if (f.difficulty) domain.push(["difficulty", "=", f.difficulty]);
|
||||
if (f.search) domain.push(["stem", "ilike", f.search]);
|
||||
return domain;
|
||||
}
|
||||
|
||||
onFilterChange(field, ev) {
|
||||
this.state.filters[field] = ev.target.value;
|
||||
this.state.page = 1;
|
||||
this.loadQuestions();
|
||||
}
|
||||
|
||||
onSearchInput(ev) {
|
||||
this.state.filters.search = ev.target.value;
|
||||
}
|
||||
|
||||
onSearchKeydown(ev) {
|
||||
if (ev.key === "Enter") {
|
||||
this.state.page = 1;
|
||||
this.loadQuestions();
|
||||
}
|
||||
}
|
||||
|
||||
applySearch() {
|
||||
this.state.page = 1;
|
||||
this.loadQuestions();
|
||||
}
|
||||
|
||||
toggleQuestion(question) {
|
||||
const idx = this.state.selectedQuestions.findIndex(q => q.id === question.id);
|
||||
if (idx >= 0) {
|
||||
this.state.selectedQuestions.splice(idx, 1);
|
||||
} else {
|
||||
this.state.selectedQuestions.push({ ...question });
|
||||
}
|
||||
}
|
||||
|
||||
isSelected(questionId) {
|
||||
return this.state.selectedQuestions.some(q => q.id === questionId);
|
||||
}
|
||||
|
||||
removeSelected(questionId) {
|
||||
const idx = this.state.selectedQuestions.findIndex(q => q.id === questionId);
|
||||
if (idx >= 0) this.state.selectedQuestions.splice(idx, 1);
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.state.selectedQuestions = [];
|
||||
}
|
||||
|
||||
nextPage() {
|
||||
if (this.state.page * this.state.pageSize < this.state.totalCount) {
|
||||
this.state.page++;
|
||||
this.loadQuestions();
|
||||
}
|
||||
}
|
||||
|
||||
prevPage() {
|
||||
if (this.state.page > 1) {
|
||||
this.state.page--;
|
||||
this.loadQuestions();
|
||||
}
|
||||
}
|
||||
|
||||
truncate(text, len) {
|
||||
if (!text) return "";
|
||||
return text.length > len ? text.substring(0, len) + "..." : text;
|
||||
}
|
||||
|
||||
skillLabel(val) {
|
||||
const map = { listening: "Listening", reading: "Reading", writing: "Writing", speaking: "Speaking", grammar: "Grammar", vocabulary: "Vocabulary" };
|
||||
return map[val] || val;
|
||||
}
|
||||
|
||||
difficultyClass(val) {
|
||||
return { easy: "bg-success", medium: "bg-warning text-dark", hard: "bg-danger" }[val] || "bg-secondary";
|
||||
}
|
||||
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.state.totalCount / this.state.pageSize));
|
||||
}
|
||||
|
||||
get selectedCount() {
|
||||
return this.state.selectedQuestions.length;
|
||||
}
|
||||
|
||||
get selectedMarks() {
|
||||
return this.state.selectedQuestions.reduce((s, q) => s + (q.marks || 1), 0);
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("encoach_content_pool", ContentPoolBrowser);
|
||||
@@ -0,0 +1,207 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { Layout } from "@web/search/layout";
|
||||
|
||||
class ExamValidationReport extends Component {
|
||||
static template = "encoach_exam_template.ExamValidation";
|
||||
static components = { Layout };
|
||||
static props = ["*"];
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
this.state = useState({
|
||||
loading: true,
|
||||
examId: null,
|
||||
exam: null,
|
||||
checks: [],
|
||||
overallValid: false,
|
||||
passCount: 0,
|
||||
failCount: 0,
|
||||
warnCount: 0,
|
||||
expandedSections: {},
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
const ctx = this.props.action?.context || {};
|
||||
this.state.examId = ctx.active_id || ctx.exam_id || null;
|
||||
if (this.state.examId) {
|
||||
await this.runValidation();
|
||||
} else {
|
||||
await this.loadExamList();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async loadExamList() {
|
||||
try {
|
||||
this.state.examList = await this.orm.searchRead(
|
||||
"encoach.exam.template",
|
||||
[],
|
||||
["name", "code", "type", "total_time_min"],
|
||||
{ limit: 50, order: "name" },
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to load exam list:", e);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async selectExam(examId) {
|
||||
this.state.examId = examId;
|
||||
this.state.loading = true;
|
||||
await this.runValidation();
|
||||
}
|
||||
|
||||
async runValidation() {
|
||||
this.state.loading = true;
|
||||
try {
|
||||
const exams = await this.orm.searchRead(
|
||||
"encoach.exam.template",
|
||||
[["id", "=", this.state.examId]],
|
||||
["name", "code", "type", "total_time_min", "pass_threshold", "structure", "subject_id"],
|
||||
);
|
||||
this.state.exam = exams.length ? exams[0] : null;
|
||||
|
||||
if (!this.state.exam) {
|
||||
this.state.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const questions = await this.orm.searchRead(
|
||||
"encoach.question",
|
||||
[],
|
||||
["skill", "difficulty", "marks", "status"],
|
||||
);
|
||||
|
||||
this.state.checks = this._runChecks(this.state.exam, questions);
|
||||
this.state.passCount = this.state.checks.filter(c => c.status === "pass").length;
|
||||
this.state.failCount = this.state.checks.filter(c => c.status === "fail").length;
|
||||
this.state.warnCount = this.state.checks.filter(c => c.status === "warn").length;
|
||||
this.state.overallValid = this.state.failCount === 0;
|
||||
} catch (e) {
|
||||
console.error("Validation error:", e);
|
||||
} finally {
|
||||
this.state.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
_runChecks(exam, questions) {
|
||||
const checks = [];
|
||||
const active = questions.filter(q => q.status === "active");
|
||||
const skills = ["listening", "reading", "writing", "speaking", "grammar", "vocabulary"];
|
||||
|
||||
checks.push({
|
||||
id: "name",
|
||||
category: "Template",
|
||||
label: "Exam name is set",
|
||||
status: exam.name ? "pass" : "fail",
|
||||
detail: exam.name || "Missing exam name",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "time",
|
||||
category: "Template",
|
||||
label: "Time limit is configured",
|
||||
status: exam.total_time_min > 0 ? "pass" : "fail",
|
||||
detail: exam.total_time_min ? `${exam.total_time_min} minutes` : "No time limit set",
|
||||
});
|
||||
|
||||
checks.push({
|
||||
id: "threshold",
|
||||
category: "Template",
|
||||
label: "Pass threshold defined",
|
||||
status: exam.pass_threshold > 0 ? "pass" : "warn",
|
||||
detail: exam.pass_threshold ? `${exam.pass_threshold}%` : "No threshold — all scores will pass",
|
||||
});
|
||||
|
||||
const poolSize = active.length;
|
||||
checks.push({
|
||||
id: "pool_size",
|
||||
category: "Content Pool",
|
||||
label: "Active questions available (min 10)",
|
||||
status: poolSize >= 10 ? "pass" : poolSize >= 5 ? "warn" : "fail",
|
||||
detail: `${poolSize} active questions in the pool`,
|
||||
});
|
||||
|
||||
for (const skill of skills) {
|
||||
const count = active.filter(q => q.skill === skill).length;
|
||||
if (count > 0) {
|
||||
checks.push({
|
||||
id: `skill_${skill}`,
|
||||
category: "Skill Coverage",
|
||||
label: `${skill.charAt(0).toUpperCase() + skill.slice(1)} questions available`,
|
||||
status: count >= 3 ? "pass" : "warn",
|
||||
detail: `${count} active questions for ${skill}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const difficulties = ["easy", "medium", "hard"];
|
||||
for (const d of difficulties) {
|
||||
const count = active.filter(q => q.difficulty === d).length;
|
||||
checks.push({
|
||||
id: `diff_${d}`,
|
||||
category: "Difficulty Balance",
|
||||
label: `${d.charAt(0).toUpperCase() + d.slice(1)} questions present`,
|
||||
status: count > 0 ? "pass" : "warn",
|
||||
detail: `${count} ${d} questions`,
|
||||
});
|
||||
}
|
||||
|
||||
const totalMarks = active.reduce((s, q) => s + (q.marks || 1), 0);
|
||||
checks.push({
|
||||
id: "marks",
|
||||
category: "Marks",
|
||||
label: "Sufficient total marks in pool",
|
||||
status: totalMarks >= 20 ? "pass" : totalMarks >= 10 ? "warn" : "fail",
|
||||
detail: `${totalMarks} total marks available`,
|
||||
});
|
||||
|
||||
return checks;
|
||||
}
|
||||
|
||||
toggleSection(category) {
|
||||
this.state.expandedSections[category] = !this.state.expandedSections[category];
|
||||
}
|
||||
|
||||
isSectionExpanded(category) {
|
||||
return this.state.expandedSections[category] !== false;
|
||||
}
|
||||
|
||||
checksForCategory(category) {
|
||||
return this.state.checks.filter(c => c.category === category);
|
||||
}
|
||||
|
||||
get categories() {
|
||||
const seen = new Set();
|
||||
return this.state.checks.reduce((arr, c) => {
|
||||
if (!seen.has(c.category)) {
|
||||
seen.add(c.category);
|
||||
arr.push(c.category);
|
||||
}
|
||||
return arr;
|
||||
}, []);
|
||||
}
|
||||
|
||||
categoryStatus(category) {
|
||||
const items = this.checksForCategory(category);
|
||||
if (items.some(c => c.status === "fail")) return "fail";
|
||||
if (items.some(c => c.status === "warn")) return "warn";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
goBack() {
|
||||
this.state.examId = null;
|
||||
this.state.exam = null;
|
||||
this.state.checks = [];
|
||||
this.state.loading = true;
|
||||
this.loadExamList();
|
||||
}
|
||||
}
|
||||
|
||||
registry.category("actions").add("encoach_exam_validation", ExamValidationReport);
|
||||
@@ -0,0 +1,224 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="encoach_exam_template.ContentPool">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<div class="o_content_pool">
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<h2 class="mb-0 me-3">Content Pool</h2>
|
||||
<span class="badge bg-primary fs-6" t-if="state.totalCount">
|
||||
<t t-esc="state.totalCount"/> questions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Filter Bar -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">Skill</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('skill', ev)">
|
||||
<option value="">All Skills</option>
|
||||
<option value="listening">Listening</option>
|
||||
<option value="reading">Reading</option>
|
||||
<option value="writing">Writing</option>
|
||||
<option value="speaking">Speaking</option>
|
||||
<option value="grammar">Grammar</option>
|
||||
<option value="vocabulary">Vocabulary</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold mb-1">Difficulty</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('difficulty', ev)">
|
||||
<option value="">All</option>
|
||||
<option value="easy">Easy</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="hard">Hard</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small fw-bold mb-1">Status</label>
|
||||
<select class="form-select form-select-sm"
|
||||
t-on-change="(ev) => this.onFilterChange('status', ev)">
|
||||
<option value="">All</option>
|
||||
<option value="active" selected="selected">Active</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="retired">Retired</option>
|
||||
<option value="flagged">Flagged</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-bold mb-1">Search</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control"
|
||||
placeholder="Search question text..."
|
||||
t-on-input="onSearchInput"
|
||||
t-on-keydown="onSearchKeydown"/>
|
||||
<button class="btn btn-outline-primary" t-on-click="applySearch">
|
||||
<i class="fa fa-search"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-1 text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary" t-on-click="() => { this.state.filters = { skill: '', difficulty: '', status: 'active', search: '' }; this.state.page = 1; this.loadQuestions(); }">
|
||||
<i class="fa fa-refresh"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||
<p class="mt-2 text-muted">Loading questions...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-else="">
|
||||
<div class="cp-split-panel">
|
||||
<!-- Left: Question List -->
|
||||
<div class="cp-question-list">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">Available Questions</h6>
|
||||
<small class="text-muted">
|
||||
Page <t t-esc="state.page"/> of <t t-esc="totalPages"/>
|
||||
</small>
|
||||
</div>
|
||||
<div class="card-body p-0 cp-scroll">
|
||||
<table class="table table-hover table-sm mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th style="width:40px"/>
|
||||
<th>Question</th>
|
||||
<th style="width:90px">Skill</th>
|
||||
<th style="width:80px">Difficulty</th>
|
||||
<th style="width:55px">Marks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="state.questions" t-as="q" t-key="q.id">
|
||||
<tr t-att-class="{ 'table-active': isSelected(q.id) }"
|
||||
class="cursor-pointer"
|
||||
t-on-click="() => this.toggleQuestion(q)">
|
||||
<td class="text-center">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
t-att-checked="isSelected(q.id)"
|
||||
t-on-click.stop="() => this.toggleQuestion(q)"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="truncate(q.stem, 80)"/>
|
||||
<br/>
|
||||
<small class="text-muted" t-esc="q.question_type"/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-primary bg-opacity-75" t-esc="skillLabel(q.skill)"/>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" t-att-class="difficultyClass(q.difficulty)" t-esc="q.difficulty"/>
|
||||
</td>
|
||||
<td class="text-center fw-bold" t-esc="q.marks"/>
|
||||
</tr>
|
||||
</t>
|
||||
<t t-if="!state.questions.length">
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted py-4">
|
||||
<i class="fa fa-inbox fa-2x mb-2 d-block"/>
|
||||
No questions match your filters
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card-footer bg-white d-flex justify-content-between align-items-center py-2">
|
||||
<small class="text-muted">
|
||||
Showing <t t-esc="Math.min((state.page - 1) * state.pageSize + 1, state.totalCount)"/>-<t t-esc="Math.min(state.page * state.pageSize, state.totalCount)"/> of <t t-esc="state.totalCount"/>
|
||||
</small>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||
t-att-disabled="state.page <= 1"
|
||||
t-on-click="prevPage">
|
||||
<i class="fa fa-chevron-left"/>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
t-att-disabled="state.page >= totalPages"
|
||||
t-on-click="nextPage">
|
||||
<i class="fa fa-chevron-right"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Selected Questions -->
|
||||
<div class="cp-selected-panel">
|
||||
<div class="card shadow-sm h-100 border-primary">
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||
<h6 class="mb-0">
|
||||
<i class="fa fa-check-square me-1"/>
|
||||
Selected (<t t-esc="selectedCount"/>)
|
||||
</h6>
|
||||
<button class="btn btn-sm btn-outline-light"
|
||||
t-on-click="clearSelection"
|
||||
t-if="selectedCount > 0">
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<!-- Summary -->
|
||||
<div class="row g-2 mb-3" t-if="selectedCount > 0">
|
||||
<div class="col-6">
|
||||
<div class="bg-light rounded p-2 text-center">
|
||||
<div class="fs-4 fw-bold text-primary" t-esc="selectedCount"/>
|
||||
<small class="text-muted">Questions</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="bg-light rounded p-2 text-center">
|
||||
<div class="fs-4 fw-bold text-success" t-esc="selectedMarks"/>
|
||||
<small class="text-muted">Total Marks</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Items -->
|
||||
<div class="cp-scroll-selected">
|
||||
<t t-foreach="state.selectedQuestions" t-as="sq" t-key="sq.id">
|
||||
<div class="d-flex align-items-start border rounded p-2 mb-2 bg-white">
|
||||
<div class="flex-grow-1 me-2">
|
||||
<small class="d-block" t-esc="truncate(sq.stem, 60)"/>
|
||||
<span class="badge bg-primary bg-opacity-50 me-1" style="font-size:0.65rem" t-esc="skillLabel(sq.skill)"/>
|
||||
<span class="badge" t-att-class="difficultyClass(sq.difficulty)" style="font-size:0.65rem" t-esc="sq.difficulty"/>
|
||||
<span class="badge bg-dark bg-opacity-50" style="font-size:0.65rem">
|
||||
<t t-esc="sq.marks"/> mk
|
||||
</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger flex-shrink-0"
|
||||
t-on-click.stop="() => this.removeSelected(sq.id)"
|
||||
title="Remove">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="selectedCount === 0">
|
||||
<div class="text-center text-muted py-4">
|
||||
<i class="fa fa-hand-pointer-o fa-2x mb-2 d-block"/>
|
||||
<small>Click questions to select them</small>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="encoach_exam_template.ExamValidation">
|
||||
<Layout display="{ controlPanel: {} }">
|
||||
<div class="o_exam_validation">
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<t t-if="state.loading">
|
||||
<div class="text-center py-5">
|
||||
<i class="fa fa-spinner fa-spin fa-3x text-primary"/>
|
||||
<p class="mt-2 text-muted">Running validation...</p>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Exam selector (when no exam_id in context) -->
|
||||
<t t-elif="!state.examId and state.examList">
|
||||
<h2 class="mb-3">Exam Validation Report</h2>
|
||||
<p class="text-muted mb-4">Select an exam template to validate.</p>
|
||||
<div class="row g-3">
|
||||
<t t-foreach="state.examList" t-as="ex" t-key="ex.id">
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="card shadow-sm h-100 cursor-pointer ev-exam-card"
|
||||
t-on-click="() => this.selectExam(ex.id)">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title" t-esc="ex.name"/>
|
||||
<span class="badge bg-secondary me-1" t-if="ex.code" t-esc="ex.code"/>
|
||||
<span class="badge bg-info" t-esc="ex.type"/>
|
||||
<div class="mt-2 text-muted small" t-if="ex.total_time_min">
|
||||
<i class="fa fa-clock-o me-1"/>
|
||||
<t t-esc="ex.total_time_min"/> min
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Validation Results -->
|
||||
<t t-elif="state.exam">
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<button class="btn btn-sm btn-outline-secondary me-3" t-on-click="goBack"
|
||||
t-if="!this.props.action?.context?.active_id">
|
||||
<i class="fa fa-arrow-left me-1"/> Back
|
||||
</button>
|
||||
<div>
|
||||
<h2 class="mb-0">
|
||||
Validation: <t t-esc="state.exam.name"/>
|
||||
</h2>
|
||||
<small class="text-muted" t-if="state.exam.code">
|
||||
Code: <t t-esc="state.exam.code"/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status Banner -->
|
||||
<div class="alert d-flex align-items-center mb-4"
|
||||
t-att-class="{ 'alert-success': state.overallValid, 'alert-danger': !state.overallValid }">
|
||||
<i class="fa fa-3x me-3"
|
||||
t-att-class="{ 'fa-check-circle': state.overallValid, 'fa-exclamation-triangle': !state.overallValid }"/>
|
||||
<div>
|
||||
<h5 class="mb-1" t-if="state.overallValid">All Checks Passed</h5>
|
||||
<h5 class="mb-1" t-else="">Validation Issues Found</h5>
|
||||
<span>
|
||||
<span class="badge bg-success me-1"><t t-esc="state.passCount"/> passed</span>
|
||||
<span class="badge bg-danger me-1" t-if="state.failCount"><t t-esc="state.failCount"/> failed</span>
|
||||
<span class="badge bg-warning text-dark" t-if="state.warnCount"><t t-esc="state.warnCount"/> warnings</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Check Categories -->
|
||||
<t t-foreach="categories" t-as="cat" t-key="cat">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center cursor-pointer py-2"
|
||||
t-on-click="() => this.toggleSection(cat)">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa me-2"
|
||||
t-att-class="{
|
||||
'fa-check-circle text-success': categoryStatus(cat) === 'pass',
|
||||
'fa-exclamation-circle text-danger': categoryStatus(cat) === 'fail',
|
||||
'fa-exclamation-triangle text-warning': categoryStatus(cat) === 'warn'
|
||||
}"/>
|
||||
<h6 class="mb-0" t-esc="cat"/>
|
||||
</div>
|
||||
<i class="fa"
|
||||
t-att-class="{ 'fa-chevron-down': isSectionExpanded(cat), 'fa-chevron-right': !isSectionExpanded(cat) }"/>
|
||||
</div>
|
||||
<div class="card-body p-0" t-if="isSectionExpanded(cat)">
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
<t t-foreach="checksForCategory(cat)" t-as="check" t-key="check.id">
|
||||
<tr>
|
||||
<td style="width:40px" class="text-center">
|
||||
<i class="fa"
|
||||
t-att-class="{
|
||||
'fa-check text-success': check.status === 'pass',
|
||||
'fa-times text-danger': check.status === 'fail',
|
||||
'fa-exclamation text-warning': check.status === 'warn'
|
||||
}"/>
|
||||
</td>
|
||||
<td>
|
||||
<span t-esc="check.label"/>
|
||||
</td>
|
||||
<td class="text-muted small text-end" t-esc="check.detail"/>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex gap-2 mt-4">
|
||||
<button class="btn btn-primary" t-on-click="() => this.runValidation()">
|
||||
<i class="fa fa-refresh me-1"/> Re-run Validation
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-elif="!state.examId">
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="fa fa-clipboard fa-3x mb-3 d-block"/>
|
||||
<p>No exam template found. Open this from an exam template record.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</t>
|
||||
</templates>
|
||||
100
custom_addons/encoach_exam_template/views/audio_file_views.xml
Normal file
100
custom_addons/encoach_exam_template/views/audio_file_views.xml
Normal file
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Audio File Views -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="view_audio_file_form" model="ir.ui.view">
|
||||
<field name="name">encoach.audio.file.form</field>
|
||||
<field name="model">encoach.audio.file</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Audio File">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="topic" placeholder="Audio Topic"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="exam_type"/>
|
||||
<field name="part"/>
|
||||
<field name="context_type"/>
|
||||
<field name="difficulty"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="audio_url" widget="url"/>
|
||||
<field name="ai_generated"/>
|
||||
<field name="approved"/>
|
||||
<field name="ielts_certified"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Transcript" name="transcript">
|
||||
<field name="transcript" widget="text" placeholder="Audio transcript..."/>
|
||||
</page>
|
||||
<page string="Validation" name="validation">
|
||||
<group>
|
||||
<field name="generation_brief"/>
|
||||
<field name="validation_errors"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_audio_file_list" model="ir.ui.view">
|
||||
<field name="name">encoach.audio.file.list</field>
|
||||
<field name="model">encoach.audio.file</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Audio Files" decoration-success="approved == True">
|
||||
<field name="topic"/>
|
||||
<field name="exam_type"/>
|
||||
<field name="part"/>
|
||||
<field name="context_type"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="approved"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_audio_file_search" model="ir.ui.view">
|
||||
<field name="name">encoach.audio.file.search</field>
|
||||
<field name="model">encoach.audio.file</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Audio Files">
|
||||
<field name="topic"/>
|
||||
<separator/>
|
||||
<filter string="Academic" name="filter_academic" domain="[('exam_type', '=', 'academic')]"/>
|
||||
<filter string="General Training" name="filter_general" domain="[('exam_type', '=', 'general_training')]"/>
|
||||
<filter string="General English" name="filter_general_eng" domain="[('exam_type', '=', 'general_english')]"/>
|
||||
<separator/>
|
||||
<filter string="Part 1" name="filter_part1" domain="[('part', '=', 1)]"/>
|
||||
<filter string="Part 2" name="filter_part2" domain="[('part', '=', 2)]"/>
|
||||
<filter string="Part 3" name="filter_part3" domain="[('part', '=', 3)]"/>
|
||||
<filter string="Part 4" name="filter_part4" domain="[('part', '=', 4)]"/>
|
||||
<separator/>
|
||||
<filter string="Approved" name="filter_approved" domain="[('approved', '=', True)]"/>
|
||||
<filter string="Exam Type" name="group_exam_type" context="{'group_by': 'exam_type'}"/>
|
||||
<filter string="Part" name="group_part" context="{'group_by': 'part'}"/>
|
||||
<filter string="Difficulty" name="group_difficulty" context="{'group_by': 'difficulty'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_audio_file" model="ir.actions.act_window">
|
||||
<field name="name">Audio Files</field>
|
||||
<field name="res_model">encoach.audio.file</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_audio_file_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Upload your first audio file
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_content_pool" model="ir.actions.client">
|
||||
<field name="name">Content Pool</field>
|
||||
<field name="tag">encoach_content_pool</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_content_pool_browser"
|
||||
name="Content Pool Browser"
|
||||
parent="encoach_core.menu_encoach_content"
|
||||
action="action_content_pool"
|
||||
sequence="5"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,80 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Exam Assignment Views -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="view_exam_assignment_form" model="ir.ui.view">
|
||||
<field name="name">encoach.exam.assignment.form</field>
|
||||
<field name="model">encoach.exam.assignment</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Exam Assignment">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="assigned,started,completed,expired"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="exam_id"/>
|
||||
<field name="student_id"/>
|
||||
<field name="batch_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="access_start"/>
|
||||
<field name="access_end"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_exam_assignment_list" model="ir.ui.view">
|
||||
<field name="name">encoach.exam.assignment.list</field>
|
||||
<field name="model">encoach.exam.assignment</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Exam Assignments" decoration-info="status == 'assigned'" decoration-warning="status == 'started'" decoration-success="status == 'completed'" decoration-muted="status == 'expired'">
|
||||
<field name="exam_id"/>
|
||||
<field name="student_id"/>
|
||||
<field name="batch_id"/>
|
||||
<field name="access_start"/>
|
||||
<field name="access_end"/>
|
||||
<field name="status"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_exam_assignment_search" model="ir.ui.view">
|
||||
<field name="name">encoach.exam.assignment.search</field>
|
||||
<field name="model">encoach.exam.assignment</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Exam Assignments">
|
||||
<field name="exam_id"/>
|
||||
<field name="student_id"/>
|
||||
<field name="batch_id"/>
|
||||
<separator/>
|
||||
<filter string="Assigned" name="filter_assigned" domain="[('status', '=', 'assigned')]"/>
|
||||
<filter string="Started" name="filter_started" domain="[('status', '=', 'started')]"/>
|
||||
<filter string="Completed" name="filter_completed" domain="[('status', '=', 'completed')]"/>
|
||||
<filter string="Expired" name="filter_expired" domain="[('status', '=', 'expired')]"/>
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
|
||||
<filter string="Exam" name="group_exam" context="{'group_by': 'exam_id'}"/>
|
||||
<filter string="Batch" name="group_batch" context="{'group_by': 'batch_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_exam_assignment" model="ir.actions.act_window">
|
||||
<field name="name">Exam Assignments</field>
|
||||
<field name="res_model">encoach.exam.assignment</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_exam_assignment_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Assign your first exam to students
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
105
custom_addons/encoach_exam_template/views/exam_custom_views.xml
Normal file
105
custom_addons/encoach_exam_template/views/exam_custom_views.xml
Normal file
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Custom Exam Views -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="view_exam_custom_form" model="ir.ui.view">
|
||||
<field name="name">encoach.exam.custom.form</field>
|
||||
<field name="model">encoach.exam.custom</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Custom Exam">
|
||||
<header>
|
||||
<field name="status" widget="statusbar" statusbar_visible="draft,published,archived"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="title" placeholder="Exam Title"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="template_id"/>
|
||||
<field name="subject_id"/>
|
||||
<field name="entity_id"/>
|
||||
<field name="teacher_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="total_time_min"/>
|
||||
<field name="pass_threshold"/>
|
||||
<field name="results_release_mode"/>
|
||||
<field name="randomize_questions"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description" placeholder="Exam description..."/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Sections" name="sections">
|
||||
<field name="section_ids">
|
||||
<list editable="bottom">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="title"/>
|
||||
<field name="skill"/>
|
||||
<field name="question_count"/>
|
||||
<field name="time_limit_min" string="Time (min)"/>
|
||||
<field name="scoring_method"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_exam_custom_list" model="ir.ui.view">
|
||||
<field name="name">encoach.exam.custom.list</field>
|
||||
<field name="model">encoach.exam.custom</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Custom Exams" decoration-info="status == 'draft'" decoration-success="status == 'published'">
|
||||
<field name="title"/>
|
||||
<field name="subject_id"/>
|
||||
<field name="entity_id"/>
|
||||
<field name="teacher_id"/>
|
||||
<field name="total_time_min" string="Time (min)"/>
|
||||
<field name="status"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_exam_custom_search" model="ir.ui.view">
|
||||
<field name="name">encoach.exam.custom.search</field>
|
||||
<field name="model">encoach.exam.custom</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Custom Exams">
|
||||
<field name="title"/>
|
||||
<field name="subject_id"/>
|
||||
<field name="entity_id"/>
|
||||
<field name="teacher_id"/>
|
||||
<separator/>
|
||||
<filter string="Draft" name="filter_draft" domain="[('status', '=', 'draft')]"/>
|
||||
<filter string="Published" name="filter_published" domain="[('status', '=', 'published')]"/>
|
||||
<filter string="Archived" name="filter_archived" domain="[('status', '=', 'archived')]"/>
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
|
||||
<filter string="Subject" name="group_subject" context="{'group_by': 'subject_id'}"/>
|
||||
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_exam_custom" model="ir.actions.act_window">
|
||||
<field name="name">Custom Exams</field>
|
||||
<field name="res_model">encoach.exam.custom</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_exam_custom_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first custom exam
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Exam Template Module Menu Items -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<menuitem id="menu_exam_templates"
|
||||
name="Templates"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="action_exam_template"
|
||||
sequence="1"/>
|
||||
|
||||
<menuitem id="menu_exam_question_bank"
|
||||
name="Question Bank"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="action_question"
|
||||
sequence="2"/>
|
||||
|
||||
<menuitem id="menu_exam_passages"
|
||||
name="Passages"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="action_passage"
|
||||
sequence="3"/>
|
||||
|
||||
<menuitem id="menu_exam_audio_files"
|
||||
name="Audio Files"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="action_audio_file"
|
||||
sequence="4"/>
|
||||
|
||||
<menuitem id="menu_exam_writing_prompts"
|
||||
name="Writing Prompts"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="action_writing_prompt"
|
||||
sequence="5"/>
|
||||
|
||||
<menuitem id="menu_exam_speaking_cards"
|
||||
name="Speaking Cards"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="action_speaking_card"
|
||||
sequence="6"/>
|
||||
|
||||
<menuitem id="menu_exam_rubrics"
|
||||
name="Rubrics"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="action_rubric"
|
||||
sequence="7"/>
|
||||
|
||||
<menuitem id="menu_exam_custom_exams"
|
||||
name="Custom Exams"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="action_exam_custom"
|
||||
sequence="8"/>
|
||||
|
||||
<menuitem id="menu_exam_assignments"
|
||||
name="Exam Assignments"
|
||||
parent="encoach_core.menu_encoach_exams"
|
||||
action="action_exam_assignment"
|
||||
sequence="9"/>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,101 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Exam Template Views -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="view_exam_template_form" model="ir.ui.view">
|
||||
<field name="name">encoach.exam.template.form</field>
|
||||
<field name="model">encoach.exam.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Exam Template">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box"/>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Template Name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="type"/>
|
||||
<field name="subject_id"/>
|
||||
<field name="entity_id"/>
|
||||
<field name="teacher_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="total_time_min"/>
|
||||
<field name="pass_threshold"/>
|
||||
<field name="results_release_mode"/>
|
||||
<field name="randomize_questions"/>
|
||||
<field name="editable"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<field name="description" placeholder="Describe this exam template..."/>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Structure" name="structure">
|
||||
<field name="structure" widget="text"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_exam_template_list" model="ir.ui.view">
|
||||
<field name="name">encoach.exam.template.list</field>
|
||||
<field name="model">encoach.exam.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Exam Templates">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="type"/>
|
||||
<field name="subject_id"/>
|
||||
<field name="entity_id"/>
|
||||
<field name="total_time_min" string="Time (min)"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_exam_template_search" model="ir.ui.view">
|
||||
<field name="name">encoach.exam.template.search</field>
|
||||
<field name="model">encoach.exam.template</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Exam Templates">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="subject_id"/>
|
||||
<field name="entity_id"/>
|
||||
<separator/>
|
||||
<filter string="International" name="filter_international" domain="[('type', '=', 'international')]"/>
|
||||
<filter string="Custom" name="filter_custom" domain="[('type', '=', 'custom')]"/>
|
||||
<separator/>
|
||||
<filter string="Active" name="filter_active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Archived" name="filter_archived" domain="[('active', '=', False)]"/>
|
||||
<filter string="Type" name="group_type" context="{'group_by': 'type'}"/>
|
||||
<filter string="Subject" name="group_subject" context="{'group_by': 'subject_id'}"/>
|
||||
<filter string="Entity" name="group_entity" context="{'group_by': 'entity_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_exam_template" model="ir.actions.act_window">
|
||||
<field name="name">Exam Templates</field>
|
||||
<field name="res_model">encoach.exam.template</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_exam_template_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first exam template
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_exam_validation" model="ir.actions.client">
|
||||
<field name="name">Exam Validation</field>
|
||||
<field name="tag">encoach_exam_validation</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_exam_validation"
|
||||
name="Exam Validation"
|
||||
parent="encoach_core.menu_encoach_content"
|
||||
action="action_exam_validation"
|
||||
sequence="10"/>
|
||||
|
||||
</odoo>
|
||||
105
custom_addons/encoach_exam_template/views/passage_views.xml
Normal file
105
custom_addons/encoach_exam_template/views/passage_views.xml
Normal file
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Passage Views -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="view_passage_form" model="ir.ui.view">
|
||||
<field name="name">encoach.passage.form</field>
|
||||
<field name="model">encoach.passage</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Reading Passage">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="topic_category" placeholder="Topic Category"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="exam_type"/>
|
||||
<field name="section_num"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="status"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="cefr_level"/>
|
||||
<field name="word_count"/>
|
||||
<field name="ai_generated"/>
|
||||
<field name="approved"/>
|
||||
<field name="ielts_certified"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Body Text" name="body_text">
|
||||
<field name="body_text" widget="text" placeholder="Paste or write the passage text..."/>
|
||||
</page>
|
||||
<page string="Validation" name="validation">
|
||||
<group>
|
||||
<field name="generation_brief"/>
|
||||
<field name="validation_errors"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_passage_list" model="ir.ui.view">
|
||||
<field name="name">encoach.passage.list</field>
|
||||
<field name="model">encoach.passage</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Passages" decoration-success="approved == True" decoration-muted="status == 'retired'">
|
||||
<field name="topic_category"/>
|
||||
<field name="exam_type"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="status"/>
|
||||
<field name="word_count"/>
|
||||
<field name="approved"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_passage_search" model="ir.ui.view">
|
||||
<field name="name">encoach.passage.search</field>
|
||||
<field name="model">encoach.passage</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Passages">
|
||||
<field name="topic_category"/>
|
||||
<separator/>
|
||||
<filter string="Academic" name="filter_academic" domain="[('exam_type', '=', 'academic')]"/>
|
||||
<filter string="General Training" name="filter_general" domain="[('exam_type', '=', 'general_training')]"/>
|
||||
<filter string="General English" name="filter_general_eng" domain="[('exam_type', '=', 'general_english')]"/>
|
||||
<separator/>
|
||||
<filter string="Draft" name="filter_draft" domain="[('status', '=', 'draft')]"/>
|
||||
<filter string="Active" name="filter_active" domain="[('status', '=', 'active')]"/>
|
||||
<filter string="Flagged" name="filter_flagged" domain="[('status', '=', 'flagged')]"/>
|
||||
<separator/>
|
||||
<filter string="Easy" name="filter_easy" domain="[('difficulty', '=', 'easy')]"/>
|
||||
<filter string="Medium" name="filter_medium" domain="[('difficulty', '=', 'medium')]"/>
|
||||
<filter string="Hard" name="filter_hard" domain="[('difficulty', '=', 'hard')]"/>
|
||||
<separator/>
|
||||
<filter string="Approved" name="filter_approved" domain="[('approved', '=', True)]"/>
|
||||
<filter string="Not Approved" name="filter_not_approved" domain="[('approved', '=', False)]"/>
|
||||
<filter string="Exam Type" name="group_exam_type" context="{'group_by': 'exam_type'}"/>
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
|
||||
<filter string="Difficulty" name="group_difficulty" context="{'group_by': 'difficulty'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_passage" model="ir.actions.act_window">
|
||||
<field name="name">Passages</field>
|
||||
<field name="res_model">encoach.passage</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_passage_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first reading passage
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
128
custom_addons/encoach_exam_template/views/question_views.xml
Normal file
128
custom_addons/encoach_exam_template/views/question_views.xml
Normal file
@@ -0,0 +1,128 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Question Views -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="view_question_form" model="ir.ui.view">
|
||||
<field name="name">encoach.question.form</field>
|
||||
<field name="model">encoach.question</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Question">
|
||||
<sheet>
|
||||
<group>
|
||||
<group string="Classification">
|
||||
<field name="skill"/>
|
||||
<field name="question_type"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="status"/>
|
||||
</group>
|
||||
<group string="Taxonomy">
|
||||
<field name="subject_id"/>
|
||||
<field name="topic_id"/>
|
||||
<field name="marks"/>
|
||||
<field name="source_type"/>
|
||||
<field name="source_id" invisible="not source_type"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Question Content">
|
||||
<field name="stem" widget="text" placeholder="Enter the question stem..."/>
|
||||
</group>
|
||||
<group>
|
||||
<group>
|
||||
<field name="options" widget="text" placeholder='JSON array: ["A", "B", "C", "D"]'/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="correct_answer" widget="text"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="IRT Parameters" name="irt">
|
||||
<group>
|
||||
<group>
|
||||
<field name="irt_a" string="Discrimination (a)"/>
|
||||
<field name="irt_b" string="Difficulty (b)"/>
|
||||
<field name="irt_c" string="Guessing (c)"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Flags" name="flags">
|
||||
<group>
|
||||
<group>
|
||||
<field name="ai_generated"/>
|
||||
<field name="ielts_certified"/>
|
||||
<field name="format_validated"/>
|
||||
</group>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_question_list" model="ir.ui.view">
|
||||
<field name="name">encoach.question.list</field>
|
||||
<field name="model">encoach.question</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Questions" decoration-muted="status == 'retired'" decoration-danger="status == 'flagged'">
|
||||
<field name="stem" string="Question" widget="char"/>
|
||||
<field name="skill"/>
|
||||
<field name="question_type"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="status"/>
|
||||
<field name="marks"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_question_search" model="ir.ui.view">
|
||||
<field name="name">encoach.question.search</field>
|
||||
<field name="model">encoach.question</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Questions">
|
||||
<field name="stem"/>
|
||||
<field name="subject_id"/>
|
||||
<field name="topic_id"/>
|
||||
<separator/>
|
||||
<filter string="Listening" name="filter_listening" domain="[('skill', '=', 'listening')]"/>
|
||||
<filter string="Reading" name="filter_reading" domain="[('skill', '=', 'reading')]"/>
|
||||
<filter string="Writing" name="filter_writing" domain="[('skill', '=', 'writing')]"/>
|
||||
<filter string="Speaking" name="filter_speaking" domain="[('skill', '=', 'speaking')]"/>
|
||||
<filter string="Grammar" name="filter_grammar" domain="[('skill', '=', 'grammar')]"/>
|
||||
<filter string="Vocabulary" name="filter_vocabulary" domain="[('skill', '=', 'vocabulary')]"/>
|
||||
<separator/>
|
||||
<filter string="MCQ" name="filter_mcq" domain="[('question_type', '=', 'mcq')]"/>
|
||||
<filter string="Gap Fill" name="filter_gap_fill" domain="[('question_type', '=', 'gap_fill')]"/>
|
||||
<filter string="Matching" name="filter_matching" domain="[('question_type', '=', 'matching')]"/>
|
||||
<separator/>
|
||||
<filter string="Easy" name="filter_easy" domain="[('difficulty', '=', 'easy')]"/>
|
||||
<filter string="Medium" name="filter_medium" domain="[('difficulty', '=', 'medium')]"/>
|
||||
<filter string="Hard" name="filter_hard" domain="[('difficulty', '=', 'hard')]"/>
|
||||
<separator/>
|
||||
<filter string="Draft" name="filter_draft" domain="[('status', '=', 'draft')]"/>
|
||||
<filter string="Active" name="filter_active" domain="[('status', '=', 'active')]"/>
|
||||
<filter string="Flagged" name="filter_flagged" domain="[('status', '=', 'flagged')]"/>
|
||||
<filter string="Skill" name="group_skill" context="{'group_by': 'skill'}"/>
|
||||
<filter string="Type" name="group_type" context="{'group_by': 'question_type'}"/>
|
||||
<filter string="Difficulty" name="group_difficulty" context="{'group_by': 'difficulty'}"/>
|
||||
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
|
||||
<filter string="Subject" name="group_subject" context="{'group_by': 'subject_id'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_question" model="ir.actions.act_window">
|
||||
<field name="name">Question Bank</field>
|
||||
<field name="res_model">encoach.question</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_question_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Add your first question to the bank
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
77
custom_addons/encoach_exam_template/views/rubric_views.xml
Normal file
77
custom_addons/encoach_exam_template/views/rubric_views.xml
Normal file
@@ -0,0 +1,77 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Rubric Views -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="view_rubric_form" model="ir.ui.view">
|
||||
<field name="name">encoach.rubric.form</field>
|
||||
<field name="model">encoach.rubric</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Rubric">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Rubric Name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="skill"/>
|
||||
<field name="exam_type"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Criteria" name="criteria">
|
||||
<field name="criteria" widget="text" placeholder="Define rubric criteria (JSON or structured text)..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rubric_list" model="ir.ui.view">
|
||||
<field name="name">encoach.rubric.list</field>
|
||||
<field name="model">encoach.rubric</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Rubrics">
|
||||
<field name="name"/>
|
||||
<field name="skill"/>
|
||||
<field name="exam_type"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_rubric_search" model="ir.ui.view">
|
||||
<field name="name">encoach.rubric.search</field>
|
||||
<field name="model">encoach.rubric</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Rubrics">
|
||||
<field name="name"/>
|
||||
<separator/>
|
||||
<filter string="Writing" name="filter_writing" domain="[('skill', '=', 'writing')]"/>
|
||||
<filter string="Speaking" name="filter_speaking" domain="[('skill', '=', 'speaking')]"/>
|
||||
<separator/>
|
||||
<filter string="Academic" name="filter_academic" domain="[('exam_type', '=', 'academic')]"/>
|
||||
<filter string="General Training" name="filter_general" domain="[('exam_type', '=', 'general_training')]"/>
|
||||
<filter string="Skill" name="group_skill" context="{'group_by': 'skill'}"/>
|
||||
<filter string="Exam Type" name="group_exam_type" context="{'group_by': 'exam_type'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_rubric" model="ir.actions.act_window">
|
||||
<field name="name">Rubrics</field>
|
||||
<field name="res_model">encoach.rubric</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_rubric_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first scoring rubric
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,103 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Speaking Card Views -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="view_speaking_card_form" model="ir.ui.view">
|
||||
<field name="name">encoach.speaking.card.form</field>
|
||||
<field name="model">encoach.speaking.card</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Speaking Card">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="topic" placeholder="Speaking Topic"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="part"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="linked_card_id"/>
|
||||
<field name="rubric_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="ai_generated"/>
|
||||
<field name="approved"/>
|
||||
<field name="ielts_certified"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Questions" name="questions">
|
||||
<field name="questions" widget="text" placeholder="Questions for the candidate..."/>
|
||||
</page>
|
||||
<page string="Bullet Points" name="bullet_points">
|
||||
<field name="bullet_points" widget="text"/>
|
||||
</page>
|
||||
<page string="Model Response" name="model_response">
|
||||
<field name="model_response" widget="text"/>
|
||||
</page>
|
||||
<page string="Validation" name="validation">
|
||||
<group>
|
||||
<field name="generation_brief"/>
|
||||
<field name="validation_errors"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_speaking_card_list" model="ir.ui.view">
|
||||
<field name="name">encoach.speaking.card.list</field>
|
||||
<field name="model">encoach.speaking.card</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Speaking Cards" decoration-success="approved == True">
|
||||
<field name="topic"/>
|
||||
<field name="part"/>
|
||||
<field name="difficulty"/>
|
||||
<field name="linked_card_id"/>
|
||||
<field name="approved"/>
|
||||
<field name="ielts_certified"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_speaking_card_search" model="ir.ui.view">
|
||||
<field name="name">encoach.speaking.card.search</field>
|
||||
<field name="model">encoach.speaking.card</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Speaking Cards">
|
||||
<field name="topic"/>
|
||||
<separator/>
|
||||
<filter string="Part 1" name="filter_part1" domain="[('part', '=', 1)]"/>
|
||||
<filter string="Part 2" name="filter_part2" domain="[('part', '=', 2)]"/>
|
||||
<filter string="Part 3" name="filter_part3" domain="[('part', '=', 3)]"/>
|
||||
<separator/>
|
||||
<filter string="Easy" name="filter_easy" domain="[('difficulty', '=', 'easy')]"/>
|
||||
<filter string="Medium" name="filter_medium" domain="[('difficulty', '=', 'medium')]"/>
|
||||
<filter string="Hard" name="filter_hard" domain="[('difficulty', '=', 'hard')]"/>
|
||||
<separator/>
|
||||
<filter string="Approved" name="filter_approved" domain="[('approved', '=', True)]"/>
|
||||
<filter string="Part" name="group_part" context="{'group_by': 'part'}"/>
|
||||
<filter string="Difficulty" name="group_difficulty" context="{'group_by': 'difficulty'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_speaking_card" model="ir.actions.act_window">
|
||||
<field name="name">Speaking Cards</field>
|
||||
<field name="res_model">encoach.speaking.card</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_speaking_card_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first speaking cue card
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Writing Prompt Views -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<record id="view_writing_prompt_form" model="ir.ui.view">
|
||||
<field name="name">encoach.writing.prompt.form</field>
|
||||
<field name="model">encoach.writing.prompt</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Writing Prompt">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="exam_type"/>
|
||||
<field name="task"/>
|
||||
<field name="writing_type"/>
|
||||
<field name="min_words"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="rubric_id"/>
|
||||
<field name="visual_url" widget="url"/>
|
||||
<field name="ai_generated"/>
|
||||
<field name="approved"/>
|
||||
<field name="ielts_certified"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Prompt" name="prompt">
|
||||
<field name="prompt_text" widget="text" placeholder="Write the prompt instructions..."/>
|
||||
</page>
|
||||
<page string="Model Answer" name="model_answer">
|
||||
<field name="model_answer" widget="text"/>
|
||||
</page>
|
||||
<page string="Validation" name="validation">
|
||||
<group>
|
||||
<field name="generation_brief"/>
|
||||
<field name="validation_errors"/>
|
||||
</group>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_writing_prompt_list" model="ir.ui.view">
|
||||
<field name="name">encoach.writing.prompt.list</field>
|
||||
<field name="model">encoach.writing.prompt</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Writing Prompts" decoration-success="approved == True">
|
||||
<field name="exam_type"/>
|
||||
<field name="task"/>
|
||||
<field name="writing_type"/>
|
||||
<field name="min_words"/>
|
||||
<field name="rubric_id"/>
|
||||
<field name="approved"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_writing_prompt_search" model="ir.ui.view">
|
||||
<field name="name">encoach.writing.prompt.search</field>
|
||||
<field name="model">encoach.writing.prompt</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Writing Prompts">
|
||||
<field name="writing_type"/>
|
||||
<separator/>
|
||||
<filter string="Academic" name="filter_academic" domain="[('exam_type', '=', 'academic')]"/>
|
||||
<filter string="General Training" name="filter_general" domain="[('exam_type', '=', 'general_training')]"/>
|
||||
<separator/>
|
||||
<filter string="Task 1" name="filter_task1" domain="[('task', '=', 'task1')]"/>
|
||||
<filter string="Task 2" name="filter_task2" domain="[('task', '=', 'task2')]"/>
|
||||
<separator/>
|
||||
<filter string="Approved" name="filter_approved" domain="[('approved', '=', True)]"/>
|
||||
<filter string="Exam Type" name="group_exam_type" context="{'group_by': 'exam_type'}"/>
|
||||
<filter string="Task" name="group_task" context="{'group_by': 'task'}"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_writing_prompt" model="ir.actions.act_window">
|
||||
<field name="name">Writing Prompts</field>
|
||||
<field name="res_model">encoach.writing.prompt</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_writing_prompt_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first writing prompt
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user