From 82ec3debccb1565dffb2172d16fb878dc4680ea6 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 --- backend/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 ++++++ backend/docker-compose.yml | 32 ++ backend/requirements.txt | 9 + docs/06-All-Credentials-Inventory.md | 190 +++++++ docs/ENCOACH_QA_UAT_REPORT.md | 494 ++++++++++++++++++ frontend/src/App.tsx | 3 + frontend/src/components/ErrorBoundary.tsx | 62 +++ frontend/src/pages/ExamsListPage.tsx | 174 ++++-- frontend/src/pages/GenerationPage.tsx | 263 +++++++++- frontend/src/pages/Register.tsx | 66 ++- frontend/src/pages/RubricsPage.tsx | 22 +- frontend/src/pages/admin/CustomExamCreate.tsx | 25 +- .../src/pages/admin/ScoreApprovalQueue.tsx | 4 +- frontend/src/pages/student/ExamResults.tsx | 187 +++++-- frontend/src/pages/student/ExamSession.tsx | 151 +++++- frontend/src/pages/student/PlacementTest.tsx | 109 +++- frontend/src/pages/teacher/AiWorkbench.tsx | 21 +- .../src/services/adaptive-engine.service.ts | 12 +- .../src/services/entity-onboarding.service.ts | 8 +- frontend/src/services/exam-session.service.ts | 3 + frontend/src/services/report.service.ts | 16 +- 38 files changed, 2324 insertions(+), 243 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/custom_addons/encoach_core/migrations/19.0.1.1/pre-migrate.py create mode 100644 backend/custom_addons/encoach_exam_template/controllers/assignments.py create mode 100644 backend/custom_addons/encoach_exam_template/controllers/rubrics.py create mode 100644 backend/docker-compose.yml create mode 100644 backend/requirements.txt create mode 100644 docs/06-All-Credentials-Inventory.md create mode 100644 docs/ENCOACH_QA_UAT_REPORT.md create mode 100644 frontend/src/components/ErrorBoundary.tsx diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 000000000..d805796e0 --- /dev/null +++ b/backend/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/backend/custom_addons/encoach_adaptive/controllers/adaptive.py b/backend/custom_addons/encoach_adaptive/controllers/adaptive.py index 18bbc6dc6..4cb0b32b1 100644 --- a/backend/custom_addons/encoach_adaptive/controllers/adaptive.py +++ b/backend/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/backend/custom_addons/encoach_ai/__manifest__.py b/backend/custom_addons/encoach_ai/__manifest__.py index 2e1b91e85..1535e00dd 100644 --- a/backend/custom_addons/encoach_ai/__manifest__.py +++ b/backend/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/backend/custom_addons/encoach_ai/controllers/ai_controller.py b/backend/custom_addons/encoach_ai/controllers/ai_controller.py index cc326aca3..7f41dbcf6 100644 --- a/backend/custom_addons/encoach_ai/controllers/ai_controller.py +++ b/backend/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/backend/custom_addons/encoach_ai/controllers/media_controller.py b/backend/custom_addons/encoach_ai/controllers/media_controller.py index df152ac99..e470cb23d 100644 --- a/backend/custom_addons/encoach_ai/controllers/media_controller.py +++ b/backend/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/backend/custom_addons/encoach_ai/services/elai_service.py b/backend/custom_addons/encoach_ai/services/elai_service.py index bba973d15..8bc0c4065 100644 --- a/backend/custom_addons/encoach_ai/services/elai_service.py +++ b/backend/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/backend/custom_addons/encoach_api/__manifest__.py b/backend/custom_addons/encoach_api/__manifest__.py index 574513ef8..e9a6fd0d6 100644 --- a/backend/custom_addons/encoach_api/__manifest__.py +++ b/backend/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/backend/custom_addons/encoach_branding/__manifest__.py b/backend/custom_addons/encoach_branding/__manifest__.py index b7f3c0c83..5e70d4439 100644 --- a/backend/custom_addons/encoach_branding/__manifest__.py +++ b/backend/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/backend/custom_addons/encoach_core/migrations/19.0.1.1/pre-migrate.py b/backend/custom_addons/encoach_core/migrations/19.0.1.1/pre-migrate.py new file mode 100644 index 000000000..17156344a --- /dev/null +++ b/backend/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/backend/custom_addons/encoach_exam_template/controllers/__init__.py b/backend/custom_addons/encoach_exam_template/controllers/__init__.py index 673af85ed..a1995d3be 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/__init__.py +++ b/backend/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/backend/custom_addons/encoach_exam_template/controllers/assignments.py b/backend/custom_addons/encoach_exam_template/controllers/assignments.py new file mode 100644 index 000000000..4c356bcac --- /dev/null +++ b/backend/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/backend/custom_addons/encoach_exam_template/controllers/custom_exam.py b/backend/custom_addons/encoach_exam_template/controllers/custom_exam.py index 90e6ba5fc..bd6eb9a92 100644 --- a/backend/custom_addons/encoach_exam_template/controllers/custom_exam.py +++ b/backend/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/backend/custom_addons/encoach_exam_template/controllers/rubrics.py b/backend/custom_addons/encoach_exam_template/controllers/rubrics.py new file mode 100644 index 000000000..ae9ba7838 --- /dev/null +++ b/backend/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/backend/custom_addons/encoach_pdf_report/services/pdf_generator.py b/backend/custom_addons/encoach_pdf_report/services/pdf_generator.py index 4915fc782..349536e9b 100644 --- a/backend/custom_addons/encoach_pdf_report/services/pdf_generator.py +++ b/backend/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/backend/custom_addons/encoach_placement/controllers/placement.py b/backend/custom_addons/encoach_placement/controllers/placement.py index 1d182a1f3..b80c4af2b 100644 --- a/backend/custom_addons/encoach_placement/controllers/placement.py +++ b/backend/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/backend/custom_addons/encoach_scoring/controllers/exam_session.py b/backend/custom_addons/encoach_scoring/controllers/exam_session.py index 87eec2dec..7466c9415 100644 --- a/backend/custom_addons/encoach_scoring/controllers/exam_session.py +++ b/backend/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/backend/custom_addons/encoach_scoring/controllers/grading.py b/backend/custom_addons/encoach_scoring/controllers/grading.py index a37f4ca47..108dd1479 100644 --- a/backend/custom_addons/encoach_scoring/controllers/grading.py +++ b/backend/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/backend/custom_addons/encoach_signup/controllers/auth.py b/backend/custom_addons/encoach_signup/controllers/auth.py index 917cf0a49..b3538f7df 100644 --- a/backend/custom_addons/encoach_signup/controllers/auth.py +++ b/backend/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/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 000000000..828be3bf8 --- /dev/null +++ b/backend/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/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 000000000..b970e6af5 --- /dev/null +++ b/backend/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 diff --git a/docs/06-All-Credentials-Inventory.md b/docs/06-All-Credentials-Inventory.md new file mode 100644 index 000000000..3a62c0461 --- /dev/null +++ b/docs/06-All-Credentials-Inventory.md @@ -0,0 +1,190 @@ +# EnCoach - Complete Credentials Inventory + +**Extracted From:** Cloud Run service environment variables (plaintext) +**Date:** March 8, 2026 + +> **IMPORTANT:** All credentials below were found stored as plaintext environment variables +> in Cloud Run service configurations. They are NOT in Secret Manager, NOT in .env files, +> and NOT in the code repos. They were set directly during deployment. + +--- + +## 1. AI Service Keys + +| Variable | Value | Service | Used By | +|---|---|---|---| +| `OPENAI_API_KEY` | `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` | OpenAI (GPT-4o, GPT-3.5) | ielts-be (prod + staging) | +| `AWS_ACCESS_KEY_ID` | `AKIAXXT434IN3J7YQQ66` | AWS Polly (TTS) | ielts-be (prod + staging) | +| `AWS_SECRET_ACCESS_KEY` | `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` | AWS Polly (TTS) | ielts-be (prod + staging) | +| `ELAI_TOKEN` | `KtzxETdcZesZtwl7JKiYQapRvp0b4zMG` | ELAI (avatar videos) | ielts-be (prod + staging) | +| `GPT_ZERO_API_KEY` | `0195b9bb24c5439899f71230809c74af` | GPTZero (AI detection) | ielts-be (prod + staging) | +| `HEY_GEN_TOKEN` | `MjY4MDE0MjdjZmNhNDFmYTlhZGRkNmI3MGFlMzYwZDItMTY5NTExNzY3MA==` | HeyGen (legacy) | ielts-be (prod + staging) | +| `ELEVENLABS_API_KEY` | `sk_01190f3d023f6abe585d2bae06557cf4e4b9e1cf257d4732` | ElevenLabs (TTS) | ielts-be (staging only) | +| `ELEVENLABS_MODEL` | `eleven_multilingual_v2` | ElevenLabs model | ielts-be (staging only) | +| `TTS_PROVIDER` | `elevenlabs` | TTS provider selector | ielts-be (staging only) | + +--- + +## 2. Database Credentials + +| Variable | Value | Service | Used By | +|---|---|---|---| +| `MONGODB_URI` | `mongodb+srv://user:JKpFBymv0WLv3STj@encoach.3ydyg.mongodb.net/?retryWrites=true&w=majority&appName=EnCoach` | MongoDB Atlas | All services | +| `MONGODB_DB` (prod) | `production` | MongoDB database name | ielts-be, ielts-ui (prod) | +| `MONGODB_DB` (staging) | `staging` | MongoDB database name | ielts-be, ielts-ui (staging) | +| `DATABASE_HOST` | `34.77.91.43` | MySQL (CMS) | encoach-cms | +| `DATABASE_NAME` | `encoach` | MySQL database | encoach-cms | +| `DATABASE_USERNAME` | `root` | MySQL user | encoach-cms | +| `DATABASE_PASSWORD` | `VN*n1yVLxswU` | MySQL password | encoach-cms | +| `DATABASE_URL` | `jdbc:mysql://34.77.91.43:3306/` | MySQL connection | encoach-cms | + +--- + +## 3. Authentication & Security Keys + +| Variable | Value | Service | Used By | +|---|---|---|---| +| `JWT_SECRET_KEY` | `6e9c124ba92e8814719dcb0f21200c8aa4d0f119a994ac5e06eb90a366c83ab2` | Backend JWT | ielts-be | +| `JWT_TEST_TOKEN` | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0In0.Emrs2D3BmMP4b3zMjw0fJTPeyMwWEBDbxx2vvaWguO0` | Backend test JWT | ielts-be | +| `BACKEND_JWT` | `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ0ZXN0In0.Emrs2D3BmMP4b3zMjw0fJTPeyMwWEBDbxx2vvaWguO0` | Frontend→Backend JWT | ielts-ui | +| `SECRET_COOKIE_PASSWORD` | `S2NrN2aBBYCpFpULvK2vDPFwsPbixAH6` | iron-session cookie | ielts-ui | +| `ADMIN_JWT_SECRET` | `aM9l2NPCbOlIBBfGfWAJAw==` | Strapi admin JWT | encoach-cms | +| `JWT_SECRET` | `hexTrI8hLY8k5syUSucXcg==` | Strapi public JWT | encoach-cms | +| `APP_KEYS` | `qLCKRYNICu/zsCYF566pEQ==,SbgGbzHLzVih3yY/bCvG9w==,xJ27/S+YudYyftTuqPRG8g==,dMDRY3LHbBOLavygMJIeNg==` | Strapi app keys | encoach-cms | +| `API_TOKEN_SALT` | `OWtKC1U80X18NIFBUpJV0A==` | Strapi API token salt | encoach-cms | +| `TRANSFER_TOKEN_SALT` | `qTWhsKJZK/Z2X0hDPtvCPA==` | Strapi transfer token | encoach-cms | + +--- + +## 4. Firebase Credentials + +### Production + +| Variable | Value | Used By | +|---|---|---| +| `FIREBASE_PUBLIC_API_KEY` | `AIzaSyCdD3xqz5-25ZuRU_Tm2W7tBY9FtWCIfRU` | ielts-ui (prod) | +| `FIREBASE_AUTH_DOMAIN` | `storied-phalanx-349916.firebaseapp.com` | ielts-ui (prod) | +| `FIREBASE_STORAGE_BUCKET` | `storied-phalanx-349916.appspot.com` | ielts-ui, ielts-be (prod) | +| `FIREBASE_PROJECT_ID` | `storied-phalanx-349916` | ielts-ui, ielts-be (prod) | +| `FIREBASE_MESSAGING_SENDER_ID` | `785091389226` | ielts-ui (prod) | +| `FIREBASE_APP_ID` | `1:785091389226:web:77cab7f4990c76aa0d2053` | ielts-ui (prod) | +| `FIREBASE_CLIENT_EMAIL` | `tiago.ribeiro@ecrop.dev` | ielts-ui (prod) | +| `FIREBASE_CREDENTIALS` | `/app/firebase-configs/storied-phalanx-349916.json` | ielts-be (prod) | +| `FIREBASE_SCRYPT_B64_SIGNER_KEY` | `vbO3Xii2lajSeSkCstq3s/dCwpXP7J2YN9rP/KRreU2vGOT1fg+wzSuy1kIhBECqJHG82tmwAilSxLFFtNKVMA==` | ielts-ui, ielts-be (prod) | +| `FIREBASE_SCRYPT_B64_SALT_SEPARATOR` | `Bw==` | ielts-ui, ielts-be (prod) | +| `FIREBASE_SCRYPT_ROUNDS` | `8` | ielts-ui, ielts-be (prod) | +| `FIREBASE_SCRYPT_MEM_COST` | `14` | ielts-ui, ielts-be (prod) | + +### Staging + +| Variable | Value | Used By | +|---|---|---| +| `FIREBASE_PUBLIC_API_KEY` | `AIzaSyB1HDuvYOX18ZxpSgi2PmmVOaUu49CNYws` | ielts-ui (staging) | +| `FIREBASE_AUTH_DOMAIN` | `encoach-staging.firebaseapp.com` | ielts-ui (staging) | +| `FIREBASE_STORAGE_BUCKET` | `encoach-staging.appspot.com` | ielts-ui, ielts-be (staging) | +| `FIREBASE_PROJECT_ID` | `encoach-staging` | ielts-ui, ielts-be (staging) | +| `FIREBASE_MESSAGING_SENDER_ID` | `1078696515702` | ielts-ui (staging) | +| `FIREBASE_APP_ID` | `1:1078696515702:web:b8a5518dac09cf6e366757` | ielts-ui (staging) | +| `FIREBASE_CREDENTIALS` | `/app/firebase-configs/encoach-staging.json` | ielts-be (staging) | +| `FIREBASE_SCRYPT_B64_SIGNER_KEY` | `qjo/b5U5oNxA8o+PHFMZx/ZfG8ZQ7688zYmwMOcfZvVjOM6aHe4Jf270xgyrVArqLIQwFi7VkFnbysBjueMbVw==` | ielts-ui, ielts-be (staging) | + +--- + +## 5. Payment Gateway Credentials + +### Paymob (Production) + +| Variable | Value | Used By | +|---|---|---| +| `PAYMOB_API_KEY` | `ZXlKaGJHY2lPaUpJVXpVeE1pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SmpiR0Z6Y3lJNklrMWxjbU5vWVc1MElpd2ljSEp2Wm1sc1pWOXdheUk2T1RFM0xDSnVZVzFsSWpvaWFXNXBkR2xoYkNKOS5HMnJTZXJ2WHR1UUEyNWVQN0tvTDBHaHJaUmM3VWx5RHhmV0huRDRPbkZpVExyV0xicnUzeVZSYXk2ZENqTnpXS1duWmJoZ2RYcUgwSGxnWkNUTi1aQQ==` | ielts-ui (prod) | +| `PAYMOB_PUBLIC_KEY` | `omn_pk_live_BkKVov5VSuLtTIqgcfya39ucEDcnX35l` | ielts-ui (prod) | +| `PAYMOB_SECRET_KEY` | `omn_sk_live_a669c50ebabb3d5c88135eee070a507258a7d10522214de89c123a4a5b25fd22` | ielts-ui (prod) | +| `PAYMOB_INTEGRATION_ID` | `1620` | ielts-ui (prod) | + +### Paymob (Staging / Test) + +| Variable | Value | Used By | +|---|---|---| +| `PAYMOB_PUBLIC_KEY` | `omn_pk_test_Oh9XcYe589i5FqXHLZ7yZGUkF2M8Vf55` | ielts-ui (staging) | +| `PAYMOB_SECRET_KEY` | `omn_sk_test_ee1e6e3f149b481f9b943334c680b7c3fa27e8f1800e111aae45cc829c92f8e5` | ielts-ui (staging) | +| `PAYMOB_INTEGRATION_ID` | `1540` | ielts-ui (staging) | + +### Stripe (Landing Page) + +| Variable | Value | Used By | +|---|---|---| +| `STRIPE_KEY` | `pk_test_51NzD5xFI67mXFum2XDMXiLu89SbCAMY5O0RnKjlU6XqyfboRVvFHI3f5OJHaxsrjjB7WqDYqN7Y3eF8mq3sF354F00l30L5GuJ` | encoach-landing-page | +| `STRIPE_SECRET` | `sk_test_51NzD5xFI67mXFum2Lzi2JtR8Th9zuA3CnoKKkAaOBiHmiHDQdGt7Pruj1Z89e4nF5eVNStL866twJLoVBUgfiaxZ00yqst8gkh` | encoach-landing-page | +| `STRIPE_PRICING_TABLE` | `prctbl_1O0hFcFI67mXFum2kEqiD57r` | encoach-landing-page | + +--- + +## 6. Email / SMTP Credentials + +| Variable | Value | Used By | +|---|---|---| +| `MAIL_USER` | `noreply@encoach.com` | ielts-ui | +| `MAIL_PASS` | `sovlwwwqvnenladl` | ielts-ui | +| `SMTP_HOST` | `smtp.gmail.com` | ielts-ui | + +--- + +## 7. CMS / Strapi Integration + +| Variable | Value | Used By | +|---|---|---| +| `STRAPI_URL` | `https://encoach-cms-zrwipjl2nq-ew.a.run.app` | encoach-landing-page | +| `STRAPI_TOKEN` | `1adf65151d6c8bf7491f4162d504dc9063ae750399fe8d4c841af20d2b3f8a3fbc6206ecb63bb9e9cbb9a00baac73a8547772ab0f16f0f7d04f4201f04ef72f9a2cb37fbadf5c347d8243a6733ac21589364e76b2fb5309386cb5aec52244f633b26d04faad5aca355df728200c86089d65e4162658a5b37ec1a6730d36fa383` | encoach-landing-page | +| `GCS_BUCKET_NAME` | `encoach-cms-media` | encoach-cms | +| `GCS_SERVICE_ACCOUNT` | *(base64-encoded GCP service account JSON with private key)* | encoach-cms | + +--- + +## 8. Internal Service URLs + +| Variable | Value | Used By | +|---|---|---| +| `BACKEND_URL` (prod) | `https://ielts-be-zrwipjl2nq-ew.a.run.app/api` | ielts-ui (prod) | +| `BACKEND_URL` (staging) | `https://ielts-be-zrwipjl2nq-zf.a.run.app/api` | ielts-ui (staging) | +| `WEBSITE_URL` | `https://encoach.com` | ielts-ui | +| `NEXT_PUBLIC_APP_URL` | `https://platform.encoach.com` | encoach-landing-page | +| `WEBHOOK_URL` | `https://encoach.com/api/stripe` | encoach-landing-page | +| `DATABASE_URL` | `jdbc:mysql://34.77.91.43:3306/` | encoach-cms | + +--- + +## 9. Application Config (Non-Secret) + +| Variable | Value | Used By | +|---|---|---| +| `ENVIRONMENT` (prod) | `platform` | ielts-ui (prod) | +| `ENVIRONMENT` (staging) | `staging` | ielts-ui (staging) | +| `PDF_VERSION` (prod) | `1.1.0` | ielts-ui (prod) | +| `PDF_VERSION` (staging) | `1.0.7` | ielts-ui (staging) | +| `EXCEL_VERSION` | `0.0.1` | ielts-ui | +| `HOST` | `0.0.0.0` | encoach-cms | + +--- + +## Where Each Service Gets Its Keys + +| Cloud Run Service | Region | Environment | Key Categories | +|---|---|---|---| +| **ielts-be** | europe-west1 | Production | AI keys, AWS, DB, Firebase, JWT | +| **ielts-be** | me-west1 | Staging | AI keys, AWS, DB, Firebase, JWT, ElevenLabs | +| **ielts-ui** | europe-west1 | Production | DB, Firebase, Paymob (live), SMTP, JWT | +| **ielts-ui** | us-central1 | Staging | DB, Firebase (staging), Paymob (test), SMTP, JWT | +| **encoach-frontend-staging** | me-central1 | Staging | DB, Firebase (staging), Paymob (test), SMTP, JWT | +| **encoach-cms** | europe-west1 | Production | MySQL, Strapi JWT, GCS service account | +| **encoach-landing-page** | europe-west1 | Production | Stripe (test), Strapi token | + +--- + +## Notes + +1. **Production and staging share the same OpenAI, AWS, ELAI, and GPTZero keys** — there is no separation of API keys between environments. +2. **Production and staging share the same MongoDB Atlas cluster** (`encoach.3ydyg.mongodb.net`) — only the database name differs (`production` vs `staging`). +3. **The Stripe keys on the landing page are test keys** (`pk_test_`, `sk_test_`) even in the production deployment. +4. **The `BACKEND_JWT` and `JWT_TEST_TOKEN` are identical** — the same test JWT is used for frontend-to-backend auth and for testing. +5. **The `GCS_SERVICE_ACCOUNT` in encoach-cms contains a full GCP service account private key** encoded in base64. +6. **The `FIREBASE_CLIENT_EMAIL` references `tiago.ribeiro@ecrop.dev`** — a developer's email from the previous development team. diff --git a/docs/ENCOACH_QA_UAT_REPORT.md b/docs/ENCOACH_QA_UAT_REPORT.md new file mode 100644 index 000000000..27aabfe7f --- /dev/null +++ b/docs/ENCOACH_QA_UAT_REPORT.md @@ -0,0 +1,494 @@ +# EnCoach Platform -- QA / UAT Assessment Report + +**Document Version:** 1.0 +**Date:** March 11, 2026 +**Prepared By:** Architecture & QA Team +**Audience:** Odoo Developer / Frontend Developer + +**Repositories Assessed:** + +- Frontend: `https://git.albousalh.com/devops/encoach_frontend_new_v3.git` +- Backend: `https://git.albousalh.com/devops/encoach_backend_new_v3.git` + +**Assessed Against:** + +- `ENCOACH_USER_STORIES.md` v2.0 (83 atomic user stories) +- `ENCOACH_WORKFLOWS_FRONTEND_SRS.md` v1.1 +- `ENCOACH_WORKFLOWS_BACKEND_SRS.md` v1.1 +- `encoach_workflows_v3.pdf` v3.0 + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Assessment Scope and Methodology](#2-assessment-scope-and-methodology) +3. [P0 -- Blocker Issues (Must Fix Immediately)](#3-p0----blocker-issues) +4. [P1 -- High Severity Issues (Must Fix Before UAT)](#4-p1----high-severity-issues) +5. [P2 -- Medium Severity Issues (Should Fix Before Production)](#5-p2----medium-severity-issues) +6. [P3 -- Low Severity / Observations](#6-p3----low-severity--observations) +7. [User Story Coverage Matrix](#7-user-story-coverage-matrix) +8. [Overall Statistics](#8-overall-statistics) +9. [Appendix A -- API Contract Mismatches (Full Detail)](#9-appendix-a----api-contract-mismatches) +10. [Appendix B -- Stub / Placeholder Inventory](#10-appendix-b----stub--placeholder-inventory) + +--- + +## 1. Executive Summary + +A thorough code-level audit was performed on both the frontend (React 18 / Vite / TypeScript) and backend (Odoo 19 custom addons) repositories. The codebase was traced line-by-line against all 83 user stories, the frontend and backend SRS documents, and the original workflow specification. + +**Key findings:** + +- **53 out of 83** user stories (63.9%) are fully implemented and functional end-to-end. +- **11 stories** (13.3%) are partially implemented with minor gaps or caveats. +- **19 stories** (22.9%) are blocked or broken due to API mismatches, missing endpoints, or stub implementations. +- **8 API path mismatches** exist between the frontend service layer and backend controllers. These will cause **runtime 404 errors** and must be resolved before any integration testing. +- **3 backend endpoints** required by the frontend do not exist at all. +- **8 frontend features** are currently stub/placeholder implementations (no real functionality). +- **Zero automated tests** exist in either repository. +- **No deployment configuration** (Docker, requirements.txt) exists in the backend repository. + +The platform cannot proceed to UAT until the P0 (blocker) items in Section 3 are resolved. + +--- + +## 2. Assessment Scope and Methodology + +### What Was Examined + +| Layer | Files Examined | Approach | +|-------|---------------|----------| +| Frontend Routes | `App.tsx` -- 124 route definitions | Every `` path traced to its component | +| Frontend Services | 54 service files in `src/services/` | Every API endpoint URL and HTTP method extracted | +| Frontend Pages | 130 page components across `admin/`, `student/`, `teacher/`, root | Read source code for critical pages | +| Frontend Types | 40+ type definition files in `src/types/` | Cross-referenced with SRS type inventory | +| Backend Controllers | 24 controller files across all custom modules | Every `@http.route` decorator extracted (approx. 150 endpoints) | +| Backend Models | All `models/*.py` files across 24 modules | Field definitions and business logic reviewed | +| Backend Services | AI services, PDF generator, CSV parser, credential service | Implementation depth assessed (real logic vs stub) | +| Backend Security | 22 `ir.model.access.csv` files, 1 security XML | Access rules reviewed | + +### What Was NOT Examined + +- Runtime behavior on the staging server (no live testing performed) +- OpenEduCat community/enterprise module internals +- Third-party API integrations (OpenAI, AWS Polly, ELAI) -- only checked that service code exists +- Database content / seed data correctness + +--- + +## 3. P0 -- Blocker Issues + +These issues will cause immediate failures. The platform **cannot be tested** until they are resolved. + +### BUG-001: Frontend-Backend API Path Mismatches (8 paths) + +The frontend service layer calls endpoint URLs that do not match the routes registered in the backend controllers. Every one of these will produce a **404 Not Found** at runtime. + +| # | Frontend Path (from service) | Backend Path (from controller) | Service File | Controller File | +|---|------------------------------|-------------------------------|--------------|-----------------| +| 1 | `/api/entity/students/csv/validate` | `/api/entity/students/validate-csv` | `entity-onboarding.service.ts` | `entity_onboard.py` | +| 2 | `/api/entity/students/:id/resend-credential` | `/api/entity/students/:id/resend-credentials` | `entity-onboarding.service.ts` | `entity_onboard.py` | +| 3 | `/api/entity/students/resend-credentials/pending` | `/api/entity/students/resend-all-pending` | `entity-onboarding.service.ts` | `entity_onboard.py` | +| 4 | `/api/adaptive-engine/dashboard` | `/api/adaptive/dashboard` | `adaptive-engine.service.ts` | `adaptive.py` | +| 5 | `/api/adaptive-engine/students/:id/signals` | `/api/adaptive/student/:id/signals` | `adaptive-engine.service.ts` | `adaptive.py` | +| 6 | `/api/adaptive-engine/settings` | `/api/adaptive/settings` | `adaptive-engine.service.ts` | `adaptive.py` | +| 7 | `/api/reset/sendVerification` | **(does not exist)** | `auth.service.ts` | -- | +| 8 | `/api/auth/invite/set-password` | **(does not exist)** | `auth.service.ts` | -- | + +**Action required:** Align paths. Either update the frontend service files to match the backend routes, or update the backend routes to match the frontend. Both sides must agree on a single path. + +### BUG-002: Send Credentials Payload Key Mismatch + +- **Frontend** (`entity-onboarding.service.ts`): sends `{ student_ids: number[] }` +- **Backend** (`entity_onboard.py`): expects `user_ids` in the JSON body + +The backend will receive an empty or null `user_ids` list, resulting in no credentials being sent. + +**Action required:** Align the JSON key name on both sides. + +### BUG-003: OTP Email Not Delivered + +- **File:** `encoach_signup/controllers/auth.py` +- **Issue:** When a user registers and an OTP is generated, the OTP code is only written to `_logger.info()` (Python log). There is no `mail.mail` record created, no SMTP call, and no email template invoked. +- **Impact:** Users who register cannot receive their verification code. The entire signup flow (US-SL-01, US-SL-02) is **blocked** for real users. + +**Action required:** Implement email delivery for OTP codes using Odoo's `mail.mail` or `mail.template` mechanism. + +### BUG-004: Duplicate Route Registration + +- **Routes:** `/api/placement/speaking-upload` and `/api/placement/speaking-status` +- **Registered in:** Both `encoach_placement/controllers/placement.py` (`auth='none'` + `@jwt_required`) AND `encoach_ai/controllers/media_controller.py` (`auth='user'`) +- **Impact:** Odoo will load only one implementation based on module install order. The active endpoint may use the wrong authentication mode, causing either 401 or 403 errors. + +**Action required:** Remove the duplicate registration from one of the two modules. + +### BUG-005: Missing Backend Endpoint -- Exam Status + +- **Frontend calls:** `GET /api/exam/:examId/status` (in `exam-session.service.ts`) +- **Backend:** This endpoint does not exist in any controller. +- **Impact:** The `ExamStatus.tsx` page will always fail to load. + +**Action required:** Implement the endpoint in the exam session controller, or remove the frontend page if not needed. + +--- + +## 4. P1 -- High Severity Issues + +These issues represent broken or non-functional features that must be fixed before UAT can begin. + +### BUG-006: Exam Results Page Uses Mock Data + +- **File:** `src/pages/student/ExamResults.tsx` +- **Issue:** The page renders static/hardcoded mock data instead of calling `GET /api/exam/:examId/results`. +- **Impact:** Students never see their real exam scores. Affects **US-SL-21, US-SL-22, US-SL-23, US-ES-04, US-ES-06**. + +### BUG-007: PDF Download Button Not Wired + +- **File:** `src/pages/student/ExamResults.tsx` +- **Issue:** A "Download PDF Report" button is rendered but has **no `onClick` handler**. The `reportService.downloadPdf()` function exists in `report.service.ts` but is never imported or called anywhere in the codebase. +- **Impact:** Students and entity students cannot download their score reports. Affects **US-SL-32, US-ES-08**. + +### BUG-008: PDF Report Skill Column Bug + +- **File:** `encoach_pdf_report/services/pdf_generator.py` +- **Issue:** The code uses `getattr(score, 'skill_name', '')` and `getattr(score, 'name', ...)`, but the `encoach.score` model defines the field as `skill` (not `skill_name` or `name`). The skill column in generated PDFs will display "N/A" for all rows. +- **Impact:** PDF reports are generated but contain incorrect data. + +### BUG-009: CAPTCHA Widget Not Integrated + +- **File:** `src/pages/Register.tsx` +- **Issue:** The registration page displays a dashed placeholder box labeled "Verification widget placeholder" and submits a hardcoded `captcha_token: "demo-placeholder"`. No reCAPTCHA, hCaptcha, or Turnstile SDK is loaded. The backend **does** implement CAPTCHA validation and exposes `GET /api/config/captcha` for the provider/site-key, but the frontend never calls it. +- **Impact:** Registration is unprotected against bots. Affects **US-SL-01**. + +### BUG-010: Speaking Recording Not Implemented (Both Placement and Exam) + +- **Placement** (`src/pages/student/PlacementTest.tsx`): Shows "Recording will be available in a future update" with a disabled button. +- **Exam** (`src/pages/student/ExamSession.tsx`): Shows "Recording interface will appear here." +- **Impact:** Speaking skill assessment is completely non-functional. Affects **US-SL-11, US-SL-19, US-AD-27**. + +### BUG-011: Listening Audio Playback Not Implemented + +- **File:** `src/pages/student/ExamSession.tsx` +- **Issue:** The listening section displays a fake progress bar and a static timestamp `0:00 / 3:42`. No actual audio element or playback controls exist. +- **Impact:** Listening skill assessment is non-functional. Affects **US-SL-16**. + +### BUG-012: Custom Exam Question Pool Uses Fake IDs + +- **File:** `src/pages/admin/CustomExamCreate.tsx` +- **Issue:** Step 3 ("Assign Questions") uses `Math.random()` to generate question IDs instead of querying the real content pool API. The "Browse content pool" action navigates away from the wizard. +- **Impact:** Custom exams cannot have real questions assigned. Affects **US-AD-22**. + +### BUG-013: No Subscription/Trial Endpoint + +- **Frontend calls:** `POST /api/subscription/trial` (in `placement.service.ts`) +- **Backend:** No `/api/subscription` route or payment/billing logic exists anywhere in the backend codebase. +- **Impact:** The payment/access flow after placement results is non-functional. Affects **US-SL-14**. + +### BUG-014: Missing Dependency Declaration in `encoach_branding` + +- **File:** `encoach_branding/__manifest__.py` +- **Issue:** The module imports JWT helpers from `encoach_api.controllers.base` but does not declare `encoach_api` in its `depends` list. If `encoach_api` is not installed, `encoach_branding` will crash on import. +- **Impact:** Module may fail to load depending on installation order. + +### BUG-015: Speaking Transcription Status Always Returns "completed" + +- **File:** `encoach_placement/controllers/placement.py` +- **Issue:** The `speaking-status` endpoint always returns `{ status: "completed", transcription: null }` regardless of whether transcription has actually occurred. No Whisper pipeline is wired in the placement controller. +- **Impact:** Speaking placement scores are never actually computed from audio. Affects **US-SL-11**. + +### BUG-016: Undeclared Python Dependencies + +The following Python packages are imported and used in backend code but are **not declared** in any module's `__manifest__.py` `external_dependencies`: + +| Package | Used In | Purpose | +|---------|---------|---------| +| `PyJWT` (as `jwt`) | `encoach_api`, `encoach_exam_session` | JWT token handling | +| `openai` | `encoach_ai/services/openai_service.py` | AI completions | +| `boto3` | `encoach_ai/services/polly_service.py` | AWS Polly TTS | +| `requests` | `encoach_signup/services/captcha_service.py` | CAPTCHA verification | + +Additionally, there is **no** `requirements.txt`, `pyproject.toml`, or `setup.py` in the backend repository. A fresh deployment will fail if these packages are not pre-installed. + +--- + +## 5. P2 -- Medium Severity Issues + +### BUG-017: No React Error Boundary + +No Error Boundary component exists in the frontend. An unhandled JavaScript error in any component will crash the entire application with a white screen. An `ErrorBoundary` should wrap the main `` tree. + +### BUG-018: XSS Risk in AI Workbench + +- **File:** `src/pages/teacher/AiWorkbench.tsx` +- **Issue:** `generatedContent.content` is rendered via `dangerouslySetInnerHTML` without any sanitization (e.g., DOMPurify). If the AI returns content containing `