diff --git a/controllers/main.py b/controllers/main.py index d8243a4..4073256 100644 --- a/controllers/main.py +++ b/controllers/main.py @@ -67,17 +67,117 @@ class AppNotificationController(http.Controller): except Exception as e: return {'status': 'error', 'message': str(e)} + @http.route('/api/loyalty/send_otp', type='jsonrpc', auth='public', methods=['POST'], csrf=False) + def send_otp(self, **kw): + """ + Public endpoint to send a verification OTP to an email address. + """ + email = kw.get('email') + phone = kw.get('phone') + phone_or_email = kw.get('phone_or_email') + otp_type = kw.get('type') + + if not otp_type: + return {'status': 'error', 'message': 'OTP type is required.'} + + if otp_type == 'signup': + if not email or not phone: + return {'status': 'error', 'message': 'Email and Phone are required for sign-up.'} + # Check if partner or user already exists + candidates = normalize_phone_search(phone) + existing_partner = request.env['res.partner'].sudo().search([ + '|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates) + ], limit=1) + if existing_partner: + return {'status': 'error', 'message': 'A member with this phone number is already registered.'} + + existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([ + '|', ('login', '=', phone), ('login', 'in', candidates), ('email', '=', email) + ], limit=1) + if existing_user: + return {'status': 'error', 'message': 'A user with this phone number or email already exists.'} + + elif otp_type == 'activation': + if not email or not phone: + return {'status': 'error', 'message': 'Email and Phone are required for activation.'} + # Check if partner exists + candidates = normalize_phone_search(phone) + partner = request.env['res.partner'].sudo().search([ + '|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates) + ], limit=1) + if not partner: + return {'status': 'error', 'message': 'Member with this phone number not found. Please contact support or register.'} + + # Check if user already active + user = request.env['res.users'].sudo().with_context(active_test=False).search([('partner_id', '=', partner.id)], limit=1) + if user and user.active: + return {'status': 'error', 'message': 'Account is already active. Please login or reset password.'} + + # Check if email is already taken by another active user + existing_email_user = request.env['res.users'].sudo().search([('email', '=', email), ('partner_id', '!=', partner.id)], limit=1) + if existing_email_user: + return {'status': 'error', 'message': 'This email address is already registered to another account.'} + + elif otp_type == 'reset_password': + if not phone_or_email: + return {'status': 'error', 'message': 'Phone number or Email is required.'} + + # Find user + user = None + if '@' in phone_or_email: + user = request.env['res.users'].sudo().search([('email', '=', phone_or_email)], limit=1) + else: + candidates = normalize_phone_search(phone_or_email) + user = request.env['res.users'].sudo().search([('login', 'in', candidates)], limit=1) + if not user: + partner = request.env['res.partner'].sudo().search([ + '|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates) + ], limit=1) + if partner: + user = request.env['res.users'].sudo().search([('partner_id', '=', partner.id)], limit=1) + + if not user: + return {'status': 'error', 'message': 'User account not found.'} + + email = user.email + if not email or '@miemapan.com' in email or '@' not in email: + return {'status': 'error', 'message': 'Your account does not have a valid email set. Please contact support.'} + + phone = user.login + + try: + request.env['loyalty.verification.otp'].sudo().generate_otp(email=email, phone=phone, otp_type=otp_type) + # Mask email for privacy response + parts = email.split('@') + masked_email = parts[0][:2] + '*' * (len(parts[0]) - 2) + '@' + parts[1] if len(parts[0]) > 2 else email + return { + 'status': 'success', + 'message': f'Verification code sent to {masked_email}.', + 'email': email + } + except Exception as e: + return {'status': 'error', 'message': f'Failed to send verification code: {str(e)}'} + @http.route('/api/loyalty/activate_account', type='jsonrpc', auth='public', methods=['POST'], csrf=False) def activate_account(self, **kw): """ Public endpoint to activate/set password for an existing partner. """ phone = kw.get('phone') + email = kw.get('email') password = kw.get('password') birth_date = kw.get('birth_date') + otp = kw.get('otp') - if not phone or not password: - return {'status': 'error', 'message': 'Phone and Password are required.'} + if not phone or not password or not email or not otp: + return {'status': 'error', 'message': 'Phone, Email, Password, and Verification Code (OTP) are required.'} + + # Verify OTP first + otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp( + otp_code=otp, email=email, phone=phone, otp_type='activation' + ) + if not otp_verified: + return {'status': 'error', 'message': 'Invalid or expired verification code.'} candidates = normalize_phone_search(phone) partner = request.env['res.partner'].sudo().search([ @@ -94,12 +194,17 @@ class AppNotificationController(http.Controller): if str(partner.birth_date) != str(birth_date): return {'status': 'error', 'message': 'Birth Date does not match our records.'} + # Update email on partner + partner.sudo().write({'email': email}) + # Find or create user user = request.env['res.users'].sudo().with_context(active_test=False).search([('partner_id', '=', partner.id)], limit=1) if user: user.sudo().write({ 'company_id': request.env.company.id, 'company_ids': [(6, 0, [request.env.company.id])], + 'login': phone, + 'email': email, 'password': password, 'active': True }) @@ -116,7 +221,7 @@ class AppNotificationController(http.Controller): user = portal_template.sudo().with_context(no_reset_password=True).copy({ 'name': partner.name, 'login': phone, - 'email': partner.email or f"{phone}@miemapan.com", + 'email': email, 'partner_id': partner.id, 'active': False, }) @@ -143,12 +248,21 @@ class AppNotificationController(http.Controller): """ name = kw.get('name') phone = kw.get('phone') + email = kw.get('email') birth_date = kw.get('birth_date') gender = kw.get('gender') password = kw.get('password') + otp = kw.get('otp') - if not name or not phone or not password: - return {'status': 'error', 'message': 'Name, Phone, and Password are required.'} + if not name or not phone or not email or not password or not otp: + return {'status': 'error', 'message': 'Name, Phone, Email, Password, and Verification Code (OTP) are required.'} + + # Verify OTP first + otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp( + otp_code=otp, email=email, phone=phone, otp_type='signup' + ) + if not otp_verified: + return {'status': 'error', 'message': 'Invalid or expired verification code.'} candidates = normalize_phone_search(phone) @@ -159,15 +273,16 @@ class AppNotificationController(http.Controller): return {'status': 'error', 'message': 'A member with this phone number is already registered.'} existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([ - '|', ('login', '=', phone), ('login', 'in', candidates) + '|', ('login', '=', phone), ('login', 'in', candidates), ('email', '=', email) ], limit=1) if existing_user: - return {'status': 'error', 'message': 'A user with this phone number login already exists.'} + return {'status': 'error', 'message': 'A user with this phone number or email already exists.'} try: partner_vals = { 'name': name, 'phone': phone, + 'email': email, 'birth_date': birth_date, 'gender': gender, 'company_id': request.env.company.id, @@ -178,7 +293,7 @@ class AppNotificationController(http.Controller): user = portal_template.sudo().with_context(no_reset_password=True).copy({ 'name': partner.name, 'login': phone, - 'email': f"{phone}@miemapan.com", + 'email': email, 'partner_id': partner.id, 'active': False, }) @@ -188,7 +303,6 @@ class AppNotificationController(http.Controller): 'company_ids': [(6, 0, [request.env.company.id])], 'password': password, 'active': True, - 'active': True, }) # Ensure portal group portal_group = request.env.ref('base.group_portal') @@ -199,6 +313,51 @@ class AppNotificationController(http.Controller): except Exception as e: return {'status': 'error', 'message': f'Registration failed: {str(e)}'} + @http.route('/api/loyalty/reset_password', type='jsonrpc', auth='public', methods=['POST'], csrf=False) + def reset_password(self, **kw): + """ + Public endpoint to reset user password using verification OTP. + """ + phone_or_email = kw.get('phone_or_email') + otp = kw.get('otp') + new_password = kw.get('password') + + if not phone_or_email or not otp or not new_password: + return {'status': 'error', 'message': 'Phone/Email, OTP, and New Password are required.'} + + # Find user + user = None + if '@' in phone_or_email: + user = request.env['res.users'].sudo().search([('email', '=', phone_or_email)], limit=1) + else: + candidates = normalize_phone_search(phone_or_email) + user = request.env['res.users'].sudo().search([('login', 'in', candidates)], limit=1) + if not user: + partner = request.env['res.partner'].sudo().search([ + '|', ('phone', 'in', candidates), ('phone_sanitized', 'in', candidates) + ], limit=1) + if partner: + user = request.env['res.users'].sudo().search([('partner_id', '=', partner.id)], limit=1) + + if not user: + return {'status': 'error', 'message': 'User account not found.'} + + # Verify OTP + otp_verified = request.env['loyalty.verification.otp'].sudo().verify_otp( + otp_code=otp, email=user.email, phone=user.login, otp_type='reset_password' + ) + if not otp_verified: + return {'status': 'error', 'message': 'Invalid or expired verification code.'} + + try: + user.sudo().write({ + 'password': new_password, + 'active': True + }) + return {'status': 'success', 'message': 'Password has been reset successfully.'} + except Exception as e: + return {'status': 'error', 'message': f'Failed to reset password: {str(e)}'} + @http.route('/api/loyalty/delete_account', type='jsonrpc', auth='user', methods=['POST'], csrf=False) def delete_account(self, **kw): """ diff --git a/models/__init__.py b/models/__init__.py index 85f12c8..44c88b8 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -2,4 +2,6 @@ from . import res_partner from . import app_notification from . import res_users from . import loyalty_card +from . import loyalty_verification_otp + diff --git a/models/loyalty_verification_otp.py b/models/loyalty_verification_otp.py new file mode 100644 index 0000000..7f4652a --- /dev/null +++ b/models/loyalty_verification_otp.py @@ -0,0 +1,97 @@ +# -*- coding: utf-8 -*- +import random +from datetime import datetime, timedelta +from odoo import api, fields, models + +class LoyaltyVerificationOtp(models.Model): + _name = 'loyalty.verification.otp' + _description = 'Loyalty Verification OTP' + _order = 'create_date desc' + + email = fields.Char(string='Email') + phone = fields.Char(string='Phone') + otp_code = fields.Char(string='OTP Code', required=True) + expiry_time = fields.Datetime(string='Expiry Time', required=True) + otp_type = fields.Selection([ + ('signup', 'Sign-up'), + ('activation', 'Activation'), + ('reset_password', 'Reset Password') + ], string='OTP Type', required=True) + is_verified = fields.Boolean(string='Is Verified', default=False) + + @api.model + def generate_otp(self, email=None, phone=None, otp_type=None): + if not otp_type: + raise ValueError("OTP type is required.") + + # Generate a 6-digit random code + otp_code = str(random.randint(100000, 999999)) + expiry_time = fields.Datetime.now() + timedelta(minutes=15) + + # Create record + otp_record = self.sudo().create({ + 'email': email, + 'phone': phone, + 'otp_code': otp_code, + 'expiry_time': expiry_time, + 'otp_type': otp_type, + }) + + # Send Email + if email: + subject = "Verification Code - Mie Mapan" + if otp_type == 'reset_password': + subject = "Reset Password Verification Code - Mie Mapan" + elif otp_type == 'activation': + subject = "Account Activation Verification Code - Mie Mapan" + + body_html = f""" +
+
+
+ Mie Mapan +
+

