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:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
68
custom_addons/encoach_exam_template/models/exam_schedule.py
Normal file
68
custom_addons/encoach_exam_template/models/exam_schedule.py
Normal 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'})
|
||||
@@ -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'),
|
||||
|
||||
15
custom_addons/encoach_exam_template/models/rubric_group.py
Normal file
15
custom_addons/encoach_exam_template/models/rubric_group.py
Normal 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',
|
||||
)
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user