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:
Yamen Ahmad
2026-04-12 14:26:39 +04:00
parent 6a62a43d61
commit ca91544acd
20 changed files with 682 additions and 75 deletions

13
Dockerfile Normal file
View 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

View File

@@ -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,
})

View File

@@ -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",

View File

@@ -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")

View File

@@ -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):

View File

@@ -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")
payload = {
"name": title,
"slides": [
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": [
{
"speech": script,
"avatar": avatar_id or "default",
"language": language,
"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": 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"),
}

View File

@@ -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',

View File

@@ -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',

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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
# ------------------------------------------------------------------

View File

@@ -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)

View File

@@ -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])

View File

@@ -303,19 +303,50 @@ 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)
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)
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': 'completed',
'status': 'processing',
'transcription': None,
})
return _error_response('session_id or upload_id is required', 400)
except Exception as e:
_logger.exception('speaking status failed')
return _error_response(str(e), 500)

View File

@@ -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
# ------------------------------------------------------------------

View File

@@ -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

View File

@@ -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
docker-compose.yml Normal file
View 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
requirements.txt Normal file
View 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