Hi,

+

Thank you for choosing Mie Mapan. Use the following OTP to complete your request. This OTP is valid for 15 minutes.

+

{otp_code}

+

Regards,
Mie Mapan Team

+
+
+

Mie Mapan Inc

+
+
+
+ """ + + mail_values = { + 'subject': subject, + 'body_html': body_html, + 'email_to': email, + 'email_from': self.env.user.email or 'no-reply@miemapan.com', + } + # Send using sudo context + self.env['mail.mail'].sudo().create(mail_values).send() + + return otp_record + + @api.model + def verify_otp(self, otp_code, email=None, phone=None, otp_type=None): + if not otp_code: + return False + + domain = [ + ('otp_code', '=', otp_code), + ('otp_type', '=', otp_type), + ('is_verified', '=', False), + ('expiry_time', '>=', fields.Datetime.now()) + ] + if email: + domain.append(('email', '=', email)) + if phone: + domain.append(('phone', '=', phone)) + + record = self.sudo().search(domain, order='create_date desc', limit=1) + if record: + record.write({'is_verified': True}) + return True + return False diff --git a/security/ir.model.access.csv b/security/ir.model.access.csv index 156b5c8..50a4360 100644 --- a/security/ir.model.access.csv +++ b/security/ir.model.access.csv @@ -7,3 +7,6 @@ access_loyalty_program_portal,loyalty.program.portal,loyalty.model_loyalty_progr access_loyalty_history_portal,loyalty.history.portal,loyalty.model_loyalty_history,base.group_portal,1,0,0,0 access_loyalty_reward_portal,loyalty.reward.portal,loyalty.model_loyalty_reward,base.group_portal,1,0,0,0 access_loyalty_rule_portal,loyalty.rule.portal,loyalty.model_loyalty_rule,base.group_portal,1,0,0,0 +access_loyalty_verification_otp_admin,loyalty.verification.otp.admin,model_loyalty_verification_otp,base.group_system,1,1,1,1 +access_loyalty_verification_otp_portal,loyalty.verification.otp.portal,model_loyalty_verification_otp,base.group_portal,1,1,1,1 +