- 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
496 lines
21 KiB
Python
496 lines
21 KiB
Python
import json
|
|
import hashlib
|
|
import logging
|
|
import random
|
|
import string
|
|
from datetime import timedelta
|
|
|
|
import requests as http_requests
|
|
|
|
from odoo import http, fields
|
|
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__)
|
|
|
|
|
|
class EncoachSignupController(http.Controller):
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/auth/register
|
|
# ------------------------------------------------------------------
|
|
@http.route('/api/auth/register', type='http', auth='public',
|
|
methods=['POST'], csrf=False)
|
|
def register(self, **kw):
|
|
try:
|
|
body = _get_json_body()
|
|
email = (body.get('email') or '').strip().lower()
|
|
password = body.get('password')
|
|
name = body.get('name', '').strip()
|
|
role = body.get('role', 'student')
|
|
captcha_token = body.get('captcha_token')
|
|
|
|
if not email or not password or not name:
|
|
return _error_response('email, password, and name are required', 400)
|
|
|
|
valid_roles = ('academic_student', 'self_learner', 'teacher', 'student')
|
|
if role not in valid_roles:
|
|
return _error_response(f'role must be one of: {", ".join(valid_roles)}', 400)
|
|
|
|
# Map frontend role names to user_type values
|
|
role_to_user_type = {
|
|
'academic_student': 'student',
|
|
'self_learner': 'student',
|
|
'teacher': 'teacher',
|
|
'student': 'student',
|
|
}
|
|
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
secret_key = ICP.get_param('encoach.captcha_secret_key', '')
|
|
provider = ICP.get_param('encoach.captcha_provider', 'recaptcha')
|
|
|
|
if captcha_token and secret_key:
|
|
verify_urls = {
|
|
'recaptcha': 'https://www.google.com/recaptcha/api/siteverify',
|
|
'hcaptcha': 'https://hcaptcha.com/siteverify',
|
|
'turnstile': 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
}
|
|
verify_url = verify_urls.get(provider)
|
|
if verify_url:
|
|
try:
|
|
resp = http_requests.post(verify_url, data={
|
|
'secret': secret_key,
|
|
'response': captcha_token,
|
|
}, timeout=10)
|
|
if not resp.json().get('success'):
|
|
return _error_response('CAPTCHA verification failed', 400)
|
|
except Exception:
|
|
return _error_response('CAPTCHA verification failed', 400)
|
|
|
|
existing = request.env['res.users'].sudo().search(
|
|
[('login', '=', email)], limit=1)
|
|
if existing:
|
|
return _error_response('Email already registered', 409)
|
|
|
|
company = request.env['res.company'].sudo().search([], limit=1)
|
|
user = request.env['res.users'].sudo().with_context(
|
|
no_reset_password=True,
|
|
).create({
|
|
'login': email,
|
|
'email': email,
|
|
'name': name,
|
|
'password': password,
|
|
'company_id': company.id,
|
|
'company_ids': [(4, company.id)],
|
|
'user_type': role_to_user_type.get(role, 'student'),
|
|
'account_source': 'self_registered',
|
|
'account_status': 'unactivated',
|
|
})
|
|
|
|
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,
|
|
})
|
|
|
|
_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,
|
|
'email': email,
|
|
'role': role,
|
|
}, 201)
|
|
|
|
except Exception as e:
|
|
_logger.exception('register failed')
|
|
return _error_response(str(e), 500)
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/auth/check-email
|
|
# ------------------------------------------------------------------
|
|
@http.route('/api/auth/check-email', type='http', auth='none',
|
|
methods=['POST'], csrf=False)
|
|
def check_email(self, **kw):
|
|
try:
|
|
body = _get_json_body()
|
|
email = (body.get('email') or '').strip().lower()
|
|
if not email:
|
|
return _error_response('email is required', 400)
|
|
|
|
exists = bool(request.env['res.users'].sudo().search(
|
|
[('login', '=', email)], limit=1))
|
|
return _json_response({'exists': exists})
|
|
|
|
except Exception as e:
|
|
_logger.exception('check_email failed')
|
|
return _error_response(str(e), 500)
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/auth/verify-email
|
|
# ------------------------------------------------------------------
|
|
@http.route('/api/auth/verify-email', type='http', auth='none',
|
|
methods=['POST'], csrf=False)
|
|
def verify_email(self, **kw):
|
|
try:
|
|
body = _get_json_body()
|
|
email = (body.get('email') or '').strip().lower()
|
|
otp = body.get('otp', '').strip()
|
|
|
|
if not email or not otp:
|
|
return _error_response('email and otp are required', 400)
|
|
|
|
otp_hash = hashlib.sha256(otp.encode()).hexdigest()
|
|
now = fields.Datetime.now()
|
|
|
|
record = request.env['encoach.otp'].sudo().search([
|
|
('email', '=', email),
|
|
('otp_hash', '=', otp_hash),
|
|
('used', '=', False),
|
|
('expires_at', '>=', now),
|
|
], limit=1, order='created_at desc')
|
|
|
|
if not record:
|
|
return _error_response('Invalid or expired OTP', 400)
|
|
|
|
record.write({'used': True})
|
|
|
|
user = request.env['res.users'].sudo().search(
|
|
[('login', '=', email)], limit=1)
|
|
if user:
|
|
user.write({'is_verified': True})
|
|
|
|
return _json_response({'verified': True})
|
|
|
|
except Exception as e:
|
|
_logger.exception('verify_email failed')
|
|
return _error_response(str(e), 500)
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/auth/resend-otp
|
|
# ------------------------------------------------------------------
|
|
@http.route('/api/auth/resend-otp', type='http', auth='none',
|
|
methods=['POST'], csrf=False)
|
|
def resend_otp(self, **kw):
|
|
try:
|
|
body = _get_json_body()
|
|
email = (body.get('email') or '').strip().lower()
|
|
if not email:
|
|
return _error_response('email is required', 400)
|
|
|
|
latest = request.env['encoach.otp'].sudo().search([
|
|
('email', '=', email),
|
|
], limit=1, order='created_at desc')
|
|
|
|
if latest and latest.resend_count >= 3:
|
|
return _error_response('Maximum resend attempts reached', 429)
|
|
|
|
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)
|
|
new_count = (latest.resend_count + 1) if latest else 1
|
|
|
|
request.env['encoach.otp'].sudo().create({
|
|
'email': email,
|
|
'otp_hash': otp_hash,
|
|
'expires_at': expires_at,
|
|
'resend_count': new_count,
|
|
})
|
|
|
|
_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
|
|
# ------------------------------------------------------------------
|
|
@http.route('/api/onboarding/goals', type='http', auth='none',
|
|
methods=['GET'], csrf=False)
|
|
@jwt_required
|
|
def onboarding_goals(self, **kw):
|
|
try:
|
|
# Dynamic goals from exam templates in DB
|
|
Template = request.env['encoach.exam.template'].sudo()
|
|
templates = Template.search([('active', '=', True)])
|
|
seen = set()
|
|
goals = []
|
|
icon_map = {
|
|
'ielts': 'fa-language',
|
|
'general_english': 'fa-comments',
|
|
'general english': 'fa-comments',
|
|
'academic': 'fa-graduation-cap',
|
|
'math': 'fa-calculator',
|
|
'mathematics': 'fa-calculator',
|
|
'it': 'fa-laptop',
|
|
'information technology': 'fa-laptop',
|
|
}
|
|
for tpl in templates:
|
|
subj = tpl.subject_id
|
|
if subj and subj.name and subj.name.lower() not in seen:
|
|
seen.add(subj.name.lower())
|
|
goal_id = subj.name.lower().replace(' ', '_')
|
|
goals.append({
|
|
'id': goal_id,
|
|
'name': subj.name,
|
|
'icon': icon_map.get(goal_id, icon_map.get(subj.name.lower(), 'fa-book')),
|
|
})
|
|
# Fallback if no templates found
|
|
if not goals:
|
|
goals = [
|
|
{'id': 'ielts', 'name': 'IELTS Preparation', 'icon': 'fa-language'},
|
|
{'id': 'general_english', 'name': 'General English', 'icon': 'fa-comments'},
|
|
{'id': 'math', 'name': 'Mathematics', 'icon': 'fa-calculator'},
|
|
{'id': 'it', 'name': 'Information Technology', 'icon': 'fa-laptop'},
|
|
]
|
|
return _json_response({'goals': goals})
|
|
|
|
except Exception as e:
|
|
_logger.exception('onboarding_goals failed')
|
|
return _error_response(str(e), 500)
|
|
|
|
# ------------------------------------------------------------------
|
|
# POST /api/onboarding/complete
|
|
# ------------------------------------------------------------------
|
|
@http.route('/api/onboarding/complete', type='http', auth='none',
|
|
methods=['POST'], csrf=False)
|
|
@jwt_required
|
|
def onboarding_complete(self, **kw):
|
|
try:
|
|
body = _get_json_body()
|
|
user = request.env.user
|
|
|
|
learning_goal = body.get('learning_goal')
|
|
if not learning_goal:
|
|
return _error_response('learning_goal is required', 400)
|
|
|
|
skip_placement = body.get('skip_placement', False)
|
|
placement_decision = 'skip' if skip_placement else 'take_now'
|
|
|
|
# learning_style: accept as JSON array ["visual","audio"] or string
|
|
learning_style_raw = body.get('learning_style')
|
|
if isinstance(learning_style_raw, list):
|
|
valid_styles = {'visual', 'audio', 'reading', 'mixed'}
|
|
learning_style_raw = [s for s in learning_style_raw if s in valid_styles]
|
|
learning_style_json = json.dumps(learning_style_raw) if learning_style_raw else None
|
|
elif isinstance(learning_style_raw, str):
|
|
learning_style_json = json.dumps([learning_style_raw])
|
|
else:
|
|
learning_style_json = None
|
|
|
|
profile_vals = {
|
|
'user_id': user.id,
|
|
'learning_goal': learning_goal,
|
|
'target_band': body.get('target_band', 0.0),
|
|
'hours_per_week': body.get('hours_per_week', 0),
|
|
'study_mode': body.get('study_mode'),
|
|
'exam_date': body.get('exam_date'),
|
|
'placement_decision': placement_decision,
|
|
}
|
|
|
|
if learning_style_json:
|
|
profile_vals['learning_style'] = learning_style_json
|
|
|
|
# Assign B1 default level when placement test is skipped
|
|
if skip_placement:
|
|
profile_vals['cefr_level'] = 'b1'
|
|
|
|
Profile = request.env['encoach.student.profile'].sudo()
|
|
profile = Profile.search([('user_id', '=', user.id)], limit=1)
|
|
if profile:
|
|
profile.write(profile_vals)
|
|
else:
|
|
profile = Profile.create(profile_vals)
|
|
|
|
user.sudo().write({
|
|
'account_status': 'activated',
|
|
'first_login': False,
|
|
})
|
|
|
|
return _json_response({
|
|
'message': 'Onboarding complete',
|
|
'profile_id': profile.id,
|
|
'placement_decision': placement_decision,
|
|
})
|
|
|
|
except Exception as e:
|
|
_logger.exception('onboarding_complete failed')
|
|
return _error_response(str(e), 500)
|
|
|
|
# ------------------------------------------------------------------
|
|
# GET /api/config/captcha
|
|
# ------------------------------------------------------------------
|
|
@http.route('/api/config/captcha', type='http', auth='none',
|
|
methods=['GET'], csrf=False)
|
|
def captcha_config(self, **kw):
|
|
try:
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
return _json_response({
|
|
'provider': ICP.get_param('encoach.captcha_provider', 'recaptcha'),
|
|
'site_key': ICP.get_param('encoach.captcha_site_key', ''),
|
|
})
|
|
|
|
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)
|