feat: complete exam lifecycle — AI generation, submission, student session, and results

- Backend: AI generation fallbacks when OpenAI not configured, full exam
  submission saving all params (difficulty, rubric, entity, grading system,
  approval workflow) and creating linked question records per section
- Backend: new exam session controller with get_session, autosave, submit,
  status, and results endpoints; student attempt/answer/score models
- Backend: new controllers for entities, approval workflows, exam schedules
- Frontend: exam session split-layout with passage panel, question types
  (MCQ, T/F/NG, gap-fill, writing, speaking), timer, and review dialog
- Frontend: results page with percentage score, per-answer breakdown table
- Frontend: generation page dynamic dropdowns, full payload submission
- Frontend: updated types for ExamSessionSection, ExamQuestion options

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-16 16:53:09 +04:00
parent 01cce7662d
commit 74d83af57f
24 changed files with 2020 additions and 69 deletions

View File

@@ -3,4 +3,8 @@ from . import ielts_exam
from . import custom_exam
from . import exam_structures
from . import assignments
from . import exam_schedules
from . import rubrics
from . import approval_workflows
from . import entities
from . import exam_session

View File

@@ -0,0 +1,284 @@
import json
import logging
from datetime import datetime
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,
)
_logger = logging.getLogger(__name__)
def _cr():
return request.env.cr
def _user_name(cr, user_id):
if not user_id:
return ''
cr.execute(
"SELECT rp.name FROM res_users ru "
"JOIN res_partner rp ON rp.id = ru.partner_id "
"WHERE ru.id = %s", (user_id,))
row = cr.fetchone()
return row[0] if row else ''
class ApprovalWorkflowController(http.Controller):
@http.route('/api/approval-workflows', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_workflows(self, **kw):
try:
cr = _cr()
cr.execute(
"SELECT id, name, type, allow_bypass, entity_id, create_date "
"FROM encoach_approval_workflow ORDER BY create_date DESC")
workflows = cr.fetchall()
items = []
for wf_id, name, wtype, bypass, entity_id, created in workflows:
cr.execute(
"SELECT id, sequence, approver_id, status, comment, "
"auto_escalate, max_days, acted_at "
"FROM encoach_approval_stage WHERE workflow_id = %s "
"ORDER BY sequence ASC", (wf_id,))
steps = []
for sid, seq, approver_id, status, comment, escalate, max_d, acted in cr.fetchall():
steps.append({
'id': sid,
'sequence': seq,
'approver_id': approver_id,
'approver_name': _user_name(cr, approver_id),
'status': status or 'pending',
'comment': comment or '',
'auto_escalate': bool(escalate),
'max_days': max_d or 0,
'acted_at': acted.isoformat() if acted else None,
})
entity_name = None
if entity_id:
cr.execute("SELECT name FROM encoach_entity WHERE id=%s", (entity_id,))
row = cr.fetchone()
entity_name = row[0] if row else None
items.append({
'id': wf_id,
'name': name,
'type': wtype or '',
'entity_id': entity_id,
'entity_name': entity_name,
'allow_bypass': bool(bypass),
'steps': steps,
'created': created.strftime('%Y-%m-%d') if created else '',
})
return _json_response({'items': items, 'total': len(items)})
except Exception as e:
_logger.exception('list workflows failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/approval-workflows', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_workflow(self, **kw):
try:
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _error_response('name is required', 400)
cr = _cr()
now = datetime.now()
uid = request.env.uid
cr.execute(
"INSERT INTO encoach_approval_workflow "
"(name, type, allow_bypass, create_uid, write_uid, create_date, write_date) "
"VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id",
(name, body.get('type', 'custom'), body.get('allow_bypass', False),
uid, uid, now, now))
wf_id = cr.fetchone()[0]
for i, step in enumerate(body.get('steps', [])):
approver_id = step.get('approver_id')
if approver_id:
cr.execute(
"INSERT INTO encoach_approval_stage "
"(workflow_id, sequence, approver_id, status, auto_escalate, max_days, "
"create_uid, write_uid, create_date, write_date) "
"VALUES (%s, %s, %s, 'pending', %s, %s, %s, %s, %s, %s)",
(wf_id, (i + 1) * 10, int(approver_id),
step.get('auto_escalate', True), step.get('max_days', 3),
uid, uid, now, now))
cr.execute("SELECT id, name, type FROM encoach_approval_workflow WHERE id=%s", (wf_id,))
row = cr.fetchone()
return _json_response({'id': row[0], 'name': row[1], 'type': row[2]}, 201)
except Exception as e:
_logger.exception('create workflow failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/approval-workflows/<int:wf_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_workflow(self, wf_id, **kw):
try:
cr = _cr()
cr.execute("DELETE FROM encoach_approval_request WHERE workflow_id=%s", (wf_id,))
cr.execute("DELETE FROM encoach_approval_stage WHERE workflow_id=%s", (wf_id,))
cr.execute("DELETE FROM encoach_approval_workflow WHERE id=%s", (wf_id,))
return _json_response({'success': True})
except Exception as e:
_logger.exception('delete workflow failed')
return _json_response({'error': str(e)}, 500)
# ── Approval Requests ──
@http.route('/api/approval-requests', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_requests(self, **kw):
try:
cr = _cr()
state_filter = kw.get('state')
where = ""
params = []
if state_filter:
where = "WHERE ar.state = %s"
params.append(state_filter)
cr.execute(f"""
SELECT ar.id, ar.workflow_id, aw.name as wf_name,
ar.res_model, ar.res_id, ar.state,
ar.requester_id, ar.current_stage_id,
ar.bypass_reason, ar.created_at
FROM encoach_approval_request ar
LEFT JOIN encoach_approval_workflow aw ON aw.id = ar.workflow_id
{where}
ORDER BY ar.created_at DESC NULLS LAST
""", params)
items = []
for row in cr.fetchall():
rid, wf_id, wf_name, model, res_id, state, req_id, stage_id, bypass, created = row
stage_seq = None
if stage_id:
cr.execute("SELECT sequence FROM encoach_approval_stage WHERE id=%s", (stage_id,))
s = cr.fetchone()
stage_seq = s[0] if s else None
items.append({
'id': rid,
'workflow_id': wf_id,
'workflow_name': wf_name or '',
'res_model': model or '',
'res_id': res_id or 0,
'state': state or 'draft',
'requester_id': req_id,
'requester_name': _user_name(cr, req_id),
'current_stage_id': stage_id,
'current_stage_sequence': stage_seq,
'bypass_reason': bypass or '',
'created_at': created.isoformat() if created else None,
})
return _json_response({'items': items, 'total': len(items)})
except Exception as e:
_logger.exception('list requests failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/approval-requests/<int:req_id>/approve', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def approve_request(self, req_id, **kw):
try:
cr = _cr()
body = _get_json_body()
now = datetime.now()
cr.execute(
"SELECT workflow_id, current_stage_id FROM encoach_approval_request WHERE id=%s",
(req_id,))
row = cr.fetchone()
if not row:
return _error_response('Request not found', 404)
wf_id, stage_id = row
if stage_id:
cr.execute(
"UPDATE encoach_approval_stage SET status='approved', comment=%s, acted_at=%s "
"WHERE id=%s", (body.get('comment', ''), now, stage_id))
cr.execute(
"SELECT id FROM encoach_approval_stage WHERE workflow_id=%s ORDER BY sequence ASC",
(wf_id,))
all_stages = [r[0] for r in cr.fetchall()]
try:
idx = all_stages.index(stage_id)
except ValueError:
idx = -1
if idx + 1 < len(all_stages):
cr.execute(
"UPDATE encoach_approval_request SET current_stage_id=%s, state='in_progress' "
"WHERE id=%s", (all_stages[idx + 1], req_id))
else:
cr.execute(
"UPDATE encoach_approval_request SET state='approved' WHERE id=%s", (req_id,))
return _json_response({'success': True, 'id': req_id})
except Exception as e:
_logger.exception('approve request failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/approval-requests/<int:req_id>/reject', type='http',
auth='none', methods=['POST'], csrf=False)
@jwt_required
def reject_request(self, req_id, **kw):
try:
cr = _cr()
body = _get_json_body()
now = datetime.now()
cr.execute("SELECT current_stage_id FROM encoach_approval_request WHERE id=%s", (req_id,))
row = cr.fetchone()
if not row:
return _error_response('Request not found', 404)
stage_id = row[0]
if stage_id:
cr.execute(
"UPDATE encoach_approval_stage SET status='rejected', comment=%s, acted_at=%s "
"WHERE id=%s", (body.get('comment', ''), now, stage_id))
cr.execute(
"UPDATE encoach_approval_request SET state='rejected' WHERE id=%s", (req_id,))
return _json_response({'success': True, 'id': req_id})
except Exception as e:
_logger.exception('reject request failed')
return _json_response({'error': str(e)}, 500)
# ── Users for assignee selection ──
@http.route('/api/approval-users', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_users(self, **kw):
try:
cr = _cr()
cr.execute(
"SELECT ru.id, rp.name, ru.login "
"FROM res_users ru JOIN res_partner rp ON rp.id = ru.partner_id "
"WHERE ru.active = true AND ru.id > 1 "
"ORDER BY rp.name LIMIT 100")
items = [{'id': r[0], 'name': r[1], 'login': r[2]} for r in cr.fetchall()]
return _json_response({'items': items})
except Exception as e:
_logger.exception('list users failed')
return _json_response({'error': str(e)}, 500)

View File

@@ -0,0 +1,34 @@
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,
)
_logger = logging.getLogger(__name__)
class EntityController(http.Controller):
@http.route('/api/entities', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_entities(self, **kw):
try:
cr = request.env.cr
cr.execute(
"SELECT id, name, code, type FROM encoach_entity ORDER BY name")
items = []
for eid, name, code, etype in cr.fetchall():
items.append({
'id': eid,
'name': name,
'code': code or '',
'type': etype or '',
})
return _json_response({'items': items, 'total': len(items)})
except Exception as e:
_logger.exception('list entities failed')
return _json_response({'error': str(e)}, 500)

View File

@@ -0,0 +1,312 @@
import json
import logging
from datetime import datetime
from odoo import http
from odoo.http import request
from odoo.addons.encoach_api.controllers.base import (
validate_token, _json_response as _base_json_response, _error_response,
)
_logger = logging.getLogger(__name__)
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
def _require_jwt():
"""Validate JWT and set the user context. Return user or error response."""
user = validate_token()
if not user:
return None, _error_response("Authentication required", status=401)
request.update_env(user=user.id)
return user, None
def _normalize_dt(val):
"""Convert ISO datetime strings (with T) to Odoo-compatible format."""
if not val or not isinstance(val, str):
return val
return val.replace('T', ' ').replace('Z', '').split('+')[0]
def _schedule_to_dict(rec):
return {
'id': rec.id,
'name': rec.name,
'exam_id': rec.exam_id.id if rec.exam_id else None,
'exam_title': rec.exam_id.title if rec.exam_id else '',
'entity_id': rec.entity_id.id if rec.entity_id else None,
'entity_name': rec.entity_id.name if rec.entity_id else '',
'start_date': rec.start_date.isoformat() if rec.start_date else None,
'end_date': rec.end_date.isoformat() if rec.end_date else None,
'state': rec.state,
'assign_mode': rec.assign_mode,
'full_length': rec.full_length,
'generate_different': rec.generate_different,
'auto_release_results': rec.auto_release_results,
'auto_start': rec.auto_start,
'official_exam': rec.official_exam,
'hide_assignee_details': rec.hide_assignee_details,
'batch_ids': rec.batch_ids.ids,
'batch_names': [b.name for b in rec.batch_ids],
'student_ids': rec.student_ids.ids,
'assignee_count': rec.assignee_count,
'completed_count': rec.completed_count,
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
}
class EncoachExamScheduleController(http.Controller):
@http.route('/api/exam-schedules', type='http', auth='none',
methods=['GET'], csrf=False)
def list_schedules(self, **kw):
try:
user, err = _require_jwt()
if err:
return err
Schedule = request.env['encoach.exam.schedule'].sudo()
domain = []
state_filter = kw.get('state')
if state_filter:
domain.append(('state', '=', state_filter))
limit = int(kw.get('limit', 50))
offset = int(kw.get('offset', 0))
total = Schedule.search_count(domain)
records = Schedule.search(domain, limit=limit, offset=offset,
order='start_date desc')
return _json_response({
'items': [_schedule_to_dict(r) for r in records],
'total': total,
})
except Exception as e:
_logger.exception('exam-schedules list failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/exam-schedules', type='http', auth='none',
methods=['POST'], csrf=False)
def create_schedule(self, **kw):
try:
user, err = _require_jwt()
if err:
return err
body = json.loads(request.httprequest.data or '{}')
name = body.get('name', '').strip()
exam_id = body.get('exam_id')
if not name or not exam_id:
return _json_response({'error': 'name and exam_id are required'}, 400)
start_date = _normalize_dt(body.get('start_date'))
end_date = _normalize_dt(body.get('end_date'))
if not start_date or not end_date:
return _json_response({'error': 'start_date and end_date are required'}, 400)
now = datetime.now()
try:
sd = datetime.strptime(start_date, '%Y-%m-%d %H:%M:%S')
except ValueError:
sd = datetime.strptime(start_date[:19], '%Y-%m-%d %H:%M:%S')
try:
ed = datetime.strptime(end_date, '%Y-%m-%d %H:%M:%S')
except ValueError:
ed = datetime.strptime(end_date[:19], '%Y-%m-%d %H:%M:%S')
if sd <= now and ed > now:
initial_state = 'active'
elif sd > now:
initial_state = 'planned'
else:
initial_state = 'past'
vals = {
'name': name,
'exam_id': int(exam_id),
'entity_id': body.get('entity_id') and int(body['entity_id']) or False,
'start_date': start_date,
'end_date': end_date,
'state': initial_state,
'assign_mode': body.get('assign_mode', 'batch'),
'full_length': body.get('full_length', True),
'generate_different': body.get('generate_different', False),
'auto_release_results': body.get('auto_release_results', False),
'auto_start': body.get('auto_start', False),
'official_exam': body.get('official_exam', False),
'hide_assignee_details': body.get('hide_assignee_details', False),
}
batch_ids = body.get('batch_ids', [])
if batch_ids:
vals['batch_ids'] = [(6, 0, [int(b) for b in batch_ids])]
student_ids = body.get('student_ids', [])
if student_ids:
vals['student_ids'] = [(6, 0, [int(s) for s in student_ids])]
rec = request.env['encoach.exam.schedule'].sudo().create(vals)
self._create_individual_assignments(rec)
return _json_response(_schedule_to_dict(rec), 201)
except Exception as e:
_logger.exception('exam-schedule create failed')
return _json_response({'error': str(e)}, 500)
def _create_individual_assignments(self, schedule):
"""Create individual assignment records for each targeted student."""
Assignment = request.env['encoach.exam.assignment'].sudo()
created_user_ids = set()
if schedule.student_ids:
for user in schedule.student_ids:
if user.id not in created_user_ids:
Assignment.create({
'exam_id': schedule.exam_id.id,
'schedule_id': schedule.id,
'student_id': user.id,
'access_start': schedule.start_date,
'access_end': schedule.end_date,
'status': 'assigned',
})
created_user_ids.add(user.id)
for batch in schedule.batch_ids:
try:
students = request.env['op.student'].sudo().search([('batch_id', '=', batch.id)])
for student in students:
user = student.user_id if hasattr(student, 'user_id') else None
if user and user.id not in created_user_ids:
Assignment.create({
'exam_id': schedule.exam_id.id,
'schedule_id': schedule.id,
'student_id': user.id,
'batch_id': batch.id,
'access_start': schedule.start_date,
'access_end': schedule.end_date,
'status': 'assigned',
})
created_user_ids.add(user.id)
except Exception:
_logger.warning('Batch student lookup failed for batch %s', batch.id)
if schedule.assign_mode == 'entity' and schedule.entity_id:
entity_users = schedule.entity_id.user_ids
for user in entity_users:
if user.id not in created_user_ids:
Assignment.create({
'exam_id': schedule.exam_id.id,
'schedule_id': schedule.id,
'student_id': user.id,
'access_start': schedule.start_date,
'access_end': schedule.end_date,
'status': 'assigned',
})
created_user_ids.add(user.id)
@http.route('/api/exam-schedules/<int:schedule_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
def update_schedule(self, schedule_id, **kw):
try:
user, err = _require_jwt()
if err:
return err
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
if not rec.exists():
return _json_response({'error': 'Not found'}, 404)
body = json.loads(request.httprequest.data or '{}')
vals = {}
for f in ('name', 'assign_mode',
'full_length', 'generate_different', 'auto_release_results',
'auto_start', 'official_exam', 'hide_assignee_details'):
if f in body:
vals[f] = body[f]
if 'start_date' in body:
vals['start_date'] = _normalize_dt(body['start_date'])
if 'end_date' in body:
vals['end_date'] = _normalize_dt(body['end_date'])
if 'entity_id' in body:
vals['entity_id'] = body['entity_id'] and int(body['entity_id']) or False
if 'batch_ids' in body:
vals['batch_ids'] = [(6, 0, [int(b) for b in body['batch_ids']])]
if 'student_ids' in body:
vals['student_ids'] = [(6, 0, [int(s) for s in body['student_ids']])]
if 'state' in body:
vals['state'] = body['state']
if vals:
rec.write(vals)
return _json_response(_schedule_to_dict(rec))
except Exception as e:
_logger.exception('exam-schedule update failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/exam-schedules/<int:schedule_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
def delete_schedule(self, schedule_id, **kw):
try:
user, err = _require_jwt()
if err:
return err
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
if not rec.exists():
return _json_response({'error': 'Not found'}, 404)
rec.assignment_ids.unlink()
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('exam-schedule delete failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/exam-schedules/<int:schedule_id>/archive', type='http', auth='none',
methods=['POST'], csrf=False)
def archive_schedule(self, schedule_id, **kw):
try:
user, err = _require_jwt()
if err:
return err
rec = request.env['encoach.exam.schedule'].sudo().browse(schedule_id)
if not rec.exists():
return _json_response({'error': 'Not found'}, 404)
rec.write({'state': 'archived'})
return _json_response(_schedule_to_dict(rec))
except Exception as e:
_logger.exception('exam-schedule archive failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/student/my-exams', type='http', auth='none',
methods=['GET'], csrf=False)
def student_my_exams(self, **kw):
"""Return exam assignments for the current student user."""
try:
user, err = _require_jwt()
if err:
return err
Assignment = request.env['encoach.exam.assignment'].sudo()
assignments = Assignment.search([
('student_id', '=', user.id),
('status', 'in', ['assigned', 'started']),
], order='access_start asc')
result = []
for a in assignments:
schedule = a.schedule_id
state = schedule.state if schedule else 'active'
result.append({
'id': a.id,
'exam_id': a.exam_id.id,
'exam_title': a.exam_id.title if a.exam_id else '',
'schedule_name': schedule.name if schedule else '',
'start_date': a.access_start.isoformat() if a.access_start else None,
'end_date': a.access_end.isoformat() if a.access_end else None,
'status': a.status,
'schedule_state': state,
'auto_start': schedule.auto_start if schedule else False,
'can_start': state == 'active' and a.status == 'assigned',
})
return _json_response({'items': result})
except Exception as e:
_logger.exception('student my-exams failed')
return _json_response({'error': str(e)}, 500)

View File

@@ -0,0 +1,316 @@
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,
)
_logger = logging.getLogger(__name__)
def _question_to_student_dict(q):
return {
'id': q.id,
'skill': q.skill or '',
'question_type': q.question_type or '',
'stem': q.stem or '',
'options': json.loads(q.options) if q.options else [],
'marks': q.marks,
'difficulty': q.difficulty or '',
'source_type': q.source_type or '',
'source_id': q.source_id or 0,
}
class ExamSessionController(http.Controller):
@http.route('/api/exam/<int:exam_id>/session', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_session(self, exam_id, **kw):
try:
Exam = request.env['encoach.exam.custom'].sudo()
exam = Exam.browse(exam_id)
if not exam.exists():
return _error_response('Exam not found', 404)
uid = request.env.user.id
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.search([
('student_id', '=', uid),
('exam_id', '=', exam.id),
('status', 'in', ['in_progress', 'scoring']),
], limit=1, order='id desc')
if not attempt:
attempt = Attempt.create({
'student_id': uid,
'exam_id': exam.id,
'status': 'in_progress',
'entity_id': exam.entity_id.id if exam.entity_id else False,
})
Answer = request.env['encoach.student.answer'].sudo()
saved_answers = {}
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
saved_answers[ans.question_id.id] = ans.answer or ''
sections = []
for sec in exam.section_ids.sorted('sequence'):
questions = []
for q in sec.question_ids:
q_dict = _question_to_student_dict(q)
q_dict['saved_answer'] = saved_answers.get(q.id, '')
questions.append(q_dict)
sec_dict = {
'id': sec.id,
'title': sec.title,
'skill': sec.skill or '',
'difficulty': sec.difficulty or '',
'time_limit_min': sec.time_limit_min or 0,
'total_marks': sec.total_marks or 0,
'scoring_method': sec.scoring_method or 'auto',
'sequence': sec.sequence,
'questions': questions,
}
if sec.passage_text:
sec_dict['passage_text'] = sec.passage_text
if sec.instructions_text:
sec_dict['instructions_text'] = sec.instructions_text
if sec.content_json:
try:
sec_dict['content'] = json.loads(sec.content_json)
except (json.JSONDecodeError, TypeError):
pass
sections.append(sec_dict)
return _json_response({
'attempt_id': attempt.id,
'exam_id': exam.id,
'exam_title': exam.title,
'exam_mode': exam.exam_mode or 'official',
'total_time_min': exam.total_time_min or 0,
'total_marks': exam.total_marks or 0,
'grading_system': exam.grading_system or 'ielts',
'access_type': exam.access_type or 'private',
'randomize_questions': exam.randomize_questions or False,
'status': attempt.status,
'started_at': str(attempt.started_at) if attempt.started_at else None,
'sections': sections,
})
except Exception as e:
_logger.exception('get_session failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/autosave', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def autosave(self, exam_id, **kw):
try:
body = _get_json_body()
attempt_id = body.get('attempt_id')
raw_answers = body.get('answers', [])
if not attempt_id:
return _error_response('attempt_id is required', 400)
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.browse(int(attempt_id))
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
return _error_response('Invalid attempt', 403)
answer_pairs = self._normalize_answers(raw_answers)
Answer = request.env['encoach.student.answer'].sudo()
saved = 0
for q_id, answer_val in answer_pairs:
existing = Answer.search([
('attempt_id', '=', attempt.id),
('question_id', '=', q_id),
], limit=1)
if existing:
existing.write({'answer': str(answer_val)})
else:
Answer.create({
'attempt_id': attempt.id,
'question_id': q_id,
'answer': str(answer_val),
})
saved += 1
return _json_response({'saved': saved})
except Exception as e:
_logger.exception('autosave failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/submit', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def submit_exam(self, exam_id, **kw):
try:
body = _get_json_body()
attempt_id = body.get('attempt_id')
raw_answers = body.get('answers', [])
if not attempt_id:
return _error_response('attempt_id is required', 400)
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.browse(int(attempt_id))
if not attempt.exists() or attempt.student_id.id != request.env.user.id:
return _error_response('Invalid attempt', 403)
answer_pairs = self._normalize_answers(raw_answers)
Answer = request.env['encoach.student.answer'].sudo()
Question = request.env['encoach.question'].sudo()
total_score = 0.0
max_score = 0.0
for q_id, answer_val in answer_pairs:
q = Question.browse(q_id)
if not q.exists():
continue
is_correct = False
score = 0.0
correct = (q.correct_answer or '').strip().lower()
given = str(answer_val).strip().lower()
if correct and given:
is_correct = correct == given
score = q.marks if is_correct else 0.0
existing = Answer.search([
('attempt_id', '=', attempt.id),
('question_id', '=', q_id),
], limit=1)
vals = {
'answer': str(answer_val),
'score': score,
'is_correct': is_correct,
}
if existing:
existing.write(vals)
else:
vals.update({
'attempt_id': attempt.id,
'question_id': q_id,
})
Answer.create(vals)
total_score += score
max_score += q.marks
from odoo.fields import Datetime
attempt.write({
'status': 'completed',
'finished_at': Datetime.now(),
'total_score': total_score,
'max_score': max_score,
})
return _json_response({
'attempt_id': attempt.id,
'status': 'completed',
'total_score': total_score,
'max_score': max_score,
'percentage': round(total_score / max_score * 100, 1) if max_score else 0,
'results_available': True,
})
except Exception as e:
_logger.exception('submit_exam failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/status', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_status(self, exam_id, **kw):
try:
uid = request.env.user.id
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.search([
('student_id', '=', uid),
('exam_id', '=', exam_id),
], limit=1, order='id desc')
if not attempt:
return _error_response('No attempt found', 404)
scores_available = attempt.status == 'completed' and attempt.max_score > 0
return _json_response({
'status': attempt.status,
'scores_available': scores_available,
'total_score': attempt.total_score,
'max_score': attempt.max_score,
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
})
except Exception as e:
_logger.exception('get_status failed')
return _error_response(str(e), 500)
@http.route('/api/exam/<int:exam_id>/results', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def get_results(self, exam_id, **kw):
try:
uid = request.env.user.id
Attempt = request.env['encoach.student.attempt'].sudo()
attempt = Attempt.search([
('student_id', '=', uid),
('exam_id', '=', exam_id),
('status', '=', 'completed'),
], limit=1, order='id desc')
if not attempt:
return _error_response('No completed attempt found', 404)
Answer = request.env['encoach.student.answer'].sudo()
answers = []
for ans in Answer.search([('attempt_id', '=', attempt.id)]):
answers.append({
'question_id': ans.question_id.id,
'answer': ans.answer or '',
'score': ans.score,
'is_correct': ans.is_correct,
'feedback': ans.feedback or '',
})
Exam = request.env['encoach.exam.custom'].sudo()
exam = Exam.browse(exam_id)
return _json_response({
'attempt_id': attempt.id,
'exam_id': exam_id,
'exam_title': exam.title if exam.exists() else '',
'status': attempt.status,
'total_score': attempt.total_score,
'max_score': attempt.max_score,
'percentage': round(attempt.total_score / attempt.max_score * 100, 1) if attempt.max_score else 0,
'started_at': str(attempt.started_at) if attempt.started_at else None,
'finished_at': str(attempt.finished_at) if attempt.finished_at else None,
'answers': answers,
})
except Exception as e:
_logger.exception('get_results failed')
return _error_response(str(e), 500)
@staticmethod
def _normalize_answers(raw):
"""Accept answers as list of {question_id, answer} or dict {qid: answer}."""
if isinstance(raw, list):
pairs = []
for item in raw:
if isinstance(item, dict):
qid = item.get('question_id') or item.get('qid')
ans = item.get('answer', '')
if qid:
pairs.append((int(qid), ans))
return pairs
if isinstance(raw, dict):
return [(int(k), v) for k, v in raw.items()]
return []

View File

@@ -3,24 +3,17 @@ 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,
)
_logger = logging.getLogger(__name__)
def _json_body():
try:
return json.loads(request.httprequest.data or '{}')
except Exception:
return {}
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
class ExamStructureController(http.Controller):
@http.route('/api/exam-structures', type='http', auth='user', methods=['GET'], csrf=False)
@http.route('/api/exam-structures', type='http', auth='none', methods=['GET'], csrf=False)
@jwt_required
def list_structures(self, **kw):
domain = [('active', '=', True)]
entity_id = kw.get('entity_id')
@@ -29,8 +22,9 @@ class ExamStructureController(http.Controller):
limit = int(kw.get('limit', 50))
offset = int(kw.get('offset', 0))
records = request.env['encoach.exam.structure'].search(domain, limit=limit, offset=offset, order='create_date desc')
total = request.env['encoach.exam.structure'].search_count(domain)
records = request.env['encoach.exam.structure'].sudo().search(
domain, limit=limit, offset=offset, order='create_date desc')
total = request.env['encoach.exam.structure'].sudo().search_count(domain)
items = []
for r in records:
@@ -52,12 +46,13 @@ class ExamStructureController(http.Controller):
return _json_response({'items': items, 'total': total})
@http.route('/api/exam-structures', type='http', auth='user', methods=['POST'], csrf=False)
@http.route('/api/exam-structures', type='http', auth='none', methods=['POST'], csrf=False)
@jwt_required
def create_structure(self, **kw):
body = _json_body()
body = _get_json_body()
name = body.get('name')
if not name:
return _json_response({'error': 'name is required'}, status=400)
return _error_response('name is required', 400)
vals = {
'name': name,
@@ -69,19 +64,61 @@ class ExamStructureController(http.Controller):
if entity_id:
vals['entity_id'] = int(entity_id)
record = request.env['encoach.exam.structure'].create(vals)
record = request.env['encoach.exam.structure'].sudo().create(vals)
return _json_response({
'id': record.id,
'name': record.name,
'entity_id': record.entity_id.id if record.entity_id else None,
'industry': record.industry or '',
'modules': json.loads(record.modules) if record.modules else [],
'config': json.loads(record.config) if record.config else {},
})
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='user', methods=['DELETE'], csrf=False)
def delete_structure(self, structure_id, **kw):
record = request.env['encoach.exam.structure'].browse(structure_id)
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='none', methods=['PUT'], csrf=False)
@jwt_required
def update_structure(self, structure_id, **kw):
record = request.env['encoach.exam.structure'].sudo().browse(structure_id)
if not record.exists():
return _json_response({'error': 'Structure not found'}, status=404)
return _error_response('Structure not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'industry' in body:
vals['industry'] = body['industry']
if 'modules' in body:
vals['modules'] = json.dumps(body['modules'])
if 'config' in body:
vals['config'] = json.dumps(body['config'])
if 'entity_id' in body:
vals['entity_id'] = int(body['entity_id']) if body['entity_id'] else False
if vals:
record.write(vals)
modules = []
if record.modules:
try:
modules = json.loads(record.modules)
except Exception:
modules = []
return _json_response({
'id': record.id,
'name': record.name,
'entity_id': record.entity_id.id if record.entity_id else None,
'entity_name': record.entity_id.name if record.entity_id else None,
'industry': record.industry or '',
'modules': modules,
'config': json.loads(record.config) if record.config else {},
})
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@jwt_required
def delete_structure(self, structure_id, **kw):
record = request.env['encoach.exam.structure'].sudo().browse(structure_id)
if not record.exists():
return _error_response('Structure not found', 404)
record.unlink()
return _json_response({'success': True})

View File

@@ -3,14 +3,13 @@ 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,
)
_logger = logging.getLogger(__name__)
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
def _rubric_to_dict(rec):
criteria_text = rec.criteria or ''
criteria_count = 0
@@ -26,6 +25,15 @@ def _rubric_to_dict(rec):
except (json.JSONDecodeError, ValueError):
criteria_count = len([l for l in criteria_text.split('\n') if l.strip()])
levels = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2']
if rec.levels:
try:
parsed_levels = json.loads(rec.levels)
if isinstance(parsed_levels, list) and parsed_levels:
levels = parsed_levels
except (json.JSONDecodeError, ValueError):
pass
return {
'id': rec.id,
'name': rec.name,
@@ -33,15 +41,16 @@ def _rubric_to_dict(rec):
'exam_type': rec.exam_type or '',
'criteria': criteria_count or 1,
'criteria_text': criteria_text,
'levels': ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'],
'levels': levels,
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
}
class EncoachRubricController(http.Controller):
@http.route('/api/rubrics', type='http', auth='user',
@http.route('/api/rubrics', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_rubrics(self, **kw):
try:
Rubric = request.env['encoach.rubric'].sudo()
@@ -58,14 +67,15 @@ class EncoachRubricController(http.Controller):
_logger.exception('rubrics list failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubrics', type='http', auth='user',
@http.route('/api/rubrics', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_rubric(self, **kw):
try:
body = json.loads(request.httprequest.data or '{}')
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _json_response({'error': 'name is required'}, 400)
return _error_response('name is required', 400)
vals = {
'name': name,
@@ -73,8 +83,140 @@ class EncoachRubricController(http.Controller):
'criteria': body.get('criteria', ''),
'exam_type': body.get('exam_type', 'academic'),
}
rec = Rubric = request.env['encoach.rubric'].sudo().create(vals)
if 'levels' in body:
vals['levels'] = json.dumps(body['levels'])
rec = request.env['encoach.rubric'].sudo().create(vals)
return _json_response(_rubric_to_dict(rec), 201)
except Exception as e:
_logger.exception('rubric create failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubrics/<int:rubric_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update_rubric(self, rubric_id, **kw):
try:
rec = request.env['encoach.rubric'].sudo().browse(rubric_id)
if not rec.exists():
return _error_response('Rubric not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'skill' in body:
vals['skill'] = body['skill']
if 'criteria' in body:
vals['criteria'] = body['criteria']
if 'exam_type' in body:
vals['exam_type'] = body['exam_type']
if 'levels' in body:
vals['levels'] = json.dumps(body['levels'])
if vals:
rec.write(vals)
return _json_response(_rubric_to_dict(rec))
except Exception as e:
_logger.exception('rubric update failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubrics/<int:rubric_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_rubric(self, rubric_id, **kw):
try:
rec = request.env['encoach.rubric'].sudo().browse(rubric_id)
if not rec.exists():
return _error_response('Rubric not found', 404)
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('rubric delete failed')
return _json_response({'error': str(e)}, 500)
def _group_to_dict(rec):
return {
'id': rec.id,
'name': rec.name,
'rubric_ids': rec.rubric_ids.ids,
'rubric_names': [r.name for r in rec.rubric_ids],
'created': rec.create_date.strftime('%Y-%m-%d') if rec.create_date else '',
}
class EncoachRubricGroupController(http.Controller):
@http.route('/api/rubric-groups', type='http', auth='none',
methods=['GET'], csrf=False)
@jwt_required
def list_rubric_groups(self, **kw):
try:
Group = request.env['encoach.rubric.group'].sudo()
limit = int(kw.get('limit', 50))
offset = int(kw.get('offset', 0))
records = Group.search([], limit=limit, offset=offset,
order='create_date desc')
total = Group.search_count([])
return _json_response({
'items': [_group_to_dict(r) for r in records],
'total': total,
})
except Exception as e:
_logger.exception('rubric-groups list failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubric-groups', type='http', auth='none',
methods=['POST'], csrf=False)
@jwt_required
def create_rubric_group(self, **kw):
try:
body = _get_json_body()
name = body.get('name', '').strip()
if not name:
return _error_response('name is required', 400)
rubric_ids = body.get('rubric_ids', [])
rec = request.env['encoach.rubric.group'].sudo().create({
'name': name,
'rubric_ids': [(6, 0, rubric_ids)],
})
return _json_response(_group_to_dict(rec), 201)
except Exception as e:
_logger.exception('rubric-group create failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubric-groups/<int:group_id>', type='http', auth='none',
methods=['PUT'], csrf=False)
@jwt_required
def update_rubric_group(self, group_id, **kw):
try:
rec = request.env['encoach.rubric.group'].sudo().browse(group_id)
if not rec.exists():
return _error_response('Group not found', 404)
body = _get_json_body()
vals = {}
if 'name' in body:
vals['name'] = body['name']
if 'rubric_ids' in body:
vals['rubric_ids'] = [(6, 0, body['rubric_ids'])]
if vals:
rec.write(vals)
return _json_response(_group_to_dict(rec))
except Exception as e:
_logger.exception('rubric-group update failed')
return _json_response({'error': str(e)}, 500)
@http.route('/api/rubric-groups/<int:group_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@jwt_required
def delete_rubric_group(self, group_id, **kw):
try:
rec = request.env['encoach.rubric.group'].sudo().browse(group_id)
if not rec.exists():
return _error_response('Group not found', 404)
rec.unlink()
return _json_response({'success': True})
except Exception as e:
_logger.exception('rubric-group delete failed')
return _json_response({'error': str(e)}, 500)