feat(generation): rebuild Generation Page with full AI workflows

- Rebuild GenerationPage.tsx from static placeholder to production-parity
  exam generation wizard with all 4 IELTS modules (Reading, Listening,
  Writing, Speaking) plus Level and Industry
- Add per-module config: timer, CEFR difficulty tags, access type,
  entities, approval workflow, rubric, grading system, shuffling
- Reading: AI passage generation, 5 exercise types (MCQ, Fill Blanks,
  Write Blanks, True/False, Paragraph Match), categories/types
- Listening: 4 section types, AI context generation, TTS audio generation
- Writing: Task 1/2, AI instruction generation, word limits, marks
- Speaking: 3 parts, AI script generation, avatar video generation
  with 7 avatar options
- Wire ExamStructuresPage to real CRUD API (list/create/delete)
- Add backend exam_structure model and controller (/api/exam-structures)
- Enhance ai_controller with 5 specialized generation handlers
  (passage, exercises, writing instructions, speaking script,
  listening context)
- Add POST /api/exam/generation/submit for exam creation workflow
- Fix media.service avatar video endpoint alignment
- All 12 API tests passed, browser-verified with real OpenAI calls

Made-with: Cursor
This commit is contained in:
Yamen Ahmad
2026-04-11 14:21:40 +04:00
parent f1c4953a63
commit 140ca7408d
11 changed files with 2134 additions and 103 deletions

View File

@@ -1,3 +1,4 @@
from . import templates
from . import ielts_exam
from . import custom_exam
from . import exam_structures

View File

@@ -0,0 +1,87 @@
import json
import logging
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
def _json_body():
try:
return json.loads(request.httprequest.data or '{}')
except Exception:
return {}
def _json_response(data, status=200):
return request.make_json_response(data, status=status)
class ExamStructureController(http.Controller):
@http.route('/api/exam-structures', type='http', auth='user', methods=['GET'], csrf=False)
def list_structures(self, **kw):
domain = [('active', '=', True)]
entity_id = kw.get('entity_id')
if entity_id:
domain.append(('entity_id', '=', int(entity_id)))
limit = int(kw.get('limit', 50))
offset = int(kw.get('offset', 0))
records = request.env['encoach.exam.structure'].search(domain, limit=limit, offset=offset, order='create_date desc')
total = request.env['encoach.exam.structure'].search_count(domain)
items = []
for r in records:
modules = []
if r.modules:
try:
modules = json.loads(r.modules)
except Exception:
modules = []
items.append({
'id': r.id,
'name': r.name,
'entity_id': r.entity_id.id if r.entity_id else None,
'entity_name': r.entity_id.name if r.entity_id else None,
'industry': r.industry or '',
'modules': modules,
'config': json.loads(r.config) if r.config else {},
})
return _json_response({'items': items, 'total': total})
@http.route('/api/exam-structures', type='http', auth='user', methods=['POST'], csrf=False)
def create_structure(self, **kw):
body = _json_body()
name = body.get('name')
if not name:
return _json_response({'error': 'name is required'}, status=400)
vals = {
'name': name,
'industry': body.get('industry', ''),
'modules': json.dumps(body.get('modules', [])),
'config': json.dumps(body.get('config', {})),
}
entity_id = body.get('entity_id')
if entity_id:
vals['entity_id'] = int(entity_id)
record = request.env['encoach.exam.structure'].create(vals)
return _json_response({
'id': record.id,
'name': record.name,
'entity_id': record.entity_id.id if record.entity_id else None,
'industry': record.industry or '',
'modules': json.loads(record.modules) if record.modules else [],
})
@http.route('/api/exam-structures/<int:structure_id>', type='http', auth='user', methods=['DELETE'], csrf=False)
def delete_structure(self, structure_id, **kw):
record = request.env['encoach.exam.structure'].browse(structure_id)
if not record.exists():
return _json_response({'error': 'Structure not found'}, status=404)
record.unlink()
return _json_response({'success': True})

View File

@@ -8,3 +8,4 @@ from . import speaking_card
from . import exam_custom
from . import exam_custom_section
from . import exam_assignment
from . import exam_structure

View File

@@ -0,0 +1,14 @@
from odoo import models, fields
class EncoachExamStructure(models.Model):
_name = 'encoach.exam.structure'
_description = 'Reusable Exam Structure'
_order = 'create_date desc'
name = fields.Char(size=200, required=True)
entity_id = fields.Many2one('encoach.entity', ondelete='set null')
industry = fields.Char(size=100)
modules = fields.Text(help='JSON list of module keys, e.g. ["reading","listening"]')
config = fields.Text(help='JSON config: timer, difficulty, passage counts per module')
active = fields.Boolean(default=True)

View File

@@ -9,3 +9,4 @@ access_encoach_rubric_user,encoach.rubric.user,model_encoach_rubric,base.group_u
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
access_encoach_exam_structure_user,encoach.exam.structure.user,model_encoach_exam_structure,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
9 access_encoach_exam_custom_user encoach.exam.custom.user model_encoach_exam_custom base.group_user 1 1 1 1
10 access_encoach_exam_custom_section_user encoach.exam.custom.section.user model_encoach_exam_custom_section base.group_user 1 1 1 1
11 access_encoach_exam_assignment_user encoach.exam.assignment.user model_encoach_exam_assignment base.group_user 1 1 1 1
12 access_encoach_exam_structure_user encoach.exam.structure.user model_encoach_exam_structure base.group_user 1 1 1 1