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

@@ -1,4 +1,5 @@
from . import rubric
from . import rubric_group
from . import exam_template
from . import passage
from . import audio_file
@@ -8,4 +9,6 @@ from . import speaking_card
from . import exam_custom
from . import exam_custom_section
from . import exam_assignment
from . import exam_schedule
from . import exam_structure
from . import student_attempt

View File

@@ -6,6 +6,7 @@ class EncoachExamAssignment(models.Model):
_description = 'Exam Assignment'
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
schedule_id = fields.Many2one('encoach.exam.schedule', ondelete='cascade')
student_id = fields.Many2one('res.users', ondelete='cascade')
batch_id = fields.Many2one('op.batch', ondelete='set null')
access_start = fields.Datetime()

View File

@@ -6,13 +6,32 @@ class EncoachExamCustom(models.Model):
_description = 'Custom Exam'
title = fields.Char(size=200, required=True)
label = fields.Char(size=100)
exam_mode = fields.Selection([
('official', 'Official'),
('practice', 'Practice'),
], default='official')
template_id = fields.Many2one('encoach.exam.template', ondelete='set null')
structure_id = fields.Many2one('encoach.exam.structure', 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')
rubric_id = fields.Many2one('encoach.rubric', ondelete='set null')
approval_workflow_id = fields.Integer()
description = fields.Text()
total_time_min = fields.Integer()
total_marks = fields.Float()
pass_threshold = fields.Float()
grading_system = fields.Selection([
('ielts', 'IELTS Band'),
('percentage', 'Percentage'),
('pass_fail', 'Pass / Fail'),
('cefr', 'CEFR Level'),
], default='ielts')
access_type = fields.Selection([
('private', 'Private'),
('public', 'Public'),
], default='private')
results_release_mode = fields.Selection([
('auto', 'Auto'),
('manual_approval', 'Manual Approval'),

View File

@@ -1,3 +1,4 @@
import json
from odoo import models, fields
@@ -8,14 +9,19 @@ class EncoachExamCustomSection(models.Model):
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
title = fields.Char(size=200, required=True)
skill = fields.Char(size=100)
difficulty = fields.Char(size=50)
question_count = fields.Integer()
time_limit_min = fields.Integer()
total_marks = fields.Float()
scoring_method = fields.Selection([
('auto', 'Auto'),
('rubric', 'Rubric'),
('mixed', 'Mixed'),
], default='auto')
sequence = fields.Integer(default=10)
passage_text = fields.Text()
instructions_text = fields.Text()
content_json = fields.Text(help='JSON blob for tasks/parts/passages/sections config')
question_ids = fields.Many2many(
'encoach.question',
'exam_custom_section_question_rel',

View File

@@ -0,0 +1,68 @@
from odoo import models, fields, api
from datetime import datetime
class EncoachExamSchedule(models.Model):
_name = 'encoach.exam.schedule'
_description = 'Exam Schedule / Assignment Group'
_order = 'create_date desc'
name = fields.Char(required=True, size=200)
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
start_date = fields.Datetime(required=True)
end_date = fields.Datetime(required=True)
full_length = fields.Boolean(default=True)
generate_different = fields.Boolean(default=False)
auto_release_results = fields.Boolean(default=False)
auto_start = fields.Boolean(default=False)
official_exam = fields.Boolean(default=False)
hide_assignee_details = fields.Boolean(default=False)
assign_mode = fields.Selection([
('entity', 'Entire Entity'),
('batch', 'Class / Batch'),
('individual', 'Individual Students'),
], default='batch', required=True)
batch_ids = fields.Many2many('op.batch', string='Classes')
student_ids = fields.Many2many('res.users', 'exam_schedule_student_rel',
'schedule_id', 'user_id', string='Students')
state = fields.Selection([
('planned', 'Planned'),
('active', 'Active'),
('past', 'Past'),
('start_expired', 'Start Expired'),
('archived', 'Archived'),
], default='planned', required=True, index=True)
assignment_ids = fields.One2many('encoach.exam.assignment', 'schedule_id')
assignee_count = fields.Integer(compute='_compute_counts', store=True)
completed_count = fields.Integer(compute='_compute_counts', store=True)
@api.depends('assignment_ids', 'assignment_ids.status')
def _compute_counts(self):
for rec in self:
assignments = rec.assignment_ids
rec.assignee_count = len(assignments)
rec.completed_count = len(assignments.filtered(lambda a: a.status == 'completed'))
def update_lifecycle_states(self):
"""Cron job: transition schedules based on current time."""
now = datetime.now()
planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '>', now)])
planned.write({'state': 'active'})
expired_planned = self.search([('state', '=', 'planned'), ('start_date', '<=', now), ('end_date', '<=', now)])
expired_planned.write({'state': 'start_expired'})
active_ended = self.search([('state', '=', 'active'), ('end_date', '<=', now)])
active_ended.write({'state': 'past'})
for rec in active_ended:
rec.assignment_ids.filtered(
lambda a: a.status in ('assigned', 'started')
).write({'status': 'expired'})

View File

@@ -11,6 +11,7 @@ class EncoachRubric(models.Model):
('speaking', 'Speaking'),
], required=True)
criteria = fields.Text(required=True)
levels = fields.Text(help='JSON list of CEFR levels, e.g. ["A1","A2","B1","B2","C1","C2"]')
exam_type = fields.Selection([
('academic', 'Academic'),
('general_training', 'General Training'),

View File

@@ -0,0 +1,15 @@
from odoo import models, fields
class EncoachRubricGroup(models.Model):
_name = 'encoach.rubric.group'
_description = 'Rubric Group'
name = fields.Char(size=200, required=True)
rubric_ids = fields.Many2many(
'encoach.rubric',
'encoach_rubric_group_rel',
'group_id',
'rubric_id',
string='Rubrics',
)

View File

@@ -0,0 +1,52 @@
from odoo import models, fields
class EncoachStudentAttempt(models.Model):
_name = 'encoach.student.attempt'
_description = 'Student Exam Attempt'
_order = 'id desc'
student_id = fields.Many2one('res.users', required=True, ondelete='cascade')
exam_id = fields.Many2one('encoach.exam.custom', required=True, ondelete='cascade')
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
status = fields.Selection([
('in_progress', 'In Progress'),
('scoring', 'Scoring'),
('completed', 'Completed'),
('abandoned', 'Abandoned'),
], default='in_progress', required=True)
started_at = fields.Datetime(default=fields.Datetime.now)
finished_at = fields.Datetime()
overall_band = fields.Float()
cefr_level = fields.Char(size=10)
listening_band = fields.Float()
reading_band = fields.Float()
writing_band = fields.Float()
speaking_band = fields.Float()
total_score = fields.Float()
max_score = fields.Float()
class EncoachStudentAnswer(models.Model):
_name = 'encoach.student.answer'
_description = 'Student Answer'
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
question_id = fields.Many2one('encoach.question', required=True, ondelete='cascade')
answer = fields.Text()
score = fields.Float()
is_correct = fields.Boolean()
feedback = fields.Text()
class EncoachStudentScore(models.Model):
_name = 'encoach.student.score'
_description = 'Student Skill Score'
attempt_id = fields.Many2one('encoach.student.attempt', required=True, ondelete='cascade')
skill = fields.Char(size=50, required=True)
band_score = fields.Float()
raw_score = fields.Float()
max_score = fields.Float()
cefr_level = fields.Char(size=10)
entity_id = fields.Many2one('encoach.entity', ondelete='set null')