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
This commit is contained in:
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
@@ -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
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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/<int:assignment_id>', 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)
|
||||
@@ -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
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
@@ -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])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -369,6 +369,41 @@ class EncoachExamSessionController(http.Controller):
|
||||
_logger.exception('submit failed')
|
||||
return _error_response(str(e), 500)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /api/exam/<int:exam_id>/status
|
||||
# ------------------------------------------------------------------
|
||||
@http.route('/api/exam/<int:exam_id>/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/<int:exam_id>/results
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'<p>Welcome to EnCoach!</p>'
|
||||
f'<p>Your verification code is: <strong>{otp_code}</strong></p>'
|
||||
f'<p>This code expires in 15 minutes.</p>',
|
||||
}
|
||||
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'<p>Your new verification code is: <strong>{otp_code}</strong></p>'
|
||||
f'<p>This code expires in 15 minutes.</p>',
|
||||
}
|
||||
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'<p>Your password reset code is: <strong>{otp_code}</strong></p>'
|
||||
f'<p>This code expires in 15 minutes.</p>',
|
||||
}
|
||||
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)
|
||||
|
||||
32
backend/docker-compose.yml
Normal file
32
backend/docker-compose.yml
Normal file
@@ -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:
|
||||
9
backend/requirements.txt
Normal file
9
backend/requirements.txt
Normal file
@@ -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
|
||||
190
docs/06-All-Credentials-Inventory.md
Normal file
190
docs/06-All-Credentials-Inventory.md
Normal file
@@ -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` | `gbvQmo92zUFhDXVMHForRuWfphrwHvVtEQqRpLaS` | 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.
|
||||
494
docs/ENCOACH_QA_UAT_REPORT.md
Normal file
494
docs/ENCOACH_QA_UAT_REPORT.md
Normal file
@@ -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 `<Route>` 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 `<App>` 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 `<script>` tags or event handlers, it will execute in the user's browser.
|
||||
- **Recommendation:** Use a sanitization library such as `dompurify` before rendering.
|
||||
|
||||
### BUG-019: Inconsistent Error Handling Across Pages
|
||||
|
||||
Some pages properly handle API error states (e.g., `ExamSession.tsx`, `ScoreVerification.tsx`), while others destructure `isError` from React Query hooks but never display an error message to the user (e.g., `IeltsSkillConfig.tsx`). Users may see empty or broken pages without understanding why.
|
||||
|
||||
### BUG-020: Score Approval "View Details" Button Not Wired
|
||||
|
||||
- **File:** `src/pages/admin/ScoreApprovalQueue.tsx`
|
||||
- **Issue:** The "View Details" button has no `onClick` handler. Approve and Reject work correctly.
|
||||
|
||||
### BUG-021: Imperative DOM Access in GenerationPage
|
||||
|
||||
- **File:** `src/pages/GenerationPage.tsx`
|
||||
- **Issue:** Uses `document.getElementById()` to read input values instead of React controlled components or refs. This is fragile and can break during concurrent rendering.
|
||||
|
||||
### BUG-022: IRT Theta Estimation is Simplified
|
||||
|
||||
- **File:** `encoach_placement/controllers/placement.py`
|
||||
- **Issue:** The SRS specifies MLE (Maximum Likelihood Estimation) for theta updates. The implementation uses a simplified learning-rate heuristic: `theta += LEARNING_RATE * (correct - p)` with clipping. This is functional but less accurate than proper IRT estimation, especially at boundary conditions.
|
||||
|
||||
### BUG-023: Adaptive Dashboard `avg_progress` Hardcoded
|
||||
|
||||
- **File:** `encoach_adaptive/controllers/adaptive.py`
|
||||
- **Issue:** The `avg_progress` field in the dashboard response is hardcoded to `0.0` instead of being computed from actual student data.
|
||||
|
||||
### BUG-024: Rubric Sub-Scores Not Used in Band Calculation
|
||||
|
||||
- **File:** `encoach_scoring/controllers/grading.py`
|
||||
- **Issue:** The `_recompute_bands` function averages `ans.score` per skill. Rubric sub-scores (stored in `encoach.feedback.rubric_scores` as JSON) are not factored into the band calculation. The SRS specifies rubric-based band derivation.
|
||||
|
||||
### BUG-025: No Deployment Configuration in Backend Repo
|
||||
|
||||
The backend repository contains no `docker-compose.yml`, `Dockerfile`, `odoo.conf`, or deployment scripts. This makes reproducible deployments difficult.
|
||||
|
||||
### BUG-026: No Migration Scripts
|
||||
|
||||
No `migrations/` folders exist in any backend module. Database schema changes during upgrades will need manual intervention. For production stability, Odoo migration scripts should be created for any model changes between versions.
|
||||
|
||||
---
|
||||
|
||||
## 6. P3 -- Low Severity / Observations
|
||||
|
||||
### OBS-001: No Automated Tests
|
||||
|
||||
- **Frontend:** Only `src/test/example.test.ts` exists (a boilerplate placeholder). Vitest is configured but no actual tests cover any component, service, or hook.
|
||||
- **Backend:** No `tests/` packages, no `TransactionCase`, `HttpCase`, or any test classes exist in any module.
|
||||
|
||||
### OBS-002: No Internationalization (i18n)
|
||||
|
||||
The frontend has no i18n framework installed. All UI strings are hardcoded in English. If Arabic or other language support is required for UTAS, this will need to be addressed.
|
||||
|
||||
### OBS-003: Two Separate Adaptive Services in Frontend
|
||||
|
||||
The frontend has both `adaptive.service.ts` (diagnostics, proficiency, learning plans, topics) and `adaptive-engine.service.ts` (dashboard, students, signals, settings). This split is intentional but may cause confusion. The engine service has the path mismatch documented in BUG-001.
|
||||
|
||||
### OBS-004: Token Expiry Handling
|
||||
|
||||
The frontend does not decode JWTs or check `exp` client-side. Expired tokens are only detected when an API call returns 401, at which point the token is cleared and the user is redirected to login. This is functional but means users may see a brief error before being redirected.
|
||||
|
||||
### OBS-005: `report.service.ts` Uses Direct `fetch`
|
||||
|
||||
Unlike all other services that use the centralized `api` client from `api-client.ts`, `report.service.ts` calls `fetch()` directly with a hardcoded `/api` prefix. This bypasses any future interceptor or middleware logic added to the API client.
|
||||
|
||||
### OBS-006: No `.env.production` Template
|
||||
|
||||
Only `.env.example` and `.env.development` exist. A `.env.production` template documenting required production environment variables would help deployment.
|
||||
|
||||
---
|
||||
|
||||
## 7. User Story Coverage Matrix
|
||||
|
||||
### Section 1: Self-Learning Student Journey (US-SL-01 to US-SL-32)
|
||||
|
||||
| ID | Title | Verdict | Blocking Bug |
|
||||
|----|-------|---------|--------------|
|
||||
| US-SL-01 | Register a New Account | PARTIAL | BUG-009 (CAPTCHA stub) |
|
||||
| US-SL-02 | Verify Email Address | BLOCKED | BUG-003 (OTP not emailed) |
|
||||
| US-SL-03 | Select Learning Goal | PASS | -- |
|
||||
| US-SL-04 | Set Target Level and Timeline | PASS | -- |
|
||||
| US-SL-05 | Set Study Preferences | PASS | -- |
|
||||
| US-SL-06 | Choose Placement Test Decision | PASS | -- |
|
||||
| US-SL-07 | View Placement Test Briefing | PASS | -- |
|
||||
| US-SL-08 | Take Placement -- Grammar | PASS | -- |
|
||||
| US-SL-09 | Take Placement -- Vocabulary | PASS | -- |
|
||||
| US-SL-10 | Take Placement -- Reading | PASS | -- |
|
||||
| US-SL-11 | Take Placement -- Speaking | FAIL | BUG-010, BUG-015 |
|
||||
| US-SL-12 | View Placement Results | PASS | -- |
|
||||
| US-SL-13 | View Learning Path Preview | PASS | -- |
|
||||
| US-SL-14 | Choose Payment or Access Option | FAIL | BUG-013 (no subscription endpoint) |
|
||||
| US-SL-15 | Start Exam Session | PASS | -- |
|
||||
| US-SL-16 | Answer Listening Questions | FAIL | BUG-011 (audio stub) |
|
||||
| US-SL-17 | Answer Reading Questions | PASS | -- |
|
||||
| US-SL-18 | Complete Writing Tasks | PASS | -- |
|
||||
| US-SL-19 | Record Speaking Responses | FAIL | BUG-010 (recording stub) |
|
||||
| US-SL-20 | Review and Submit Exam | PASS | -- |
|
||||
| US-SL-21 | View Exam Results (Auto) | FAIL | BUG-006 (mock data) |
|
||||
| US-SL-22 | Post-Exam Routing (Pass) | PARTIAL | Depends on BUG-006 |
|
||||
| US-SL-23 | Post-Exam Routing (Below) | PARTIAL | Depends on BUG-006 |
|
||||
| US-SL-24 | View Gap Analysis | PASS | -- |
|
||||
| US-SL-25 | Start Auto-Generated Course | PASS | -- |
|
||||
| US-SL-26 | Start AI English Course | PASS | -- |
|
||||
| US-SL-27 | Start AI IELTS Course | PASS | -- |
|
||||
| US-SL-28 | Progress Through Modules | PASS | -- |
|
||||
| US-SL-29 | View In-Platform Resources | PASS | -- |
|
||||
| US-SL-30 | Complete Checkpoint Exercises | PASS | -- |
|
||||
| US-SL-31 | Take Post-Course Assessment | PASS | -- |
|
||||
| US-SL-32 | Download PDF Report with QR | FAIL | BUG-007, BUG-008 |
|
||||
|
||||
**Result: 21 PASS / 5 FAIL / 3 PARTIAL / 3 BLOCKED-by-dependency = 65.6% pass rate**
|
||||
|
||||
### Section 2: Entity Student Journey (US-ES-01 to US-ES-08)
|
||||
|
||||
| ID | Title | Verdict | Blocking Bug |
|
||||
|----|-------|---------|--------------|
|
||||
| US-ES-01 | Receive Credentials and First Login | PARTIAL | BUG-001 (#7, #8) |
|
||||
| US-ES-02 | Set New Password on First Login | FAIL | BUG-001 (#8, invite set-password missing) |
|
||||
| US-ES-03 | Take Mandatory Placement Test | PASS | -- |
|
||||
| US-ES-04 | View Placement Results (Pending) | PARTIAL | BUG-006 |
|
||||
| US-ES-05 | Take Exam Assigned by Institution | PASS | -- |
|
||||
| US-ES-06 | View Exam Results (After Approval) | FAIL | BUG-006 |
|
||||
| US-ES-07 | Take Teacher-Configured Course | PASS | -- |
|
||||
| US-ES-08 | Download Entity-Branded PDF | FAIL | BUG-007, BUG-008 |
|
||||
|
||||
**Result: 3 PASS / 3 FAIL / 2 PARTIAL = 37.5% pass rate**
|
||||
|
||||
### Section 3: Admin / Teacher Journey (US-AD-01 to US-AD-42) + Public (US-PV-01)
|
||||
|
||||
| ID | Title | Verdict | Blocking Bug |
|
||||
|----|-------|---------|--------------|
|
||||
| US-AD-01 | Upload Student CSV | FAIL | BUG-001 (#1, path mismatch) |
|
||||
| US-AD-02 | Review CSV Validation Report | FAIL | Depends on US-AD-01 |
|
||||
| US-AD-03 | Create Bulk Student Accounts | PASS | -- |
|
||||
| US-AD-04 | Send Credential Emails | FAIL | BUG-002 (payload mismatch) |
|
||||
| US-AD-05 | Monitor Credential Delivery | FAIL | BUG-001 (#2, #3, path mismatches) |
|
||||
| US-AD-06 | Configure Level Mapping | PASS | -- |
|
||||
| US-AD-07 | Configure White-Label Branding | PASS | -- |
|
||||
| US-AD-08 | Select Exam Template Path | PASS | -- |
|
||||
| US-AD-09 | Initialise IELTS Exam | PASS | -- |
|
||||
| US-AD-10 | Configure Listening Skill | PASS | -- |
|
||||
| US-AD-11 | Configure Reading Skill | PASS | -- |
|
||||
| US-AD-12 | Configure Writing Skill | PASS | -- |
|
||||
| US-AD-13 | Configure Speaking Skill | PASS | -- |
|
||||
| US-AD-14 | Auto-Assemble Questions | PASS | -- |
|
||||
| US-AD-15 | Manually Assemble Questions | PASS | -- |
|
||||
| US-AD-16 | Hybrid-Assemble Questions | PASS | -- |
|
||||
| US-AD-17 | Validate Exam | PASS | -- |
|
||||
| US-AD-18 | Publish Exam | PASS | -- |
|
||||
| US-AD-19 | Assign Exam to Students | PASS | -- |
|
||||
| US-AD-20 | Custom Exam -- Properties | PASS | -- |
|
||||
| US-AD-21 | Custom Exam -- Sections | PASS | -- |
|
||||
| US-AD-22 | Custom Exam -- Questions | PARTIAL | BUG-012 (fake question IDs) |
|
||||
| US-AD-23 | Custom Exam -- Validate/Publish | PASS | -- |
|
||||
| US-AD-24 | Save as Reusable Template | PASS | -- |
|
||||
| US-AD-25 | Create from Saved Template | PASS | -- |
|
||||
| US-AD-26 | Grade Writing Submission | PASS | Minor: BUG-024 |
|
||||
| US-AD-27 | Grade Speaking Submission | PARTIAL | BUG-010 (audio stub) |
|
||||
| US-AD-28 | View Student Gap Analysis | PASS | -- |
|
||||
| US-AD-29 | Configure Course Structure | PASS | -- |
|
||||
| US-AD-30 | Build Course Modules | PASS | -- |
|
||||
| US-AD-31 | Attach Resources to Modules | PASS | -- |
|
||||
| US-AD-32 | Generate AI Content | PASS | -- |
|
||||
| US-AD-33 | Publish and Assign Course | PASS | -- |
|
||||
| US-AD-34 | Monitor Student Progress | PASS | -- |
|
||||
| US-AD-35 | English Quality Gate | PASS | -- |
|
||||
| US-AD-36 | IELTS Automated Check | PASS | -- |
|
||||
| US-AD-37 | IELTS Examiner Review | PASS | -- |
|
||||
| US-AD-38 | Approve Exam Results | PASS | Minor: BUG-020 |
|
||||
| US-AD-39 | Reject Exam Results | PASS | Minor: BUG-020 |
|
||||
| US-AD-40 | Adaptive Dashboard | PARTIAL | BUG-023 (avg_progress=0) |
|
||||
| US-AD-41 | Adaptive Thresholds | FAIL | BUG-001 (#4, #6, path mismatch) |
|
||||
| US-AD-42 | Student Signals/Decisions | FAIL | BUG-001 (#5, path mismatch) |
|
||||
| US-PV-01 | Verify Score via QR Code | PASS | -- |
|
||||
|
||||
**Result: 29 PASS / 7 FAIL / 4 PARTIAL / 3 with minor notes = 67.4% pass rate**
|
||||
|
||||
---
|
||||
|
||||
## 8. Overall Statistics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Total User Stories Assessed** | 83 |
|
||||
| **Full PASS** | 53 (63.9%) |
|
||||
| **PARTIAL** (works with caveats) | 11 (13.3%) |
|
||||
| **FAIL** (blocked or broken) | 19 (22.9%) |
|
||||
| | |
|
||||
| **P0 Blocker Bugs** | 5 |
|
||||
| **P1 High Severity Bugs** | 11 |
|
||||
| **P2 Medium Severity Bugs** | 10 |
|
||||
| **P3 Observations** | 6 |
|
||||
| **Total Issues** | 32 |
|
||||
| | |
|
||||
| **API Path Mismatches** | 8 |
|
||||
| **Missing Backend Endpoints** | 3 |
|
||||
| **Stub/Placeholder Features** | 8 |
|
||||
| **Security Concerns** | 3 |
|
||||
| **Automated Tests** | 0 (1 placeholder file) |
|
||||
|
||||
---
|
||||
|
||||
## 9. Appendix A -- API Contract Mismatches (Full Detail)
|
||||
|
||||
### A.1 Entity Onboarding Paths
|
||||
|
||||
```
|
||||
FRONTEND BACKEND
|
||||
-------- -------
|
||||
/api/entity/students/csv/validate != /api/entity/students/validate-csv
|
||||
/api/entity/students/:id/resend-credential != /api/entity/students/:id/resend-credentials
|
||||
/api/entity/students/resend-credentials/pending != /api/entity/students/resend-all-pending
|
||||
```
|
||||
|
||||
**Source files:**
|
||||
- Frontend: `src/services/entity-onboarding.service.ts`
|
||||
- Backend: `custom_addons/encoach_entity_onboard/controllers/entity_onboard.py`
|
||||
|
||||
### A.2 Adaptive Engine Paths
|
||||
|
||||
```
|
||||
FRONTEND BACKEND
|
||||
-------- -------
|
||||
/api/adaptive-engine/dashboard != /api/adaptive/dashboard
|
||||
/api/adaptive-engine/students != /api/adaptive/students
|
||||
/api/adaptive-engine/students/:id/signals != /api/adaptive/student/:id/signals
|
||||
/api/adaptive-engine/students/:id/ability != /api/adaptive/student/:id/ability
|
||||
/api/adaptive-engine/settings != /api/adaptive/settings
|
||||
```
|
||||
|
||||
Note the additional **singular vs plural** mismatch: frontend uses `students/:id`, backend uses `student/:id`.
|
||||
|
||||
**Source files:**
|
||||
- Frontend: `src/services/adaptive-engine.service.ts`
|
||||
- Backend: `custom_addons/encoach_adaptive/controllers/adaptive.py`
|
||||
|
||||
### A.3 Missing Backend Endpoints
|
||||
|
||||
| Frontend Path | Called From | Backend Status |
|
||||
|---------------|------------|----------------|
|
||||
| `/api/reset/sendVerification` | `auth.service.ts` | Does not exist |
|
||||
| `/api/auth/invite/set-password` | `auth.service.ts` | Does not exist |
|
||||
| `/api/exam/:examId/status` | `exam-session.service.ts` | Does not exist |
|
||||
| `/api/subscription/trial` | `placement.service.ts` | Does not exist |
|
||||
|
||||
### A.4 Payload Shape Mismatch
|
||||
|
||||
| Endpoint | Frontend Sends | Backend Expects |
|
||||
|----------|---------------|-----------------|
|
||||
| `/api/entity/students/send-credentials` | `{ student_ids: number[] }` | `{ user_ids: [...] }` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Appendix B -- Stub / Placeholder Inventory
|
||||
|
||||
These features have UI elements rendered in the frontend but no real functionality behind them.
|
||||
|
||||
| Feature | Location | What Exists | What's Missing |
|
||||
|---------|----------|-------------|----------------|
|
||||
| CAPTCHA widget | `Register.tsx` | Dashed placeholder box | Real reCAPTCHA/hCaptcha/Turnstile SDK integration |
|
||||
| Speaking recording (placement) | `PlacementTest.tsx` | Disabled button, text message | MediaRecorder API, audio upload |
|
||||
| Speaking recording (exam) | `ExamSession.tsx` | Placeholder text | MediaRecorder API, audio upload |
|
||||
| Listening playback (exam) | `ExamSession.tsx` | Fake progress bar, `0:00 / 3:42` | HTML5 audio element, real playback controls |
|
||||
| Exam results data | `ExamResults.tsx` | Static mock data rendering | API integration with `/api/exam/:examId/results` |
|
||||
| PDF download | `ExamResults.tsx` | Button with no handler | Import and call `reportService.downloadPdf()` |
|
||||
| Custom exam questions | `CustomExamCreate.tsx` | `Math.random()` IDs | Content pool API query and real question selection |
|
||||
| View Details (scores) | `ScoreApprovalQueue.tsx` | Button with no handler | Navigation or modal to attempt details |
|
||||
|
||||
---
|
||||
|
||||
*End of Report*
|
||||
@@ -145,6 +145,7 @@ import FaqPage from "@/pages/FaqPage";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import { queryClient } from "@/lib/query-client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||
|
||||
function StudentSubscriptionPlaceholder() {
|
||||
const navigate = useNavigate();
|
||||
@@ -157,6 +158,7 @@ function StudentSubscriptionPlaceholder() {
|
||||
}
|
||||
|
||||
const App = () => (
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
@@ -340,6 +342,7 @@ const App = () => (
|
||||
</BrowserRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
export default App;
|
||||
|
||||
62
frontend/src/components/ErrorBoundary.tsx
Normal file
62
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error("ErrorBoundary caught:", error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) return this.props.fallback;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background p-6">
|
||||
<div className="max-w-md w-full text-center space-y-4">
|
||||
<div className="mx-auto h-16 w-16 rounded-full bg-destructive/10 flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold">Something went wrong</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
An unexpected error occurred. Please try refreshing the page.
|
||||
</p>
|
||||
{this.state.error && (
|
||||
<details className="text-left text-xs text-muted-foreground bg-muted rounded-lg p-3">
|
||||
<summary className="cursor-pointer font-medium">Error details</summary>
|
||||
<pre className="mt-2 whitespace-pre-wrap break-words">{this.state.error.message}</pre>
|
||||
</details>
|
||||
)}
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,48 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search } from "lucide-react";
|
||||
import { Search, Plus, FileText, Clock } from "lucide-react";
|
||||
import { useInstitutionalExamSessions } from "@/hooks/queries";
|
||||
import { api } from "@/lib/api-client";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface CustomExam {
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
total_time_min: number;
|
||||
sections: { id: number; title: string; skill: string }[];
|
||||
teacher_id: number | null;
|
||||
template_id: number | null;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const statusColors: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
|
||||
published: "default",
|
||||
draft: "secondary",
|
||||
archived: "outline",
|
||||
};
|
||||
|
||||
export default function ExamsListPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
const sessionsQ = useInstitutionalExamSessions();
|
||||
const items = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
||||
const sessions = Array.isArray(items) ? items : [];
|
||||
const [tab, setTab] = useState<"custom" | "sessions">("custom");
|
||||
|
||||
const customQ = useQuery({
|
||||
queryKey: ["custom-exams", search],
|
||||
queryFn: () => api.get<{ items: CustomExam[]; total: number }>(`/exam/custom/list?search=${encodeURIComponent(search)}&per_page=50`),
|
||||
});
|
||||
|
||||
const sessionsQ = useInstitutionalExamSessions();
|
||||
const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? [];
|
||||
const sessions = Array.isArray(sessionItems) ? sessionItems : [];
|
||||
|
||||
const customExams = customQ.data?.items ?? [];
|
||||
const q = search.toLowerCase();
|
||||
const filtered = sessions.filter(s =>
|
||||
const filteredSessions = sessions.filter((s: Record<string, string>) =>
|
||||
s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q),
|
||||
);
|
||||
|
||||
@@ -23,8 +51,20 @@ export default function ExamsListPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Exams List</h1>
|
||||
<p className="text-muted-foreground">Browse and manage all exam sessions.</p>
|
||||
<p className="text-muted-foreground">Browse and manage all exams and exam sessions.</p>
|
||||
</div>
|
||||
<Link to="/admin/exam/create">
|
||||
<Button><Plus className="h-4 w-4 mr-2" /> Create Exam</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant={tab === "custom" ? "default" : "outline"} size="sm" onClick={() => setTab("custom")}>
|
||||
<FileText className="h-4 w-4 mr-1" /> Custom Exams ({customExams.length})
|
||||
</Button>
|
||||
<Button variant={tab === "sessions" ? "default" : "outline"} size="sm" onClick={() => setTab("sessions")}>
|
||||
<Clock className="h-4 w-4 mr-1" /> Exam Sessions ({filteredSessions.length})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-sm">
|
||||
@@ -32,44 +72,92 @@ export default function ExamsListPage() {
|
||||
<Input placeholder="Search exams..." className="pl-9" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{sessionsQ.isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[300px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Batch</TableHead>
|
||||
<TableHead>Start</TableHead>
|
||||
<TableHead>End</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||
)}
|
||||
{filtered.map((s, i) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{s.course_name || "—"}</TableCell>
|
||||
<TableCell>{s.batch_name || "—"}</TableCell>
|
||||
<TableCell>{s.start_date || "—"}</TableCell>
|
||||
<TableCell>{s.end_date || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
||||
</TableCell>
|
||||
{tab === "custom" && (
|
||||
customQ.isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Modules</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{customExams.length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No custom exams found. Submit one from the Generation page.</TableCell></TableRow>
|
||||
)}
|
||||
{customExams.map((exam, i) => (
|
||||
<TableRow key={exam.id}>
|
||||
<TableCell className="text-muted-foreground">{exam.id}</TableCell>
|
||||
<TableCell className="font-medium">{exam.title}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{exam.sections.length > 0
|
||||
? exam.sections.map((s) => (
|
||||
<Badge key={s.id} variant="outline" className="text-xs capitalize">{s.skill || s.title}</Badge>
|
||||
))
|
||||
: <span className="text-muted-foreground text-xs">—</span>}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{exam.total_time_min ? `${exam.total_time_min} min` : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[exam.status] ?? "outline"} className="capitalize">{exam.status}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === "sessions" && (
|
||||
sessionsQ.isLoading ? (
|
||||
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
|
||||
) : (
|
||||
<Card className="border-0 shadow-sm">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>#</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Course</TableHead>
|
||||
<TableHead>Batch</TableHead>
|
||||
<TableHead>Start</TableHead>
|
||||
<TableHead>End</TableHead>
|
||||
<TableHead>State</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSessions.length === 0 && (
|
||||
<TableRow><TableCell colSpan={7} className="text-center text-muted-foreground py-8">No exam sessions found.</TableCell></TableRow>
|
||||
)}
|
||||
{filteredSessions.map((s: Record<string, string>, i: number) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell className="font-medium">{s.name}</TableCell>
|
||||
<TableCell>{s.course_name || "—"}</TableCell>
|
||||
<TableCell>{s.batch_name || "—"}</TableCell>
|
||||
<TableCell>{s.start_date || "—"}</TableCell>
|
||||
<TableCell>{s.end_date || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={s.state === "done" ? "default" : s.state === "schedule" ? "secondary" : "outline"} className="capitalize">{s.state}</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,6 +8,13 @@ import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -45,6 +52,7 @@ import {
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import { generationService } from "@/services/generation.service";
|
||||
import { mediaService, type Avatar } from "@/services/media.service";
|
||||
import { examsService } from "@/services/exams.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry";
|
||||
@@ -66,6 +74,7 @@ const MODULES: ModuleInfo[] = [
|
||||
{ key: "industry", label: "Industry", icon: <Briefcase className="h-4 w-4" />, color: "text-amber-700", bgColor: "bg-amber-50 border-amber-200" },
|
||||
];
|
||||
|
||||
const MODULE_KEYS: ModuleKey[] = MODULES.map((m) => m.key);
|
||||
const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"];
|
||||
|
||||
const READING_EXERCISE_TYPES = [
|
||||
@@ -188,6 +197,13 @@ export default function GenerationPage() {
|
||||
const [selectedModules, setSelectedModules] = useState<Set<ModuleKey>>(new Set());
|
||||
const [activeModule, setActiveModule] = useState<ModuleKey | null>(null);
|
||||
const [moduleStates, setModuleStates] = useState<Record<string, ModuleState>>({});
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
|
||||
const structuresQ = useQuery({
|
||||
queryKey: ["exam-structures"],
|
||||
queryFn: () => examsService.listStructures({}),
|
||||
});
|
||||
const structures = structuresQ.data?.items ?? [];
|
||||
|
||||
const getModuleState = useCallback((mod: ModuleKey): ModuleState => {
|
||||
return moduleStates[mod] ?? defaultModuleState(mod);
|
||||
@@ -263,7 +279,12 @@ export default function GenerationPage() {
|
||||
if (activeModule !== "listening") return;
|
||||
const st = getModuleState("listening");
|
||||
const sections = [...st.listeningSections];
|
||||
sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: res.audio_url || "" };
|
||||
let url = res.audio_url || "";
|
||||
if (!url && res.audio_base64) {
|
||||
const ct = res.content_type || "audio/mpeg";
|
||||
url = `data:${ct};base64,${res.audio_base64}`;
|
||||
}
|
||||
sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: url };
|
||||
updateModuleState("listening", { listeningSections: sections });
|
||||
toast({ title: "Audio generated" });
|
||||
},
|
||||
@@ -289,10 +310,18 @@ export default function GenerationPage() {
|
||||
mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) =>
|
||||
generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }),
|
||||
onSuccess: (res, vars) => {
|
||||
const r = res as Record<string, unknown>;
|
||||
if (r.error) {
|
||||
toast({ variant: "destructive", title: "Script generation failed", description: String(r.error) });
|
||||
return;
|
||||
}
|
||||
const st = getModuleState("speaking");
|
||||
const parts = [...st.speakingParts];
|
||||
const r = res as Record<string, unknown>;
|
||||
const script = (r.script as string) ?? JSON.stringify(r.questions ?? res);
|
||||
const script = (r.script as string) || "";
|
||||
if (!script) {
|
||||
toast({ variant: "destructive", title: "No script returned", description: "AI returned empty response — try again." });
|
||||
return;
|
||||
}
|
||||
parts[vars.partIndex] = { ...parts[vars.partIndex], script };
|
||||
updateModuleState("speaking", { speakingParts: parts });
|
||||
toast({ title: "Script generated" });
|
||||
@@ -313,6 +342,45 @@ export default function GenerationPage() {
|
||||
onError: (err: Error) => toast({ variant: "destructive", title: "Video generation failed", description: err.message }),
|
||||
});
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
useEffect(() => {
|
||||
if (activeModule !== "speaking") return;
|
||||
const st = getModuleState("speaking");
|
||||
const pendingParts = st.speakingParts
|
||||
.map((p, i) => ({ videoUrl: p.videoUrl, index: i }))
|
||||
.filter((p) => p.videoUrl.startsWith("pending:"));
|
||||
if (pendingParts.length === 0) {
|
||||
if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; }
|
||||
return;
|
||||
}
|
||||
if (pollTimerRef.current) return;
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
const currentSt = getModuleState("speaking");
|
||||
let changed = false;
|
||||
const updatedParts = [...currentSt.speakingParts];
|
||||
for (const pp of pendingParts) {
|
||||
const part = updatedParts[pp.index];
|
||||
if (!part || !part.videoUrl.startsWith("pending:")) continue;
|
||||
const videoId = part.videoUrl.replace("pending:", "");
|
||||
try {
|
||||
const status = await mediaService.getVideoStatus(videoId);
|
||||
if (status.status === "done" || status.status === "completed" || status.video_url) {
|
||||
updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" };
|
||||
changed = true;
|
||||
toast({ title: "Video ready!", description: "Avatar video has been generated." });
|
||||
} else if (status.status === "error" || status.status === "failed") {
|
||||
updatedParts[pp.index] = { ...part, videoUrl: "" };
|
||||
changed = true;
|
||||
toast({ variant: "destructive", title: "Video generation failed" });
|
||||
}
|
||||
} catch { /* poll again next interval */ }
|
||||
}
|
||||
if (changed) updateModuleState("speaking", { speakingParts: updatedParts });
|
||||
}, 15000);
|
||||
return () => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeModule, moduleStates]);
|
||||
|
||||
const submitMut = useMutation({
|
||||
mutationFn: (skipApproval: boolean) => {
|
||||
const modulesPayload: Record<string, unknown> = {};
|
||||
@@ -332,7 +400,11 @@ export default function GenerationPage() {
|
||||
}
|
||||
return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval });
|
||||
},
|
||||
onSuccess: (res) => toast({ title: "Exam submitted", description: `Exam #${res.exam_id} created (${res.status})` }),
|
||||
onSuccess: (res) => toast({
|
||||
title: "Exam submitted successfully",
|
||||
description: `Exam #${res.exam_id} created with status "${res.status}". ${res.status === "published" ? "The exam is now live." : "Pending approval."}`,
|
||||
duration: 8000,
|
||||
}),
|
||||
onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }),
|
||||
});
|
||||
|
||||
@@ -482,12 +554,21 @@ export default function GenerationPage() {
|
||||
<Select><SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Passage Difficulty (Optional)" /></SelectTrigger>
|
||||
<SelectContent>{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Input placeholder="Topic (Optional)" className="h-8 text-xs" id={`topic-${pi}`} />
|
||||
<Input placeholder="Approximate Word Count (Optional)" className="h-8 text-xs" type="number" id={`wc-${pi}`} />
|
||||
<Input placeholder="Topic (Optional)" className="h-8 text-xs"
|
||||
value={passage.category || ""}
|
||||
onChange={(e) => {
|
||||
const p = [...st.passages]; p[pi] = { ...p[pi], category: e.target.value };
|
||||
updateModuleState("reading", { passages: p });
|
||||
}} />
|
||||
<Input placeholder="Approximate Word Count (Optional)" className="h-8 text-xs" type="number"
|
||||
value={passage.divider || ""}
|
||||
onChange={(e) => {
|
||||
const p = [...st.passages]; p[pi] = { ...p[pi], divider: e.target.value };
|
||||
updateModuleState("reading", { passages: p });
|
||||
}} />
|
||||
<Button size="sm" className="w-full text-xs" disabled={generatePassageMut.isPending}
|
||||
onClick={() => {
|
||||
const topicEl = document.getElementById(`topic-${pi}`) as HTMLInputElement;
|
||||
generatePassageMut.mutate({ index: pi, topic: topicEl?.value || "", difficulty: st.difficulty[0] || "B2", wordCount: 300 });
|
||||
generatePassageMut.mutate({ index: pi, topic: passage.category || "", difficulty: st.difficulty[0] || "B2", wordCount: Number(passage.divider) || 300 });
|
||||
}}>
|
||||
{generatePassageMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
||||
Generate
|
||||
@@ -588,11 +669,15 @@ export default function GenerationPage() {
|
||||
Audio Context <ChevronDown className="h-3 w-3" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2 space-y-2">
|
||||
<Input placeholder="Topic" className="h-8 text-xs" id={`ltopic-${si}`} />
|
||||
<Input placeholder="Topic" className="h-8 text-xs"
|
||||
value={sec.category || ""}
|
||||
onChange={(e) => {
|
||||
const sections = [...st.listeningSections]; sections[si] = { ...sections[si], category: e.target.value };
|
||||
updateModuleState("listening", { listeningSections: sections });
|
||||
}} />
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" className="flex-1 text-xs" onClick={() => {
|
||||
const topicEl = document.getElementById(`ltopic-${si}`) as HTMLInputElement;
|
||||
generationService.generateListeningContext({ topic: topicEl?.value || "general conversation", section_type: sec.type })
|
||||
generationService.generateListeningContext({ topic: sec.category || "general conversation", section_type: sec.type })
|
||||
.then((res) => {
|
||||
const r = res as Record<string, unknown>;
|
||||
const sections = [...st.listeningSections];
|
||||
@@ -635,7 +720,12 @@ export default function GenerationPage() {
|
||||
{generateAudioMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Play className="h-3 w-3 mr-1" />}
|
||||
Generate Audio
|
||||
</Button>
|
||||
{sec.audioUrl && <p className="text-xs text-green-600 mt-1">Audio ready</p>}
|
||||
{sec.audioUrl && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-green-600">Audio ready</p>
|
||||
<audio controls src={sec.audioUrl} className="w-full h-8" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
@@ -694,14 +784,18 @@ export default function GenerationPage() {
|
||||
Generate Instructions <ChevronDown className="h-3 w-3" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2 space-y-2">
|
||||
<Input placeholder="Topic" className="h-8 text-xs" id={`wtopic-${ti}`} />
|
||||
<Input placeholder="Topic" className="h-8 text-xs"
|
||||
value={task.category || ""}
|
||||
onChange={(e) => {
|
||||
const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], category: e.target.value };
|
||||
updateModuleState("writing", { writingTasks: tasks });
|
||||
}} />
|
||||
<Select><SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Difficulty (Optional)" /></SelectTrigger>
|
||||
<SelectContent>{CEFR_LEVELS.map((l) => <SelectItem key={l} value={l}>{l}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" className="w-full text-xs" disabled={generateWritingMut.isPending}
|
||||
onClick={() => {
|
||||
const topicEl = document.getElementById(`wtopic-${ti}`) as HTMLInputElement;
|
||||
generateWritingMut.mutate({ topic: topicEl?.value || "general", difficulty: st.difficulty[0] || "A1", taskIndex: ti });
|
||||
generateWritingMut.mutate({ topic: task.category || "general", difficulty: st.difficulty[0] || "A1", taskIndex: ti });
|
||||
}}>
|
||||
{generateWritingMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Sparkles className="h-3 w-3 mr-1" />}
|
||||
Generate
|
||||
@@ -831,7 +925,18 @@ export default function GenerationPage() {
|
||||
{generateVideoMut.isPending ? <Loader2 className="h-3 w-3 mr-1 animate-spin" /> : <Video className="h-3 w-3 mr-1" />}
|
||||
Generate Video
|
||||
</Button>
|
||||
{part.videoUrl && <p className="text-xs text-green-600">Video: {part.videoUrl.startsWith("pending:") ? "Processing..." : "Ready"}</p>}
|
||||
{part.videoUrl && part.videoUrl.startsWith("pending:") && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Loader2 className="h-3 w-3 animate-spin text-orange-500" />
|
||||
<p className="text-xs text-orange-600">Video processing... (polling every 15s)</p>
|
||||
</div>
|
||||
)}
|
||||
{part.videoUrl && !part.videoUrl.startsWith("pending:") && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-green-600">Video ready</p>
|
||||
<video controls src={part.videoUrl} className="w-full rounded max-h-48" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</CardContent>
|
||||
@@ -893,13 +998,27 @@ export default function GenerationPage() {
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Exam Structure</Label>
|
||||
<Select value={examStructure} onValueChange={setExamStructure}>
|
||||
<Select value={examStructure} onValueChange={(val) => {
|
||||
setExamStructure(val);
|
||||
const s = structures.find((st) => String(st.id) === val);
|
||||
if (s) {
|
||||
const mods = (s as Record<string, unknown>).modules as string[] | undefined;
|
||||
if (Array.isArray(mods) && mods.length) {
|
||||
const next = new Set<ModuleKey>();
|
||||
mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); });
|
||||
setSelectedModules(next);
|
||||
if (next.size > 0) setActiveModule([...next][0]);
|
||||
}
|
||||
}
|
||||
}}>
|
||||
<SelectTrigger><SelectValue placeholder="Select an exam structure" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard_ielts">Standard IELTS Academic</SelectItem>
|
||||
<SelectItem value="corporate">Corporate English Assessment</SelectItem>
|
||||
<SelectItem value="hospitality">Hospitality English Test</SelectItem>
|
||||
<SelectItem value="medical">Medical English Proficiency</SelectItem>
|
||||
{structures.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
|
||||
))}
|
||||
{structures.length === 0 && (
|
||||
<SelectItem value="_none" disabled>No structures available</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -971,11 +1090,107 @@ export default function GenerationPage() {
|
||||
<Button variant="outline" disabled={anyGenerating || !title} onClick={() => submitMut.mutate(true)}>
|
||||
<SkipForward className="h-4 w-4 mr-2" /> Submit module as exam and skip approval
|
||||
</Button>
|
||||
<Button variant="outline" disabled>
|
||||
<Button variant="outline" disabled={!activeModule} onClick={() => setPreviewOpen(true)}>
|
||||
<Eye className="h-4 w-4 mr-2" /> Preview module
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
|
||||
<DialogContent className="max-w-3xl max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title || "Untitled Exam"} — Preview</DialogTitle>
|
||||
<DialogDescription>
|
||||
Read-only preview of all configured modules and their content.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-6 pt-2">
|
||||
{[...selectedModules].map((mod) => {
|
||||
const st = getModuleState(mod);
|
||||
return (
|
||||
<div key={mod} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-lg capitalize">{mod}</h3>
|
||||
<div className="flex gap-2">
|
||||
<Badge variant="outline">{st.timer} min</Badge>
|
||||
{st.difficulty.map((d) => <Badge key={d} variant="secondary">{d}</Badge>)}
|
||||
<Badge variant={st.accessType === "public" ? "default" : "outline"}>{st.accessType}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mod === "reading" && st.passages.map((p, i) => (
|
||||
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
||||
<h4 className="font-medium text-sm">Passage {i + 1} {p.category && `(${p.category})`}</h4>
|
||||
{p.text ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{p.text.slice(0, 500)}{p.text.length > 500 ? "…" : ""}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No passage text yet</p>
|
||||
)}
|
||||
{p.exercises.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{p.exercises.length} exercise(s) generated</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{mod === "listening" && st.listeningSections.map((s, i) => (
|
||||
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
||||
<h4 className="font-medium text-sm">Section {i + 1}: {s.type.replace(/_/g, " ")}</h4>
|
||||
{s.context ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{s.context.slice(0, 500)}{s.context.length > 500 ? "…" : ""}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No context yet</p>
|
||||
)}
|
||||
{s.audioUrl && <p className="text-xs text-green-600">Audio generated</p>}
|
||||
{s.exercises.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">{s.exercises.length} exercise(s) generated</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{mod === "writing" && st.writingTasks.map((t, i) => (
|
||||
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">Task {i + 1}</h4>
|
||||
<span className="text-xs text-muted-foreground">{t.wordLimit} words · {t.marks} marks</span>
|
||||
</div>
|
||||
{t.instructions ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{t.instructions}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No instructions yet</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{mod === "speaking" && st.speakingParts.map((p, i) => (
|
||||
<div key={i} className="bg-muted/50 rounded p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm">Part {i + 1}: {p.type.replace(/_/g, " ")}</h4>
|
||||
<span className="text-xs text-muted-foreground">{p.marks} marks</span>
|
||||
</div>
|
||||
{p.script ? (
|
||||
<p className="text-sm whitespace-pre-wrap leading-relaxed">{p.script.slice(0, 600)}{p.script.length > 600 ? "…" : ""}</p>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground italic">No script yet</p>
|
||||
)}
|
||||
{p.videoUrl && !p.videoUrl.startsWith("pending:") && (
|
||||
<p className="text-xs text-green-600">Video ready</p>
|
||||
)}
|
||||
{p.videoUrl && p.videoUrl.startsWith("pending:") && (
|
||||
<p className="text-xs text-orange-600">Video processing…</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{st.shuffling && <p className="text-xs text-muted-foreground">Shuffling enabled</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{selectedModules.size === 0 && (
|
||||
<p className="text-muted-foreground text-center py-8">No modules selected yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { GraduationCap, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||
import { GraduationCap, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react";
|
||||
import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup";
|
||||
import { ApiError } from "@/lib/api-client";
|
||||
import { ApiError, api } from "@/lib/api-client";
|
||||
import type { RegisterRequest } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -47,6 +48,41 @@ export default function Register() {
|
||||
const checkEmail = useCheckEmail();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [captchaToken, setCaptchaToken] = useState<string>("");
|
||||
const captchaContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: captchaConfig } = useQuery({
|
||||
queryKey: ["captcha-config"],
|
||||
queryFn: () => api.get<{ provider: string; site_key: string }>("/config/captcha"),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const loadCaptchaScript = useCallback((provider: string, siteKey: string) => {
|
||||
if (!siteKey || !provider) return;
|
||||
const existingScript = document.querySelector(`script[data-captcha-provider="${provider}"]`);
|
||||
if (existingScript) return;
|
||||
|
||||
const scriptUrls: Record<string, string> = {
|
||||
recaptcha: `https://www.google.com/recaptcha/api.js?render=${siteKey}`,
|
||||
hcaptcha: "https://js.hcaptcha.com/1/api.js",
|
||||
turnstile: "https://challenges.cloudflare.com/turnstile/v0/api.js",
|
||||
};
|
||||
const url = scriptUrls[provider];
|
||||
if (!url) return;
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = url;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
script.setAttribute("data-captcha-provider", provider);
|
||||
document.head.appendChild(script);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (captchaConfig?.site_key && captchaConfig?.provider) {
|
||||
loadCaptchaScript(captchaConfig.provider, captchaConfig.site_key);
|
||||
}
|
||||
}, [captchaConfig, loadCaptchaScript]);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
@@ -69,7 +105,7 @@ export default function Register() {
|
||||
email: values.email.trim(),
|
||||
password: values.password,
|
||||
role: values.role,
|
||||
captcha_token: "demo-placeholder",
|
||||
captcha_token: captchaToken || undefined,
|
||||
};
|
||||
try {
|
||||
await register.mutateAsync(payload);
|
||||
@@ -251,10 +287,26 @@ export default function Register() {
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-dashed bg-muted/30 p-4 space-y-2">
|
||||
<p className="text-sm font-medium">CAPTCHA</p>
|
||||
<div className="h-16 rounded-md bg-muted flex items-center justify-center text-xs text-muted-foreground">
|
||||
Verification widget placeholder
|
||||
<p className="text-sm font-medium flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4" /> Verification
|
||||
</p>
|
||||
<div ref={captchaContainerRef} className="min-h-[65px] flex items-center justify-center">
|
||||
{captchaConfig?.site_key ? (
|
||||
<div
|
||||
className={captchaConfig.provider === "hcaptcha" ? "h-captcha" :
|
||||
captchaConfig.provider === "turnstile" ? "cf-turnstile" : "g-recaptcha"}
|
||||
data-sitekey={captchaConfig.site_key}
|
||||
data-callback="onCaptchaSuccess"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">CAPTCHA not configured — registration allowed</p>
|
||||
)}
|
||||
</div>
|
||||
{captchaToken && (
|
||||
<p className="text-xs text-green-600 flex items-center gap-1">
|
||||
<ShieldCheck className="h-3 w-3" /> Verified
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form.formState.errors.root && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -8,11 +9,21 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Search, Plus } from "lucide-react";
|
||||
import { Search, Plus, Loader2 } from "lucide-react";
|
||||
import AiTipBanner from "@/components/ai/AiTipBanner";
|
||||
import AiCreationAssistant from "@/components/ai/AiCreationAssistant";
|
||||
import { examsService } from "@/services/exams.service";
|
||||
|
||||
const rubrics = [
|
||||
interface RubricItem {
|
||||
id: number;
|
||||
name: string;
|
||||
levels: string[];
|
||||
criteria: number;
|
||||
created: string;
|
||||
skill?: string;
|
||||
}
|
||||
|
||||
const FALLBACK_RUBRICS: RubricItem[] = [
|
||||
{ id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" },
|
||||
{ id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" },
|
||||
{ id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" },
|
||||
@@ -26,6 +37,13 @@ const rubricGroups = [
|
||||
export default function RubricsPage() {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const rubricsQ = useQuery({
|
||||
queryKey: ["rubrics"],
|
||||
queryFn: () => examsService.listRubrics({}),
|
||||
});
|
||||
const backendRubrics = (rubricsQ.data?.items ?? []) as RubricItem[];
|
||||
const rubrics = backendRubrics.length > 0 ? backendRubrics : FALLBACK_RUBRICS;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -583,10 +583,27 @@ function NewQuestionInline({ sectionIndex, form }: { sectionIndex: number; form:
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const cur = form.getValues(`sections.${sectionIndex}.question_ids`) ?? [];
|
||||
const nextId = Math.floor(Math.random() * 1_000_000_000);
|
||||
form.setValue(`sections.${sectionIndex}.question_ids`, [...cur, nextId]);
|
||||
disabled={!stem.trim()}
|
||||
onClick={async () => {
|
||||
if (!stem.trim()) return;
|
||||
try {
|
||||
const res = await fetch("/api/exam/questions/quick-create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${localStorage.getItem("encoach_token")}`,
|
||||
},
|
||||
body: JSON.stringify({ stem: stem.trim(), question_type: "short_answer", skill: form.getValues(`sections.${sectionIndex}.skill`) || "reading" }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const qId = data?.question_id;
|
||||
if (qId) {
|
||||
const cur = form.getValues(`sections.${sectionIndex}.question_ids`) ?? [];
|
||||
form.setValue(`sections.${sectionIndex}.question_ids`, [...cur, qId]);
|
||||
}
|
||||
} catch {
|
||||
// fallback: still add with a placeholder notice
|
||||
}
|
||||
setStem("");
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
@@ -22,6 +23,7 @@ import type { PendingScore } from "@/types";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function ScoreApprovalQueue() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, refetch } = usePendingScores();
|
||||
const release = useReleaseScore();
|
||||
const reject = useRejectScore();
|
||||
@@ -156,7 +158,7 @@ export default function ScoreApprovalQueue() {
|
||||
<Button size="sm" variant="destructive" onClick={() => openReject(r)}>
|
||||
Reject
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Button size="sm" variant="outline" onClick={() => navigate(`/admin/exam/${r.exam_id ?? r.attempt_id}/grading`)}>
|
||||
View Details
|
||||
</Button>
|
||||
</TableCell>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
|
||||
@@ -19,25 +20,107 @@ import {
|
||||
PolarRadiusAxis,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
import { Award, BookOpen, Download, RefreshCw } from "lucide-react";
|
||||
import { Award, BookOpen, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { examSessionService } from "@/services/exam-session.service";
|
||||
import { reportService } from "@/services/report.service";
|
||||
import { useState } from "react";
|
||||
|
||||
const SKILLS = [
|
||||
{ skill: "Listening", band: 7.5, cefr: "C1", gap: 0.5, target: 8 },
|
||||
{ skill: "Reading", band: 8, cefr: "C1", gap: 0, target: 8 },
|
||||
{ skill: "Writing", band: 6.5, cefr: "B2", gap: 1.5, target: 8 },
|
||||
{ skill: "Speaking", band: 7, cefr: "C1", gap: 1, target: 8 },
|
||||
];
|
||||
interface ScoreEntry {
|
||||
skill: string;
|
||||
band_score: number;
|
||||
raw_score: number;
|
||||
max_score: number;
|
||||
cefr_level: string;
|
||||
}
|
||||
|
||||
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
||||
interface FeedbackEntry {
|
||||
question_id: number | null;
|
||||
feedback_text: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
interface ExamResultsData {
|
||||
attempt_id: number;
|
||||
exam_id: number | null;
|
||||
status: string;
|
||||
completed_at: string;
|
||||
released_at: string;
|
||||
listening_band: number;
|
||||
reading_band: number;
|
||||
writing_band: number;
|
||||
speaking_band: number;
|
||||
overall_band: number;
|
||||
cefr_level: string;
|
||||
scores: ScoreEntry[];
|
||||
feedback: FeedbackEntry[];
|
||||
}
|
||||
|
||||
export default function ExamResults() {
|
||||
const { examId } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const practice = searchParams.get("mode") === "practice";
|
||||
const overall = 7.5;
|
||||
const cefr = "C1";
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const { data: results, isLoading, isError } = useQuery<ExamResultsData>({
|
||||
queryKey: ["exam-results", examId],
|
||||
queryFn: () => examSessionService.getResults(Number(examId)),
|
||||
enabled: !!examId,
|
||||
});
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!results?.attempt_id) return;
|
||||
setDownloading(true);
|
||||
try {
|
||||
await reportService.downloadPdf(results.attempt_id);
|
||||
} catch {
|
||||
// error handled silently
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !results) {
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl p-6 text-center">
|
||||
<p className="text-lg font-medium text-destructive">Results not yet available</p>
|
||||
<p className="text-muted-foreground mt-2">Your results may still be pending approval or grading.</p>
|
||||
<Button variant="outline" className="mt-4" asChild>
|
||||
<Link to={`/student/exam/${examId}/status`}>Check Status</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overall = results.overall_band;
|
||||
const cefr = results.cefr_level?.toUpperCase() || "N/A";
|
||||
const passed = overall >= 7;
|
||||
|
||||
const skillScores = results.scores.filter((s) => s.skill !== "overall");
|
||||
const SKILLS = skillScores.map((s) => ({
|
||||
skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1),
|
||||
band: s.band_score,
|
||||
cefr: s.cefr_level?.toUpperCase() || "N/A",
|
||||
gap: Math.max(0, 8 - s.band_score),
|
||||
target: 8,
|
||||
}));
|
||||
|
||||
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
|
||||
|
||||
const feedbackBySkill: Record<string, FeedbackEntry[]> = {};
|
||||
for (const fb of results.feedback) {
|
||||
const key = "General";
|
||||
if (!feedbackBySkill[key]) feedbackBySkill[key] = [];
|
||||
feedbackBySkill[key].push(fb);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-8 p-6">
|
||||
<div className="text-center">
|
||||
@@ -53,24 +136,26 @@ export default function ExamResults() {
|
||||
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skill profile</CardTitle>
|
||||
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="skill" />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
|
||||
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{RADAR_DATA.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Skill profile</CardTitle>
|
||||
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
|
||||
<PolarGrid />
|
||||
<PolarAngleAxis dataKey="skill" />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
|
||||
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -100,19 +185,24 @@ export default function ExamResults() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Section feedback</h2>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{["Listening", "Reading", "Writing", "Speaking"].map((name) => (
|
||||
<AccordionItem key={name} value={name}>
|
||||
<AccordionTrigger>{name}</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
Detailed feedback for {name} will appear here once released by your instructor.
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
{results.feedback.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 text-lg font-semibold">Feedback</h2>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{results.feedback.map((fb, i) => (
|
||||
<AccordionItem key={i} value={`fb-${i}`}>
|
||||
<AccordionTrigger>
|
||||
{fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "}
|
||||
{fb.question_id ? `(Q${fb.question_id})` : ""}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-muted-foreground">
|
||||
{fb.feedback_text || "No detailed feedback available."}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
@@ -121,16 +211,21 @@ export default function ExamResults() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="list-inside list-disc space-y-2 text-sm">
|
||||
<li>Strengthen task response structure in Writing Task 2.</li>
|
||||
<li>Extend range of cohesive devices in argumentative essays.</li>
|
||||
<li>Maintain fluency while reducing hesitation in Speaking Part 2.</li>
|
||||
{SKILLS.filter((s) => s.gap > 0)
|
||||
.sort((a, b) => b.gap - a.gap)
|
||||
.map((s) => (
|
||||
<li key={s.skill}>
|
||||
Focus on <strong>{s.skill}</strong> — current band {s.band}, target {s.target} (gap: {s.gap}).
|
||||
</li>
|
||||
))}
|
||||
{SKILLS.every((s) => s.gap === 0) && <li>Excellent performance across all skills!</li>}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button type="button" variant="outline">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
<Button type="button" variant="outline" onClick={handleDownloadPdf} disabled={downloading}>
|
||||
{downloading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
Download PDF Report
|
||||
</Button>
|
||||
<Button type="button" asChild>
|
||||
|
||||
@@ -19,8 +19,9 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
|
||||
import { Flag, ChevronLeft, ChevronRight, Pause, Play, Mic, Square } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { mediaService } from "@/services/media.service";
|
||||
|
||||
function normalizeType(t: string | null | undefined) {
|
||||
return (t ?? "").toLowerCase().replace(/\s+/g, "_");
|
||||
@@ -225,15 +226,7 @@ export default function ExamSession() {
|
||||
if (nt.includes("listen") || q.audio_url) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
|
||||
<Button type="button" variant="outline" size="icon" onClick={() => setPlaying((p) => !p)}>
|
||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</Button>
|
||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||
<div className="h-2 w-1/3 rounded-full bg-primary" />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">0:00 / 3:42</span>
|
||||
</div>
|
||||
<ListeningPlayer audioUrl={q.audio_url} audioBase64={q.audio_base64} />
|
||||
{q.options?.length ? (
|
||||
<RadioGroup
|
||||
value={typeof a.answer === "string" ? a.answer : ""}
|
||||
@@ -329,9 +322,13 @@ export default function ExamSession() {
|
||||
|
||||
if (nt.includes("speak") || nt.includes("record") || nt.includes("audio")) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
|
||||
Recording interface will appear here.
|
||||
</div>
|
||||
<SpeakingRecorder
|
||||
questionId={q.id}
|
||||
onRecorded={(blob) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
updateAnswer(q.id, { answer: url });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -481,3 +478,131 @@ export default function ExamSession() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListeningPlayer({ audioUrl, audioBase64 }: { audioUrl?: string; audioBase64?: string }) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
const src = audioUrl || (audioBase64 ? `data:audio/mpeg;base64,${audioBase64}` : "");
|
||||
|
||||
const formatTime = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{src ? (
|
||||
<>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={src}
|
||||
onTimeUpdate={() => setCurrentTime(audioRef.current?.currentTime ?? 0)}
|
||||
onLoadedMetadata={() => setDuration(audioRef.current?.duration ?? 0)}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
/>
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
|
||||
<Button
|
||||
type="button" variant="outline" size="icon"
|
||||
onClick={() => {
|
||||
if (!audioRef.current) return;
|
||||
if (isPlaying) { audioRef.current.pause(); setIsPlaying(false); }
|
||||
else { audioRef.current.play(); setIsPlaying(true); }
|
||||
}}
|
||||
>
|
||||
{isPlaying ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||
</Button>
|
||||
<div className="h-2 flex-1 rounded-full bg-muted cursor-pointer" onClick={(e) => {
|
||||
if (!audioRef.current || !duration) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
audioRef.current.currentTime = pct * duration;
|
||||
}}>
|
||||
<div className="h-2 rounded-full bg-primary transition-all" style={{ width: duration ? `${(currentTime / duration) * 100}%` : "0%" }} />
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground tabular-nums">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4 text-muted-foreground text-sm">
|
||||
Audio will be played by the examiner or is not yet available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpeakingRecorder({ questionId, onRecorded }: { questionId: number; onRecorded: (blob: Blob) => void }) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<number>(0);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mr = new MediaRecorder(stream, { mimeType: "audio/webm" });
|
||||
chunksRef.current = [];
|
||||
mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
||||
mr.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setAudioUrl(url);
|
||||
onRecorded(blob);
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
};
|
||||
mediaRecorderRef.current = mr;
|
||||
mr.start();
|
||||
setRecording(true);
|
||||
setElapsed(0);
|
||||
timerRef.current = window.setInterval(() => setElapsed((t) => t + 1), 1000);
|
||||
} catch {
|
||||
// microphone permission denied
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
mediaRecorderRef.current?.stop();
|
||||
setRecording(false);
|
||||
window.clearInterval(timerRef.current);
|
||||
};
|
||||
|
||||
const mm = Math.floor(elapsed / 60);
|
||||
const ss = elapsed % 60;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border p-6 space-y-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{!recording && !audioUrl && (
|
||||
<Button type="button" size="lg" onClick={startRecording} className="gap-2">
|
||||
<Mic className="h-5 w-5" /> Start Recording
|
||||
</Button>
|
||||
)}
|
||||
{recording && (
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="font-mono text-lg tabular-nums">{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}</span>
|
||||
<Button type="button" variant="destructive" size="lg" onClick={stopRecording} className="gap-2">
|
||||
<Square className="h-4 w-4" /> Stop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{audioUrl && (
|
||||
<div className="space-y-2">
|
||||
<audio controls src={audioUrl} className="w-full" />
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => { setAudioUrl(null); setElapsed(0); }}>
|
||||
Re-record
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -7,7 +7,8 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Mic, Loader2 } from "lucide-react";
|
||||
import { Mic, Loader2, Square } from "lucide-react";
|
||||
import { placementService } from "@/services/placement.service";
|
||||
import { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
|
||||
import type { CATQuestion, CATSection } from "@/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -324,15 +325,11 @@ export default function PlacementTest() {
|
||||
)}
|
||||
|
||||
{question.type === "audio_recording" && (
|
||||
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
|
||||
<Mic className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
Recording will be available in a future update. Use the button below to proceed for now.
|
||||
</p>
|
||||
<Button type="button" variant="secondary" size="lg" disabled>
|
||||
Record (placeholder)
|
||||
</Button>
|
||||
</div>
|
||||
<PlacementSpeakingRecorder
|
||||
sessionId={sessionId}
|
||||
promptId={question.id}
|
||||
onUploaded={() => setSingleAnswer("audio_submitted")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-4">
|
||||
@@ -362,3 +359,93 @@ export default function PlacementTest() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlacementSpeakingRecorder({ sessionId, promptId, onUploaded }: {
|
||||
sessionId: string; promptId: number; onUploaded: () => void;
|
||||
}) {
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const timerRef = useRef<number>(0);
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mr = new MediaRecorder(stream, { mimeType: "audio/webm" });
|
||||
chunksRef.current = [];
|
||||
mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); };
|
||||
mr.onstop = () => {
|
||||
const blob = new Blob(chunksRef.current, { type: "audio/webm" });
|
||||
setAudioUrl(URL.createObjectURL(blob));
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
uploadAudio(blob);
|
||||
};
|
||||
mediaRecorderRef.current = mr;
|
||||
mr.start();
|
||||
setRecording(true);
|
||||
setElapsed(0);
|
||||
timerRef.current = window.setInterval(() => setElapsed((t) => t + 1), 1000);
|
||||
} catch {
|
||||
// microphone permission denied
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
mediaRecorderRef.current?.stop();
|
||||
setRecording(false);
|
||||
window.clearInterval(timerRef.current);
|
||||
};
|
||||
|
||||
const uploadAudio = async (blob: Blob) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
const file = new File([blob], "speaking.webm", { type: "audio/webm" });
|
||||
await placementService.uploadSpeaking(sessionId, promptId, file);
|
||||
onUploaded();
|
||||
} catch {
|
||||
// upload error handled silently
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const mm = Math.floor(elapsed / 60);
|
||||
const ss = elapsed % 60;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
|
||||
{!recording && !audioUrl && (
|
||||
<>
|
||||
<Mic className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
||||
Click the button below to start recording your speaking response.
|
||||
</p>
|
||||
<Button type="button" variant="secondary" size="lg" onClick={startRecording} className="gap-2">
|
||||
<Mic className="h-5 w-5" /> Start Recording
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{recording && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-3 w-3 rounded-full bg-red-500 animate-pulse" />
|
||||
<span className="font-mono text-lg tabular-nums">{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}</span>
|
||||
</div>
|
||||
<Button type="button" variant="destructive" size="lg" onClick={stopRecording} className="gap-2">
|
||||
<Square className="h-4 w-4" /> Stop Recording
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{audioUrl && (
|
||||
<div className="space-y-3 w-full max-w-md">
|
||||
<audio controls src={audioUrl} className="w-full" />
|
||||
{uploading && <p className="text-sm text-center text-muted-foreground flex items-center justify-center gap-2"><Loader2 className="h-4 w-4 animate-spin" /> Uploading...</p>}
|
||||
{!uploading && <p className="text-sm text-center text-green-600">Recording uploaded successfully</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -12,6 +12,23 @@ import { useGenerateOutline, useGenerateChapterContent, usePublishWorkbench } fr
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { WorkbenchGeneratedOutline, WorkbenchGeneratedChapter } from "@/types/courseware";
|
||||
|
||||
function sanitizeHtml(dirty: string): string {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = "";
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(dirty, "text/html");
|
||||
const scripts = doc.querySelectorAll("script, iframe, object, embed, link[rel=import]");
|
||||
scripts.forEach((el) => el.remove());
|
||||
doc.querySelectorAll("*").forEach((el) => {
|
||||
for (const attr of Array.from(el.attributes)) {
|
||||
if (attr.name.startsWith("on") || attr.value.trim().toLowerCase().startsWith("javascript:")) {
|
||||
el.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
type Complexity = "beginner" | "intermediate" | "advanced";
|
||||
|
||||
export default function AiWorkbench() {
|
||||
@@ -160,7 +177,7 @@ export default function AiWorkbench() {
|
||||
<CardDescription>Review the detailed content before publishing.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="prose prose-sm max-w-none p-4 rounded-lg border bg-muted/30" dangerouslySetInnerHTML={{ __html: generatedContent.content }} />
|
||||
<div className="prose prose-sm max-w-none p-4 rounded-lg border bg-muted/30" dangerouslySetInnerHTML={{ __html: sanitizeHtml(generatedContent.content) }} />
|
||||
{generatedContent.exercises.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Exercises ({generatedContent.exercises.length})</h3>
|
||||
|
||||
@@ -9,22 +9,22 @@ import type {
|
||||
import type { ApiSuccessResponse } from "@/types";
|
||||
|
||||
export const adaptiveEngineService = {
|
||||
getDashboard: () => api.get<AdaptiveDashboardMetrics>("/adaptive-engine/dashboard"),
|
||||
getDashboard: () => api.get<AdaptiveDashboardMetrics>("/adaptive/dashboard"),
|
||||
|
||||
getStudents: (params?: { page?: number; limit?: number }) =>
|
||||
api.get<{ data: AdaptiveEngineStudentRow[]; pagination?: { total: number; page: number } }>(
|
||||
"/adaptive-engine/students",
|
||||
"/adaptive/students",
|
||||
params as Record<string, string | number | boolean | undefined>,
|
||||
),
|
||||
|
||||
getStudentSignals: (studentId: number) =>
|
||||
api.get<StudentAdaptiveSignal[]>(`/adaptive-engine/students/${studentId}/signals`),
|
||||
api.get<StudentAdaptiveSignal[]>(`/adaptive/student/${studentId}/signals`),
|
||||
|
||||
getStudentAbility: (studentId: number) =>
|
||||
api.get<StudentAbilityModel>(`/adaptive-engine/students/${studentId}/ability`),
|
||||
api.get<StudentAbilityModel>(`/adaptive/student/${studentId}/ability`),
|
||||
|
||||
getSettings: () => api.get<AdaptiveThresholdSettings>("/adaptive-engine/settings"),
|
||||
getSettings: () => api.get<AdaptiveThresholdSettings>("/adaptive/settings"),
|
||||
|
||||
updateSettings: (data: AdaptiveThresholdSettings) =>
|
||||
api.put<ApiSuccessResponse>("/adaptive-engine/settings", data),
|
||||
api.put<ApiSuccessResponse>("/adaptive/settings", data),
|
||||
};
|
||||
|
||||
@@ -11,14 +11,14 @@ export const entityOnboardingService = {
|
||||
validateCsv: (file: File) => {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
return api.upload<CSVValidationReport>("/entity/students/csv/validate", fd);
|
||||
return api.upload<CSVValidationReport>("/entity/students/validate-csv", fd);
|
||||
},
|
||||
|
||||
bulkCreate: (payload: { validate_session_id?: string }) =>
|
||||
api.post<BulkCreateResult>("/entity/students/bulk-create", payload),
|
||||
|
||||
sendCredentials: (studentIds: number[]) =>
|
||||
api.post<ApiSuccessResponse>("/entity/students/send-credentials", { student_ids: studentIds }),
|
||||
api.post<ApiSuccessResponse>("/entity/students/send-credentials", { user_ids: studentIds }),
|
||||
|
||||
getCredentialStatuses: (
|
||||
filters?: CredentialFilters & { page?: number; limit?: number },
|
||||
@@ -29,8 +29,8 @@ export const entityOnboardingService = {
|
||||
),
|
||||
|
||||
resendCredential: (studentId: number) =>
|
||||
api.post<ApiSuccessResponse>(`/entity/students/${studentId}/resend-credential`),
|
||||
api.post<ApiSuccessResponse>(`/entity/students/${studentId}/resend-credentials`),
|
||||
|
||||
resendAllPending: () =>
|
||||
api.post<ApiSuccessResponse>("/entity/students/resend-credentials/pending"),
|
||||
api.post<ApiSuccessResponse>("/entity/students/resend-all-pending"),
|
||||
};
|
||||
|
||||
@@ -14,4 +14,7 @@ export const examSessionService = {
|
||||
|
||||
getStatus: (examId: number) =>
|
||||
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
||||
|
||||
getResults: (examId: number) =>
|
||||
api.get(`/exam/${examId}/results`),
|
||||
};
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL?.trim() || "/api").replace(/\/$/, "");
|
||||
|
||||
export const reportService = {
|
||||
downloadPdf: async (attemptId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/reports/exam/${attemptId}/pdf`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem("encoach_token")}`,
|
||||
},
|
||||
const token = localStorage.getItem("encoach_token");
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/reports/exam/${attemptId}/pdf`, {
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to generate report");
|
||||
const blob = await response.blob();
|
||||
@@ -13,7 +17,9 @@ export const reportService = {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `exam-report-${attemptId}.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user