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'
Welcome to EnCoach!
' f'Your verification code is: {otp_code}
' f'This code expires in 15 minutes.
', } request.env['mail.mail'].sudo().create(mail_values).send() except Exception as mail_err: _logger.warning('OTP email failed for %s: %s', email, mail_err) return _json_response({ 'message': 'Registration successful. Please verify your email.', 'user_id': user.id, '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'Your new verification code is: {otp_code}
' f'This code expires in 15 minutes.
', } request.env['mail.mail'].sudo().create(mail_values).send() except Exception as mail_err: _logger.warning('Resend OTP email failed for %s: %s', email, mail_err) return _json_response({'message': 'OTP resent'}) except Exception as e: _logger.exception('resend_otp failed') return _error_response(str(e), 500) # ------------------------------------------------------------------ # POST /api/reset/sendVerification # ------------------------------------------------------------------ @http.route('/api/reset/sendVerification', type='http', auth='public', methods=['POST'], csrf=False) def reset_send_verification(self, **kw): try: body = _get_json_body() email = (body.get('email') or '').strip().lower() if not email: return _error_response('email is required', 400) user = request.env['res.users'].sudo().search( [('login', '=', email)], limit=1) if not user: return _json_response({'message': 'If the email exists, a reset code has been sent.'}) otp_code = ''.join(random.choices(string.digits, k=6)) otp_hash = hashlib.sha256(otp_code.encode()).hexdigest() expires_at = fields.Datetime.now() + timedelta(minutes=15) request.env['encoach.otp'].sudo().create({ 'email': email, 'otp_hash': otp_hash, 'expires_at': expires_at, }) try: template = request.env.ref('encoach_signup.email_template_password_reset', raise_if_not_found=False) if template: template.sudo().with_context(otp_code=otp_code).send_mail(user.id, force_send=True) else: mail_values = { 'subject': 'EnCoach Password Reset Code', 'email_from': request.env['ir.config_parameter'].sudo().get_param( 'mail.catchall.email', 'noreply@encoach.com'), 'email_to': email, 'body_html': f'Your password reset code is: {otp_code}
' f'This code expires in 15 minutes.
', } request.env['mail.mail'].sudo().create(mail_values).send() except Exception as mail_err: _logger.warning('Password reset email failed for %s: %s', email, mail_err) _logger.info('Password reset OTP for %s: %s', email, otp_code) return _json_response({'message': 'If the email exists, a reset code has been sent.'}) except Exception as e: _logger.exception('reset_send_verification failed') return _error_response(str(e), 500) # ------------------------------------------------------------------ # POST /api/auth/invite/set-password # ------------------------------------------------------------------ @http.route('/api/auth/invite/set-password', type='http', auth='public', methods=['POST'], csrf=False) def invite_set_password(self, **kw): try: body = _get_json_body() token = body.get('token', '').strip() password = body.get('password', '') if not token or not password: return _error_response('token and password are required', 400) Invite = request.env['encoach.invite'].sudo() invite = Invite.search([('token', '=', token), ('used', '=', False)], limit=1) if not invite: return _error_response('Invalid or expired invitation token', 400) user = invite.user_id if not user: return _error_response('No user associated with this invitation', 400) user.sudo().write({'password': password}) invite.write({'used': True}) user.sudo().write({ 'account_status': 'activated', 'first_login': False, }) return _json_response({'message': 'Password set successfully'}) except Exception as e: _logger.exception('invite_set_password failed') return _error_response(str(e), 500) # ------------------------------------------------------------------ # GET /api/onboarding/goals # ------------------------------------------------------------------ @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)