# -*- coding: utf-8 -*- from odoo import http from odoo.http import request def normalize_phone_search(phone): if not phone: return [] digits = ''.join(c for c in phone if c.isdigit()) if not digits: return [] candidates = [digits] if digits.startswith('62'): candidates.append('0' + digits[2:]) candidates.append(digits[2:]) elif digits.startswith('0'): candidates.append('62' + digits[1:]) candidates.append(digits[1:]) else: candidates.append('0' + digits) candidates.append('62' + digits) return list(set(candidates)) class AppNotificationController(http.Controller): @http.route('/api/loyalty/fetch_notifications', type='jsonrpc', auth='user', methods=['POST'], csrf=False) def fetch_notifications(self, **kw): """ Endpoint for the Flutter app Background Task and In-App notification center. Returns image_128 (base64 thumbnail) for list display. The Flutter detail screen loads the full image via /web/image/ with session cookie. Body is now HTML from the rich text editor — returned as-is. """ import base64 as b64mod user = request.env.user partner = user.partner_id last_id = kw.get('last_id', 0) # Give them global notifications OR notifications tagged explicitly to them domain = [ ('id', '>', last_id), '|', ('partner_ids', 'in', [partner.id]), ('is_global', '=', True) ] notifications = request.env['mapan.app.notification'].sudo().search_read( domain, ['id', 'title', 'body', 'create_date', 'image_128'], order='create_date desc', limit=50 ) # Normalize image_128 bytes → base64 string for JSON transport for notif in notifications: img = notif.get('image_128') if img and isinstance(img, bytes): notif['image_128'] = img.decode('utf-8') elif not img: notif['image_128'] = None notif['has_image'] = bool(notif['image_128']) # body is HTML string — pass through as-is if notif.get('body') is False: notif['body'] = '' return { 'status': 'success', 'data': notifications } @http.route('/api/loyalty/cms_content', type='jsonrpc', auth='public', methods=['POST'], csrf=False) def fetch_cms_content(self, **kw): """ Public endpoint to fetch carousel slides and promo highlights for the Flutter app home screen. """ import base64 as b64mod from datetime import date today = date.today() # --- Carousel Slides --- carousel_domain = [('is_active', '=', True)] carousel_items = request.env['mapan.app.carousel'].sudo().search_read( carousel_domain, ['id', 'name', 'image', 'image_url', 'link_url', 'sequence'], order='sequence, id' ) for item in carousel_items: img = item.get('image') if img and isinstance(img, bytes): item['image'] = img.decode('utf-8') elif not img: item['image'] = None # --- Promo Highlights --- promo_domain = [('is_active', '=', True)] promos = request.env['mapan.app.promo'].sudo().search_read( promo_domain, ['id', 'name', 'body', 'image_128', 'date_start', 'date_end', 'sequence'], order='sequence, id' ) active_promos = [] for promo in promos: # Filter by validity date client-side after fetch ds = promo.get('date_start') de = promo.get('date_end') if ds and ds > today: continue if de and de < today: continue img = promo.get('image_128') if img and isinstance(img, bytes): promo['image_128'] = img.decode('utf-8') elif not img: promo['image_128'] = None # Convert date objects to string promo['date_start'] = str(promo['date_start']) if promo.get('date_start') else None promo['date_end'] = str(promo['date_end']) if promo.get('date_end') else None if promo.get('body') is False: promo['body'] = '' active_promos.append(promo) return { 'status': 'success', 'carousel': carousel_items, 'promos': active_promos, } @http.route('/api/loyalty/app_config', type='jsonrpc', auth='public', methods=['POST'], csrf=False) def fetch_app_config(self, **kw): """ Public endpoint to fetch app configuration (About Us URL, Contact Us URL, Branding & Theme). """ config = request.env['mapan.app.config'].sudo().search([], limit=1) if not config: config = request.env['mapan.app.config'].sudo().create({ 'name': 'App Configuration', 'primary_color': '#C62828', 'secondary_color': '#FF8F00' }) brand_logo = config.brand_logo if brand_logo and isinstance(brand_logo, bytes): brand_logo = brand_logo.decode('utf-8') return { 'status': 'success', 'about_us_url': config.about_us_url or '', 'contact_us_url': config.contact_us_url or '', 'brand_logo': brand_logo or '', 'primary_color': config.primary_color or '#C62828', 'secondary_color': config.secondary_color or '#FF8F00', } @http.route('/api/loyalty/order_history', type='jsonrpc', auth='user', methods=['POST'], csrf=False) def fetch_order_history(self, **kw): """ Authenticated endpoint to fetch loyalty point history for the current user. Queries loyalty.history which tracks earn/spend events. Positive points = earned, negative points = spent. """ user = request.env.user partner = user.partner_id # Get all loyalty cards for this partner cards = request.env['loyalty.card'].sudo().search([('partner_id', '=', partner.id)]) card_ids = cards.ids if not card_ids: return {'status': 'success', 'data': []} history_records = request.env['loyalty.history'].sudo().search_read( [('card_id', 'in', card_ids)], ['id', 'card_id', 'points', 'date', 'order_id'], order='date desc', limit=100 ) result = [] for rec in history_records: points = rec.get('points') or 0 point_type = 'earn' if points >= 0 else 'spend' order_ref = '' order_id = rec.get('order_id') if order_id and isinstance(order_id, (list, tuple)) and len(order_id) > 1: order_ref = str(order_id[1]) elif order_id: order_ref = str(order_id) result.append({ 'id': rec['id'], 'date': str(rec.get('date') or ''), 'points': round(float(points), 2), 'type': point_type, 'order_ref': order_ref, 'card_id': rec['card_id'][0] if isinstance(rec.get('card_id'), (list, tuple)) else rec.get('card_id'), }) return {'status': 'success', 'data': result} @http.route('/api/loyalty/branches', type='jsonrpc', auth='public', methods=['POST'], csrf=False) def fetch_branches(self, **kw): """ Public endpoint for the Flutter app to get branches without exposing API keys. Includes latitude/longitude for geolocation-based distance sorting on the client. """ try: branches = request.env['res.company'].sudo().search_read( [('parent_id', '!=', False)], ['name', 'street', 'city', 'phone', 'partner_latitude', 'partner_longitude'], limit=50 ) return {'status': 'success', 'data': branches} 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 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([ '|', ('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.'} # Verify birth date if set on the partner if partner.birth_date: if not birth_date: return {'status': 'error', 'message': 'Birth Date is required for verification.'} 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 }) portal_group = request.env.ref('base.group_portal') if portal_group not in user.group_ids: user.sudo().write({'group_ids': [(4, portal_group.id)]}) else: portal_template = request.env.ref('base.template_portal_user_id') existing_user = request.env['res.users'].sudo().with_context(active_test=False).search([('login', '=', phone)], limit=1) if existing_user: return {'status': 'error', 'message': 'A user with this phone number login already exists.'} try: user = portal_template.sudo().with_context(no_reset_password=True).copy({ 'name': partner.name, 'login': phone, 'email': email, 'partner_id': partner.id, 'active': False, }) # Write company, password and activate user.sudo().write({ 'company_id': request.env.company.id, 'company_ids': [(6, 0, [request.env.company.id])], 'password': password, 'active': True, }) # Ensure portal group portal_group = request.env.ref('base.group_portal') if portal_group not in user.group_ids: user.sudo().write({'group_ids': [(4, portal_group.id)]}) except Exception as e: return {'status': 'error', 'message': f'Failed to create user: {str(e)}'} return {'status': 'success', 'message': 'Account activated successfully. You can now login.'} @http.route('/api/loyalty/signup_member', type='jsonrpc', auth='public', methods=['POST'], csrf=False) def signup_member(self, **kw): """ Public endpoint to sign up a new member. """ 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 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) 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.'} try: partner_vals = { 'name': name, 'phone': phone, 'email': email, 'birth_date': birth_date, 'gender': gender, 'company_id': request.env.company.id, } partner = request.env['res.partner'].sudo().create(partner_vals) portal_template = request.env.ref('base.template_portal_user_id') user = portal_template.sudo().with_context(no_reset_password=True).copy({ 'name': partner.name, 'login': phone, 'email': email, 'partner_id': partner.id, 'active': False, }) # Write company, password and activate user.sudo().write({ 'company_id': request.env.company.id, 'company_ids': [(6, 0, [request.env.company.id])], 'password': password, 'active': True, }) # Ensure portal group portal_group = request.env.ref('base.group_portal') if portal_group not in user.group_ids: user.sudo().write({'group_ids': [(4, portal_group.id)]}) return {'status': 'success', 'message': 'Account registered successfully.'} 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): """ Authenticated endpoint to permanently delete/anonymize a member account. """ user = request.env.user password = kw.get('password') if not password: return {'status': 'error', 'message': 'Password is required.'} try: user.with_user(user)._check_credentials({'type': 'password', 'password': password}, {'interactive': True}) except Exception: return {'status': 'error', 'message': 'Incorrect password.'} partner = user.partner_id user.sudo().write({'active': False}) partner.sudo().write({ 'name': f'Deleted Member {partner.id}', 'phone': False, 'email': False, 'birth_date': False, 'gender': False, 'active': False, }) partner.loyalty_card_ids.sudo().write({ 'active': False, 'points': 0.0 }) return {'status': 'success', 'message': 'Account deleted successfully.'}