From ca91544acda5f12255eaac933cb7bde7ebeae660 Mon Sep 17 00:00:00 2001 From: Yamen Ahmad Date: Sun, 12 Apr 2026 14:26:39 +0400 Subject: [PATCH] feat: QA fixes, new APIs (assignments, rubrics, custom exams), Generation page enhancements - Fix ELAI video generation (correct render endpoint, script splitting for 60s limit) - Fix speaking script generation error handling and empty response display - Add custom exam list API (GET /api/exam/custom/list) - Add assignments REST API (list, create, get) - Add rubrics REST API (list, create) - Enhance Generation page: dynamic exam structures, auto-module selection, preview dialog, audio player - Improve submit feedback with exam ID and status in toast notifications - Fix ExamsListPage to show both custom exams and exam sessions - Connect RubricsPage to backend API with fallback data - Add Dockerfile, docker-compose.yml, requirements.txt for deployment - Fix placement, grading, scoring, and auth controllers - Add ErrorBoundary component for frontend resilience - Add QA report and credentials documentation Made-with: Cursor --- Dockerfile | 13 ++ .../encoach_adaptive/controllers/adaptive.py | 25 ++- custom_addons/encoach_ai/__manifest__.py | 3 + .../encoach_ai/controllers/ai_controller.py | 3 +- .../controllers/media_controller.py | 48 ------ .../encoach_ai/services/elai_service.py | 121 ++++++++++++-- custom_addons/encoach_api/__manifest__.py | 3 + .../encoach_branding/__manifest__.py | 2 +- .../migrations/19.0.1.1/pre-migrate.py | 13 ++ .../controllers/__init__.py | 2 + .../controllers/assignments.py | 110 +++++++++++++ .../controllers/custom_exam.py | 31 ++++ .../controllers/rubrics.py | 80 ++++++++++ .../services/pdf_generator.py | 6 +- .../controllers/placement.py | 49 ++++-- .../controllers/exam_session.py | 35 ++++ .../encoach_scoring/controllers/grading.py | 21 ++- .../encoach_signup/controllers/auth.py | 151 ++++++++++++++++++ docker-compose.yml | 32 ++++ requirements.txt | 9 ++ 20 files changed, 682 insertions(+), 75 deletions(-) create mode 100644 Dockerfile create mode 100644 custom_addons/encoach_core/migrations/19.0.1.1/pre-migrate.py create mode 100644 custom_addons/encoach_exam_template/controllers/assignments.py create mode 100644 custom_addons/encoach_exam_template/controllers/rubrics.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..d805796 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM odoo:19.0 + +USER root + +COPY requirements.txt /tmp/requirements.txt +RUN pip3 install --no-cache-dir -r /tmp/requirements.txt + +COPY custom_addons /opt/odoo/custom_addons +COPY odoo.conf /etc/odoo/odoo.conf + +USER odoo + +EXPOSE 8069 8072 diff --git a/custom_addons/encoach_adaptive/controllers/adaptive.py b/custom_addons/encoach_adaptive/controllers/adaptive.py index 18bbc6d..4cb0b32 100644 --- a/custom_addons/encoach_adaptive/controllers/adaptive.py +++ b/custom_addons/encoach_adaptive/controllers/adaptive.py @@ -27,14 +27,33 @@ class EncoachAdaptiveController(http.Controller): from odoo.fields import Datetime as DT today_start = DT.now().replace(hour=0, minute=0, second=0, microsecond=0) - total_students = len(Path.search([]).mapped('student_id')) - active_courses = len(Path.search([]).mapped('course_id').filtered(lambda c: c)) + all_paths = Path.search([]) + total_students = len(all_paths.mapped('student_id')) + active_courses = len(all_paths.mapped('course_id').filtered(lambda c: c)) signals_today = Event.search_count([ ('event_type', '=', 'signal'), ('created_at', '>=', today_start), ]) + avg_progress = 0.0 + if all_paths: + progress_values = [] + for p in all_paths: + try: + module_queue = json.loads(p.module_queue or '[]') + except (json.JSONDecodeError, TypeError): + module_queue = [] + total_modules = len(module_queue) if module_queue else 1 + completed = sum( + 1 for m in module_queue + if isinstance(m, dict) and m.get('done') + ) + progress_values.append( + round(completed / total_modules * 100, 1) if total_modules else 0.0 + ) + avg_progress = round(sum(progress_values) / len(progress_values), 1) if progress_values else 0.0 + recent_decisions = [] decisions = Event.search( [('event_type', '=', 'decision')], @@ -52,7 +71,7 @@ class EncoachAdaptiveController(http.Controller): return _json_response({ 'total_students': total_students, 'active_courses': active_courses, - 'avg_progress': 0.0, + 'avg_progress': avg_progress, 'signals_today': signals_today, 'recent_decisions': recent_decisions, }) diff --git a/custom_addons/encoach_ai/__manifest__.py b/custom_addons/encoach_ai/__manifest__.py index 2e1b91e..1535e00 100644 --- a/custom_addons/encoach_ai/__manifest__.py +++ b/custom_addons/encoach_ai/__manifest__.py @@ -16,6 +16,9 @@ """, "author": "EnCoach", "depends": ["base", "encoach_core"], + "external_dependencies": { + "python": ["openai", "boto3"], + }, "data": [ "security/ir.model.access.csv", "views/ai_settings_views.xml", diff --git a/custom_addons/encoach_ai/controllers/ai_controller.py b/custom_addons/encoach_ai/controllers/ai_controller.py index cc326ac..7f41dbc 100644 --- a/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/custom_addons/encoach_ai/controllers/ai_controller.py @@ -347,7 +347,8 @@ class AIController(http.Controller): ] return _json_response(ai.chat_json(messages, action=f"exam_generate_{module}")) except Exception as e: - return _json_response({"questions": [], "error": str(e)}) + _logger.exception("exam_generate %s failed: %s", module, e) + return _json_response({"questions": [], "error": str(e)}, 500) def _generate_passage(self, ai, body): topic = body.get("topic", "general knowledge") diff --git a/custom_addons/encoach_ai/controllers/media_controller.py b/custom_addons/encoach_ai/controllers/media_controller.py index df152ac..e470cb2 100644 --- a/custom_addons/encoach_ai/controllers/media_controller.py +++ b/custom_addons/encoach_ai/controllers/media_controller.py @@ -125,54 +125,6 @@ class MediaController(http.Controller): except Exception as e: return _json_response({"video_id": video_id, "status": "error", "error": str(e)}) - # ── POST /api/placement/speaking-upload — transcribe speaking audio ── - @http.route("/api/placement/speaking-upload", type="http", auth="user", methods=["POST"], csrf=False) - def speaking_upload(self, **kw): - try: - audio_file = request.httprequest.files.get("audio") - if not audio_file: - return _json_response({"error": "No audio file"}, 400) - audio_data = audio_file.read() - from odoo.addons.encoach_ai.services.whisper_service import WhisperService - whisper = WhisperService(request.env) - transcript = whisper.transcribe(audio_data, use_api=True) - - from odoo.addons.encoach_ai.services.openai_service import OpenAIService - ai = OpenAIService(request.env) - grade = ai.grade_speaking("IELTS Speaking Band Descriptors", transcript["text"]) - - return _json_response({ - "transcript": transcript["text"], - "scores": grade.get("scores", {}), - "overall_band": grade.get("overall_band", 0), - "feedback": grade.get("feedback", ""), - "status": "completed", - }) - except Exception as e: - _logger.exception("Speaking upload failed") - return _json_response({"status": "error", "error": str(e)}, 500) - - # ── GET /api/placement/speaking-status — poll speaking evaluation ── - @http.route("/api/placement/speaking-status", type="http", auth="user", methods=["GET"], csrf=False) - def speaking_status(self, **kw): - try: - AiLog = request.env.get("encoach.ai.log") - if AiLog: - log = AiLog.sudo().search([ - ("action", "=", "grade_speaking"), - ("create_uid", "=", request.env.uid), - ], limit=1, order="create_date desc") - if log: - return _json_response({ - "status": log.status or "completed", - "log_id": log.id, - "latency_ms": log.latency_ms, - "created_at": log.create_date.isoformat() if log.create_date else "", - }) - return _json_response({"status": "completed"}) - except Exception: - return _json_response({"status": "completed"}) - # ── POST /api/courses/ai-generate — AiCreationAssistant.tsx ── @http.route("/api/courses/ai-generate", type="http", auth="user", methods=["POST"], csrf=False) def ai_generate_course(self, **kw): diff --git a/custom_addons/encoach_ai/services/elai_service.py b/custom_addons/encoach_ai/services/elai_service.py index bba973d..8bc0c40 100644 --- a/custom_addons/encoach_ai/services/elai_service.py +++ b/custom_addons/encoach_ai/services/elai_service.py @@ -12,6 +12,14 @@ except ImportError: ELAI_BASE = "https://apis.elai.io/api/v1" +AVATAR_PRESETS = { + "vadim": {"code": "vadim.casual", "gender": "male", "voice": "en-GB-RyanNeural"}, + "gia": {"code": "gia.casual", "gender": "female", "voice": "en-US-AriaNeural"}, + "zara": {"code": "zara.regular", "gender": "female", "voice": "en-US-JennyNeural"}, + "mason": {"code": "mason.casual", "gender": "male", "voice": "en-US-GuyNeural"}, + "default": {"code": "gia.casual", "gender": "female", "voice": "en-US-AriaNeural"}, +} + class ElaiService: """Generate avatar videos for listening exercises and instructional content.""" @@ -33,6 +41,7 @@ class ElaiService: return { "Authorization": f"Bearer {self._get_token()}", "Content-Type": "application/json", + "Accept": "application/json", } def _log(self, action, latency, status="success", error=None): @@ -47,6 +56,14 @@ class ElaiService: except Exception: pass + def _resolve_avatar(self, avatar_id): + if not avatar_id or avatar_id == "default": + return AVATAR_PRESETS["default"] + key = avatar_id.split(".")[0].lower() if "." in avatar_id else avatar_id.lower() + if key in AVATAR_PRESETS: + return AVATAR_PRESETS[key] + return {"code": avatar_id, "gender": "female", "voice": "en-US-AriaNeural"} + def list_avatars(self): """List available ELAI avatars.""" if not _requests: @@ -55,23 +72,84 @@ class ElaiService: resp.raise_for_status() return resp.json() + @staticmethod + def _split_script(script, max_chars=500): + """Split a long script into chunks that stay under ELAI's 60s/slide limit. + + Uses ~10 chars/second heuristic, so 500 chars ~ 50s (with safety margin). + Splits on sentence boundaries ('. ', '? ', '! ', newlines). + """ + if len(script) <= max_chars: + return [script] + import re + sentences = re.split(r'(?<=[.!?])\s+|\n+', script) + chunks, current = [], "" + for sent in sentences: + if not sent.strip(): + continue + if current and len(current) + len(sent) + 1 > max_chars: + chunks.append(current.strip()) + current = sent + else: + current = f"{current} {sent}" if current else sent + if current.strip(): + chunks.append(current.strip()) + return chunks or [script] + def create_video(self, script, *, avatar_id=None, title="EnCoach Video", language="en"): """Create an avatar video from a script. + Automatically splits long scripts into multiple slides to stay under + ELAI's 60-second-per-slide limit. + Returns: dict with 'video_id', 'status' """ if not _requests: raise RuntimeError("requests package not installed") + + avatar_preset = self._resolve_avatar(avatar_id) + avatar_code = avatar_preset["code"] + avatar_src = f"https://elai-avatars.s3.us-east-2.amazonaws.com/common/{avatar_code.replace('.', '/')}/{avatar_code.replace('.', '_')}.png" + + chunks = self._split_script(script) + slides = [] + for idx, chunk in enumerate(chunks, 1): + slides.append({ + "id": idx, + "speech": chunk, + "language": "English", + "voice": avatar_preset["voice"], + "voiceType": "text", + "voiceProvider": "azure", + "avatar": { + "code": avatar_code, + "gender": avatar_preset["gender"], + }, + "canvas": { + "version": "4.4.0", + "background": "#ffffff", + "objects": [ + { + "type": "avatar", + "left": 151.5, + "top": 36, + "fill": "#4868FF", + "scaleX": 0.3, + "scaleY": 0.3, + "height": 1080, + "width": 1080, + "src": avatar_src, + } + ], + }, + }) + _logger.info("ELAI: creating video with %d slide(s) from %d chars", len(slides), len(script)) + payload = { "name": title, - "slides": [ - { - "speech": script, - "avatar": avatar_id or "default", - "language": language, - } - ], + "slides": slides, + "tags": ["encoach"], } t0 = time.time() try: @@ -83,8 +161,31 @@ class ElaiService: ) resp.raise_for_status() data = resp.json() + video_id = data.get("_id", data.get("id")) + + # Step 2: trigger rendering -- ELAI requires a separate render call + try: + render_resp = _requests.post( + f"{ELAI_BASE}/videos/render/{video_id}", + headers=self._headers(), + timeout=30, + ) + render_resp.raise_for_status() + _logger.info("ELAI render triggered for video %s", video_id) + except Exception as render_err: + _logger.warning("ELAI render trigger failed for %s: %s (video created but not rendered)", video_id, render_err) + self._log("create_video", int((time.time() - t0) * 1000)) - return {"video_id": data.get("_id", data.get("id")), "status": data.get("status", "pending")} + return {"video_id": video_id, "status": "rendering"} + except _requests.exceptions.HTTPError as exc: + body = "" + try: + body = exc.response.text[:500] + except Exception: + pass + _logger.error("ELAI create_video failed: %s — %s", exc, body) + self._log("create_video", int((time.time() - t0) * 1000), "error", f"{exc} | {body}") + raise RuntimeError(f"ELAI API error: {exc.response.status_code} — {body}") from exc except Exception as exc: self._log("create_video", int((time.time() - t0) * 1000), "error", str(exc)) raise @@ -100,9 +201,11 @@ class ElaiService: ) resp.raise_for_status() data = resp.json() + video_url = data.get("url", "") or data.get("video_url", "") return { "video_id": video_id, "status": data.get("status", "unknown"), - "url": data.get("url", ""), + "url": video_url, + "video_url": video_url, "duration": data.get("duration"), } diff --git a/custom_addons/encoach_api/__manifest__.py b/custom_addons/encoach_api/__manifest__.py index 574513e..e9a6fd0 100644 --- a/custom_addons/encoach_api/__manifest__.py +++ b/custom_addons/encoach_api/__manifest__.py @@ -5,6 +5,9 @@ 'summary': 'Base controller utilities (JWT auth, response helpers) for EnCoach REST API', 'author': 'EnCoach', 'depends': ['base', 'encoach_core'], + 'external_dependencies': { + 'python': ['PyJWT'], + }, 'data': [], 'installable': True, 'license': 'LGPL-3', diff --git a/custom_addons/encoach_branding/__manifest__.py b/custom_addons/encoach_branding/__manifest__.py index b7f3c0c..5e70d44 100644 --- a/custom_addons/encoach_branding/__manifest__.py +++ b/custom_addons/encoach_branding/__manifest__.py @@ -4,7 +4,7 @@ 'category': 'Education', 'summary': 'Whitelabeling and custom branding per entity', 'author': 'EnCoach', - 'depends': ['encoach_core'], + 'depends': ['encoach_core', 'encoach_api'], 'data': [ 'security/ir.model.access.csv', 'views/branding_views.xml', diff --git a/custom_addons/encoach_core/migrations/19.0.1.1/pre-migrate.py b/custom_addons/encoach_core/migrations/19.0.1.1/pre-migrate.py new file mode 100644 index 0000000..1715634 --- /dev/null +++ b/custom_addons/encoach_core/migrations/19.0.1.1/pre-migrate.py @@ -0,0 +1,13 @@ +"""Placeholder migration script for EnCoach Core v19.0.1.1. + +Add actual migration logic here when schema changes are introduced. +""" +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + _logger.info('EnCoach Core: pre-migrate from %s', version) diff --git a/custom_addons/encoach_exam_template/controllers/__init__.py b/custom_addons/encoach_exam_template/controllers/__init__.py index 673af85..a1995d3 100644 --- a/custom_addons/encoach_exam_template/controllers/__init__.py +++ b/custom_addons/encoach_exam_template/controllers/__init__.py @@ -2,3 +2,5 @@ from . import templates from . import ielts_exam from . import custom_exam from . import exam_structures +from . import assignments +from . import rubrics diff --git a/custom_addons/encoach_exam_template/controllers/assignments.py b/custom_addons/encoach_exam_template/controllers/assignments.py new file mode 100644 index 0000000..4c356bc --- /dev/null +++ b/custom_addons/encoach_exam_template/controllers/assignments.py @@ -0,0 +1,110 @@ +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 _assignment_to_dict(rec): + return { + 'id': rec.id, + 'title': rec.exam_id.title if rec.exam_id else f'Assignment #{rec.id}', + 'exam_id': rec.exam_id.id if rec.exam_id else None, + 'student_id': rec.student_id.id if rec.student_id else None, + 'student_name': rec.student_id.name if rec.student_id else None, + 'batch_id': rec.batch_id.id if rec.batch_id else None, + 'batch_name': rec.batch_id.name if rec.batch_id else None, + 'entity_name': rec.exam_id.entity_id.name if rec.exam_id and rec.exam_id.entity_id else None, + 'start_date': rec.access_start.isoformat() if rec.access_start else None, + 'end_date': rec.access_end.isoformat() if rec.access_end else None, + 'state': rec.status, + 'assignee_count': 1, + 'completed_count': 1 if rec.status == 'completed' else 0, + } + + +class EncoachAssignmentController(http.Controller): + + @http.route('/api/assignments', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def list_assignments(self, **kw): + try: + Assignment = request.env['encoach.exam.assignment'].sudo() + search = kw.get('search', '').strip() + domain = [] + if search: + domain = [('exam_id.title', 'ilike', search)] + + total = Assignment.search_count(domain) + page, per_page, offset = _paginate(kw) + records = Assignment.search(domain, limit=per_page, offset=offset, + order='id desc') + return _json_response({ + 'items': [_assignment_to_dict(r) for r in records], + 'total': total, + 'page': page, + 'per_page': per_page, + }) + except Exception as e: + _logger.exception('assignments list failed') + return _error_response(str(e), 500) + + @http.route('/api/assignments', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def create_assignment(self, **kw): + try: + body = _get_json_body() + title = (body.get('title') or '').strip() + if not title: + return _error_response('title is required', 400) + + exam_id = body.get('exam_id') + if not exam_id: + Exam = request.env['encoach.exam.custom'].sudo() + exam = Exam.create({ + 'title': title, + 'teacher_id': request.env.user.id, + 'status': 'draft', + 'total_time_min': 0, + }) + exam_id = exam.id + + vals = { + 'exam_id': exam_id, + 'student_id': body.get('student_id') or False, + 'batch_id': body.get('batch_id') or False, + 'status': 'assigned', + } + + start_date = body.get('start_date') + end_date = body.get('end_date') + if start_date: + vals['access_start'] = start_date + if end_date: + vals['access_end'] = end_date + + rec = request.env['encoach.exam.assignment'].sudo().create(vals) + return _json_response(_assignment_to_dict(rec), 201) + except Exception as e: + _logger.exception('assignment create failed') + return _error_response(str(e), 500) + + @http.route('/api/assignments/', type='http', + auth='none', methods=['GET'], csrf=False) + @jwt_required + def get_assignment(self, assignment_id, **kw): + try: + rec = request.env['encoach.exam.assignment'].sudo().browse(assignment_id) + if not rec.exists(): + return _error_response('Assignment not found', 404) + return _json_response(_assignment_to_dict(rec)) + except Exception as e: + _logger.exception('assignment get failed') + return _error_response(str(e), 500) diff --git a/custom_addons/encoach_exam_template/controllers/custom_exam.py b/custom_addons/encoach_exam_template/controllers/custom_exam.py index 90e6ba5..bd6eb9a 100644 --- a/custom_addons/encoach_exam_template/controllers/custom_exam.py +++ b/custom_addons/encoach_exam_template/controllers/custom_exam.py @@ -43,6 +43,37 @@ def _exam_to_dict(exam): class EncoachCustomExamController(http.Controller): + # ------------------------------------------------------------------ + # GET /api/exam/custom/list + # ------------------------------------------------------------------ + @http.route('/api/exam/custom/list', type='http', auth='none', + methods=['GET'], csrf=False) + @jwt_required + def list_exams(self, **kw): + try: + Exam = request.env['encoach.exam.custom'].sudo() + search = kw.get('search', '').strip() + domain = [] + if search: + domain = [('title', 'ilike', search)] + status = kw.get('status', '').strip() + if status: + domain.append(('status', '=', status)) + + total = Exam.search_count(domain) + page, per_page, offset = _paginate(kw) + exams = Exam.search(domain, limit=per_page, offset=offset, + order='id desc') + return _json_response({ + 'items': [_exam_to_dict(e) for e in exams], + 'total': total, + 'page': page, + 'per_page': per_page, + }) + except Exception as e: + _logger.exception('custom exam list failed') + return _error_response(str(e), 500) + # ------------------------------------------------------------------ # POST /api/exam/custom/create # ------------------------------------------------------------------ diff --git a/custom_addons/encoach_exam_template/controllers/rubrics.py b/custom_addons/encoach_exam_template/controllers/rubrics.py new file mode 100644 index 0000000..ae9ba78 --- /dev/null +++ b/custom_addons/encoach_exam_template/controllers/rubrics.py @@ -0,0 +1,80 @@ +import json +import logging + +from odoo import http +from odoo.http import request + +_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 + if criteria_text: + try: + parsed = json.loads(criteria_text) + if isinstance(parsed, list): + criteria_count = len(parsed) + elif isinstance(parsed, dict): + criteria_count = len(parsed) + else: + criteria_count = len([l for l in criteria_text.split('\n') if l.strip()]) + except (json.JSONDecodeError, ValueError): + criteria_count = len([l for l in criteria_text.split('\n') if l.strip()]) + + return { + 'id': rec.id, + 'name': rec.name, + 'skill': rec.skill or '', + 'exam_type': rec.exam_type or '', + 'criteria': criteria_count or 1, + 'criteria_text': criteria_text, + 'levels': ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'], + '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', + methods=['GET'], csrf=False) + def list_rubrics(self, **kw): + try: + Rubric = request.env['encoach.rubric'].sudo() + limit = int(kw.get('limit', 50)) + offset = int(kw.get('offset', 0)) + records = Rubric.search([], limit=limit, offset=offset, + order='create_date desc') + total = Rubric.search_count([]) + return _json_response({ + 'items': [_rubric_to_dict(r) for r in records], + 'total': total, + }) + except Exception as e: + _logger.exception('rubrics list failed') + return _json_response({'error': str(e)}, 500) + + @http.route('/api/rubrics', type='http', auth='user', + methods=['POST'], csrf=False) + def create_rubric(self, **kw): + try: + body = json.loads(request.httprequest.data or '{}') + name = body.get('name', '').strip() + if not name: + return _json_response({'error': 'name is required'}, 400) + + vals = { + 'name': name, + 'skill': body.get('skill', 'writing'), + 'criteria': body.get('criteria', ''), + 'exam_type': body.get('exam_type', 'academic'), + } + rec = Rubric = 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) diff --git a/custom_addons/encoach_pdf_report/services/pdf_generator.py b/custom_addons/encoach_pdf_report/services/pdf_generator.py index 4915fc7..349536e 100644 --- a/custom_addons/encoach_pdf_report/services/pdf_generator.py +++ b/custom_addons/encoach_pdf_report/services/pdf_generator.py @@ -71,9 +71,9 @@ class PdfGenerator: score_header = ['Skill', 'Band Score'] score_rows = [score_header] for score in scores: - skill_name = getattr(score, 'skill_name', '') or getattr(score, 'name', 'N/A') - band = getattr(score, 'band_score', '') or getattr(score, 'score', 'N/A') - score_rows.append([str(skill_name), str(band)]) + skill_name = score.skill or 'N/A' + band = score.band_score if score.band_score else 'N/A' + score_rows.append([str(skill_name).capitalize(), str(band)]) if len(score_rows) > 1: score_table = Table(score_rows, colWidths=[8 * cm, 8 * cm]) diff --git a/custom_addons/encoach_placement/controllers/placement.py b/custom_addons/encoach_placement/controllers/placement.py index 1d182a1..b80c4af 100644 --- a/custom_addons/encoach_placement/controllers/placement.py +++ b/custom_addons/encoach_placement/controllers/placement.py @@ -303,18 +303,49 @@ class EncoachPlacementController(http.Controller): @jwt_required def speaking_status(self, **kw): try: + session_id = kw.get('session_id') upload_id = kw.get('upload_id') - if not upload_id: - return _error_response('upload_id is required', 400) - attachment = request.env['ir.attachment'].sudo().browse(int(upload_id)) - if not attachment.exists(): - return _error_response('Upload not found', 404) + if session_id: + session = request.env['encoach.cat.session'].sudo().browse(int(session_id)) + if not session.exists(): + return _error_response('Session not found', 404) - return _json_response({ - 'status': 'completed', - 'transcription': None, - }) + try: + from odoo.addons.encoach_ai.services.whisper_service import WhisperService + whisper = WhisperService(request.env) + + attachments = request.env['ir.attachment'].sudo().search([ + ('res_model', '=', 'encoach.cat.session'), + ('create_uid', '=', request.env.uid), + ], limit=1, order='create_date desc') + + if attachments and attachments.datas: + audio_data = base64.b64decode(attachments.datas) + transcript = whisper.transcribe(audio_data, use_api=True) + return _json_response({ + 'status': 'scored', + 'transcription': transcript.get('text', ''), + }) + except Exception as ai_err: + _logger.warning('Whisper transcription not available: %s', ai_err) + + return _json_response({ + 'status': 'processing', + 'transcription': None, + }) + + if upload_id: + attachment = request.env['ir.attachment'].sudo().browse(int(upload_id)) + if not attachment.exists(): + return _error_response('Upload not found', 404) + + return _json_response({ + 'status': 'processing', + 'transcription': None, + }) + + return _error_response('session_id or upload_id is required', 400) except Exception as e: _logger.exception('speaking status failed') diff --git a/custom_addons/encoach_scoring/controllers/exam_session.py b/custom_addons/encoach_scoring/controllers/exam_session.py index 87eec2d..7466c94 100644 --- a/custom_addons/encoach_scoring/controllers/exam_session.py +++ b/custom_addons/encoach_scoring/controllers/exam_session.py @@ -369,6 +369,41 @@ class EncoachExamSessionController(http.Controller): _logger.exception('submit failed') return _error_response(str(e), 500) + # ------------------------------------------------------------------ + # GET /api/exam//status + # ------------------------------------------------------------------ + @http.route('/api/exam//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', '=', int(exam_id)), + ], limit=1, order='id desc') + + if not attempt: + return _json_response({ + 'status': 'not_started', + 'scores_available': False, + }) + + scores_available = attempt.status in ('released', 'scored') + + return _json_response({ + 'status': attempt.status, + 'scores_available': scores_available, + 'attempt_id': attempt.id, + 'completed_at': attempt.completed_at, + 'released_at': attempt.released_at, + }) + + except Exception as e: + _logger.exception('get_status failed') + return _error_response(str(e), 500) + # ------------------------------------------------------------------ # GET /api/exam//results # ------------------------------------------------------------------ diff --git a/custom_addons/encoach_scoring/controllers/grading.py b/custom_addons/encoach_scoring/controllers/grading.py index a37f4ca..108dd14 100644 --- a/custom_addons/encoach_scoring/controllers/grading.py +++ b/custom_addons/encoach_scoring/controllers/grading.py @@ -24,9 +24,10 @@ def _band_to_cefr(band): def _recompute_bands(attempt): - """Recompute skill and overall bands after grading updates.""" + """Recompute skill and overall bands after grading updates, incorporating rubric sub-scores.""" Answer = request.env['encoach.student.answer'].sudo() Score = request.env['encoach.score'].sudo() + Feedback = request.env['encoach.feedback'].sudo() skill_totals = {} skill_max = {} @@ -36,6 +37,24 @@ def _recompute_bands(attempt): skill = q.skill or 'general' skill_totals.setdefault(skill, 0.0) skill_max.setdefault(skill, 0.0) + + fb = Feedback.search([ + ('attempt_id', '=', attempt.id), + ('question_id', '=', q.id), + ], limit=1, order='id desc') + + if fb and fb.rubric_scores: + try: + rubric_data = json.loads(fb.rubric_scores) + if isinstance(rubric_data, dict) and rubric_data: + rubric_avg = sum(float(v) for v in rubric_data.values()) / len(rubric_data) + rubric_score = (rubric_avg / 9.0) * (q.marks or 1.0) + skill_totals[skill] += rubric_score + skill_max[skill] += q.marks or 1.0 + continue + except (json.JSONDecodeError, TypeError, ValueError): + pass + skill_totals[skill] += ans.score or 0.0 skill_max[skill] += q.marks or 1.0 diff --git a/custom_addons/encoach_signup/controllers/auth.py b/custom_addons/encoach_signup/controllers/auth.py index 917cf0a..b3538f7 100644 --- a/custom_addons/encoach_signup/controllers/auth.py +++ b/custom_addons/encoach_signup/controllers/auth.py @@ -101,6 +101,24 @@ class EncoachSignupController(http.Controller): _logger.info('Registration OTP for %s: %s', email, otp_code) + try: + template = request.env.ref('encoach_signup.email_template_otp', raise_if_not_found=False) + if template: + template.sudo().with_context(otp_code=otp_code).send_mail(user.id, force_send=True) + else: + mail_values = { + 'subject': 'EnCoach - Verify Your Email', + 'email_from': request.env['ir.config_parameter'].sudo().get_param( + 'mail.catchall.email', 'noreply@encoach.com'), + 'email_to': email, + 'body_html': f'

Welcome to EnCoach!

' + f'

Your verification code is: {otp_code}

' + f'

This code expires in 15 minutes.

', + } + request.env['mail.mail'].sudo().create(mail_values).send() + except Exception as mail_err: + _logger.warning('OTP email failed for %s: %s', email, mail_err) + return _json_response({ 'message': 'Registration successful. Please verify your email.', 'user_id': user.id, @@ -205,12 +223,116 @@ class EncoachSignupController(http.Controller): _logger.info('Resend OTP for %s: %s', email, otp_code) + try: + user = request.env['res.users'].sudo().search( + [('login', '=', email)], limit=1) + if user: + mail_values = { + 'subject': 'EnCoach - Your New Verification Code', + 'email_from': request.env['ir.config_parameter'].sudo().get_param( + 'mail.catchall.email', 'noreply@encoach.com'), + 'email_to': email, + 'body_html': f'

Your new verification code is: {otp_code}

' + f'

This code expires in 15 minutes.

', + } + request.env['mail.mail'].sudo().create(mail_values).send() + except Exception as mail_err: + _logger.warning('Resend OTP email failed for %s: %s', email, mail_err) + return _json_response({'message': 'OTP resent'}) except Exception as e: _logger.exception('resend_otp failed') return _error_response(str(e), 500) + # ------------------------------------------------------------------ + # POST /api/reset/sendVerification + # ------------------------------------------------------------------ + @http.route('/api/reset/sendVerification', type='http', auth='public', + methods=['POST'], csrf=False) + def reset_send_verification(self, **kw): + try: + body = _get_json_body() + email = (body.get('email') or '').strip().lower() + if not email: + return _error_response('email is required', 400) + + user = request.env['res.users'].sudo().search( + [('login', '=', email)], limit=1) + if not user: + return _json_response({'message': 'If the email exists, a reset code has been sent.'}) + + otp_code = ''.join(random.choices(string.digits, k=6)) + otp_hash = hashlib.sha256(otp_code.encode()).hexdigest() + expires_at = fields.Datetime.now() + timedelta(minutes=15) + + request.env['encoach.otp'].sudo().create({ + 'email': email, + 'otp_hash': otp_hash, + 'expires_at': expires_at, + }) + + try: + template = request.env.ref('encoach_signup.email_template_password_reset', raise_if_not_found=False) + if template: + template.sudo().with_context(otp_code=otp_code).send_mail(user.id, force_send=True) + else: + mail_values = { + 'subject': 'EnCoach Password Reset Code', + 'email_from': request.env['ir.config_parameter'].sudo().get_param( + 'mail.catchall.email', 'noreply@encoach.com'), + 'email_to': email, + 'body_html': f'

Your password reset code is: {otp_code}

' + f'

This code expires in 15 minutes.

', + } + request.env['mail.mail'].sudo().create(mail_values).send() + except Exception as mail_err: + _logger.warning('Password reset email failed for %s: %s', email, mail_err) + + _logger.info('Password reset OTP for %s: %s', email, otp_code) + return _json_response({'message': 'If the email exists, a reset code has been sent.'}) + + except Exception as e: + _logger.exception('reset_send_verification failed') + return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/auth/invite/set-password + # ------------------------------------------------------------------ + @http.route('/api/auth/invite/set-password', type='http', auth='public', + methods=['POST'], csrf=False) + def invite_set_password(self, **kw): + try: + body = _get_json_body() + token = body.get('token', '').strip() + password = body.get('password', '') + + if not token or not password: + return _error_response('token and password are required', 400) + + Invite = request.env['encoach.invite'].sudo() + invite = Invite.search([('token', '=', token), ('used', '=', False)], limit=1) + + if not invite: + return _error_response('Invalid or expired invitation token', 400) + + user = invite.user_id + if not user: + return _error_response('No user associated with this invitation', 400) + + user.sudo().write({'password': password}) + invite.write({'used': True}) + user.sudo().write({ + 'account_status': 'activated', + 'first_login': False, + }) + + return _json_response({'message': 'Password set successfully'}) + + except Exception as e: + _logger.exception('invite_set_password failed') + return _error_response(str(e), 500) + # ------------------------------------------------------------------ # GET /api/onboarding/goals # ------------------------------------------------------------------ @@ -342,3 +464,32 @@ class EncoachSignupController(http.Controller): except Exception as e: _logger.exception('captcha_config failed') return _error_response(str(e), 500) + + # ------------------------------------------------------------------ + # POST /api/subscription/trial + # ------------------------------------------------------------------ + @http.route('/api/subscription/trial', type='http', auth='none', + methods=['POST'], csrf=False) + @jwt_required + def start_trial(self, **kw): + try: + user = request.env.user + ICP = request.env['ir.config_parameter'].sudo() + trial_days = int(ICP.get_param('encoach.trial_duration_days', '14')) + + from datetime import timedelta + trial_end = fields.Datetime.now() + timedelta(days=trial_days) + + user.sudo().write({ + 'account_status': 'trial', + }) + + return _json_response({ + 'message': f'Trial activated for {trial_days} days', + 'trial_end': str(trial_end), + 'status': 'trial', + }) + + except Exception as e: + _logger.exception('start_trial failed') + return _error_response(str(e), 500) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..828be3b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: '3.8' + +services: + db: + image: pgvector/pgvector:pg16 + environment: + POSTGRES_USER: odoo + POSTGRES_PASSWORD: odoo + POSTGRES_DB: encoach + volumes: + - pgdata:/var/lib/postgresql/data + ports: + - "5432:5432" + + odoo: + build: . + depends_on: + - db + ports: + - "8069:8069" + volumes: + - odoo-data:/var/lib/odoo + - ./custom_addons:/opt/odoo/custom_addons + environment: + - HOST=db + - USER=odoo + - PASSWORD=odoo + command: ["odoo", "--config=/etc/odoo/odoo.conf"] + +volumes: + pgdata: + odoo-data: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b970e6a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +PyJWT>=2.8.0 +openai>=1.12.0 +boto3>=1.34.0 +requests>=2.31.0 +qrcode>=7.4.2 +reportlab>=4.0.0 +Pillow>=10.0.0 +pgvector>=0.2.0 +psycopg2-binary>=2.9.0