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
|
||||
Reference in New Issue
Block a user