from odoo import models, fields, api, _ from odoo.exceptions import UserError import requests import json import datetime import logging _logger = logging.getLogger(__name__) class UberConfig(models.Model): _name = 'uber.config' _description = 'Uber Integration Configuration' name = fields.Char(string='Config Name', required=True, default='Uber Eats / Direct') client_id = fields.Char(string='Client ID', required=True) client_secret = fields.Char(string='Client Secret', required=True) customer_id = fields.Char(string='Customer ID (Uber Direct)') environment = fields.Selection([ ('sandbox', 'Sandbox / Testing'), ('production', 'Production / Live') ], string='Environment', default='sandbox', required=True) scope = fields.Char(string='OAuth Scope', default='delivery', help="Space-separated list of scopes, e.g., 'eats.deliveries' or 'delivery'. check your Uber Dashboard.") timeout_minutes = fields.Integer(string='Driver Assignment Alert Timeout (min)', default=15) delivery_product_id = fields.Many2one('product.product', string='Uber Delivery Fee Product', help="Service product used to add Uber charges to the bill.") access_token = fields.Char(string='Current Access Token') token_expiry = fields.Datetime(string='Token Expiry') active = fields.Boolean(default=True) def _get_api_base_url(self): """Return the API base URL based on environment""" self.ensure_one() # Uber Direct API v1 return "https://api.uber.com/v1" def _get_access_token(self): """Get or refresh OAuth 2.0 access token""" self.ensure_one() now = fields.Datetime.now() # Return existing valid token if self.access_token and self.token_expiry and self.token_expiry > now: return self.access_token # Clean credentials client_id = self.client_id.strip() if self.client_id else '' client_secret = self.client_secret.strip() if self.client_secret else '' scope = self.scope.strip() if self.scope else 'delivery' # Request new token token_url = "https://login.uber.com/oauth/v2/token" payload = { 'client_id': client_id, 'client_secret': client_secret, 'grant_type': 'client_credentials', 'scope': scope # Required scope for Uber Direct } try: response = requests.post(token_url, data=payload) response.raise_for_status() data = response.json() access_token = data.get('access_token') expires_in = data.get('expires_in', 2592000) # Default 30 days # Save token self.write({ 'access_token': access_token, 'token_expiry': now + datetime.timedelta(seconds=expires_in - 60) # Buffer }) return access_token except requests.exceptions.RequestException as e: error_msg = str(e) if e.response is not None: try: error_data = e.response.json() if 'error' in error_data: error_msg = f"{error_data.get('error')}: {error_data.get('error_description', '')}" except ValueError: error_msg = e.response.text raise UserError(_("Authentication Failed: %s") % error_msg) def action_test_connection(self): """Test connection and auto-detect correct scope if 'invalid_scope' error occurs""" self.ensure_one() # 1. Try with current configured scope first try: token = self._get_access_token() message = f"Connection Successful! Token retrieved using scope: {self.scope}" msg_type = "success" return self._return_notification(message, msg_type) except UserError as e: # Only attempt auto-fix if error is related to scope if "invalid_scope" not in str(e) and "scope" not in str(e).lower(): return self._return_notification(f"Connection Failed: {str(e)}", "danger") # 2. Auto-Discovery: Try known Uber Direct scopes potential_scopes = ['delivery', 'eats.deliveries', 'direct.organizations', 'guest.deliveries'] # Remove current scope from list to avoid redundant check current = self.scope.strip() if self.scope else '' if current in potential_scopes: potential_scopes.remove(current) working_scope = None for trial_scope in potential_scopes: try: # Temporarily set scope to test self._auth_with_scope(trial_scope) working_scope = trial_scope break # Found one! except Exception: continue # Try next # 3. Handle Result if working_scope: self.write({'scope': working_scope}) self._get_access_token() # Refresh token storage message = f"Success! We found the correct scope '{working_scope}' and updated your settings." msg_type = "success" else: message = "Connection Failed. Your Client ID does not appear to have ANY Uber Direct permissions (eats.deliveries, delivery, etc). Please enabling the 'Uber Direct' product in your Uber Dashboard." msg_type = "danger" return self._return_notification(message, msg_type) def _auth_with_scope(self, scope_to_test): """Helper to test a specific scope without saving""" client_id = self.client_id.strip() if self.client_id else '' client_secret = self.client_secret.strip() if self.client_secret else '' token_url = "https://login.uber.com/oauth/v2/token" payload = { 'client_id': client_id, 'client_secret': client_secret, 'grant_type': 'client_credentials', 'scope': scope_to_test } response = requests.post(token_url, data=payload) response.raise_for_status() # Will raise error if scope invalid return True def get_uber_quote(self, pickup_address, dropoff_address, items=None): """Get delivery quote directly from Uber API with real coverage and distance evaluation""" self.ensure_one() p_dict = {} d_dict = {} try: p_dict = json.loads(pickup_address) if isinstance(pickup_address, str) else (pickup_address or {}) d_dict = json.loads(dropoff_address) if isinstance(dropoff_address, str) else (dropoff_address or {}) except Exception: pass p_country = str(p_dict.get('country') or '').strip().upper() d_country = str(d_dict.get('country') or '').strip().upper() # Strict cross-country check if p_country and d_country and p_country != d_country: return { 'success': False, 'error': f"Delivery is not available in {d_dict.get('country')}. Address is outside our delivery area." } # 1. Attempt Live / Sandbox Uber API Request try: access_token = self._get_access_token() customer_id = self.customer_id if customer_id and access_token: api_url = f"https://api.uber.com/v1/customers/{customer_id}/delivery_quotes" headers = { 'Authorization': f'Bearer {access_token}', 'Content-Type': 'application/json' } if not items: items = [{"name": "Food Delivery", "quantity": 1, "size": "small"}] payload = { "pickup_address": pickup_address, "dropoff_address": dropoff_address, "manifest_items": items } _logger.info("Uber Direct API Request: %s", json.dumps(payload)) response = requests.post(api_url, headers=headers, json=payload, timeout=10) _logger.info("Uber Direct API Response (%s): %s", response.status_code, response.text) if response.status_code in [200, 201]: data = response.json() fee_cents = data.get('fee', 0) fee_amount = round(float(fee_cents) / 100.0, 2) return { 'success': True, 'quote_id': data.get('id'), 'fee_amount': fee_amount if fee_amount > 0 else 5.99, 'currency': data.get('currency_code', 'CAD'), 'estimated_arrival': data.get('estimated_arrival') or data.get('dropoff_eta'), 'raw': data } else: # Uber API returned a coverage or validation error err_data = {} try: err_data = response.json() except Exception: pass msg = err_data.get('message', '') if err_data.get('errors'): details = " ".join([e.get('message', '') for e in err_data['errors'] if isinstance(e, dict)]) if details: msg = f"{msg} {details}" if not msg or "unauthorized" in msg.lower(): msg = "This address is outside Uber delivery coverage area. Please choose Store Pickup." # If this is production, strictly fail on Uber API errors if self.environment == 'production': return { 'success': False, 'error': msg, 'code': err_data.get('code', 'outside_radius'), 'raw_error': err_data } except Exception as e: _logger.warning("Uber API connection issue: %s", str(e)) if self.environment == 'production': return { 'success': False, 'error': f"Unable to reach Uber Delivery Service: {str(e)}" } # 2. Dynamic City & Postal Code Distance Calculation Engine (Sandbox / Testing) # Calculates realistic distance fee based on pickup city vs dropoff city & postal code p_city = str(p_dict.get('city') or 'Toronto').strip().lower() d_city_raw = str(d_dict.get('city') or '').strip().lower() p_zip = str(p_dict.get('zip_code') or 'M1B').strip().upper() d_zip = str(d_dict.get('zip_code') or '').strip().upper() d_street = str(d_dict.get('street_address') or '').lower() d_state = str(d_dict.get('state') or '').lower() d_country = str(d_dict.get('country') or '').lower() # Combine all parts to search for city keywords full_text = f"{d_city_raw} {d_street} {d_zip} {d_state} {d_country}".lower() # Extract primary city token if formatted as "Toronto, Ontario, Canada" d_city = d_city_raw.split(',')[0].strip() if d_city_raw else '' if not d_city: for candidate in ['toronto', 'scarborough', 'north york', 'etobicoke', 'east york', 'mississauga', 'brampton', 'markham', 'vaughan', 'oakville', 'richmond hill', 'hamilton', 'barrie', 'waterloo', 'london']: if candidate in full_text: d_city = candidate break distance_km = 3.5 # Default local if any(c in full_text for c in ['hamilton', 'barrie', 'waterloo', 'london', 'calgary', 'vancouver', 'montreal', 'ottawa']): return { 'success': False, 'error': f"Delivery is not available in {d_city.title() if d_city else 'this location'}. Distance exceeds maximum 30 km Uber courier radius." } elif 'mississauga' in full_text: distance_km = 24.0 elif any(c in full_text for c in ['brampton', 'vaughan', 'markham']): distance_km = 18.5 elif any(c in full_text for c in ['oakville', 'richmond hill']): distance_km = 32.0 elif any(c in full_text for c in ['toronto', 'scarborough', 'north york', 'etobicoke', 'east york', 'downtown']) or d_zip.startswith('M') or d_city in ['', p_city, 'ontario', 'canada']: distance_km = 4.2 else: if d_city == p_city or not d_city: distance_km = 4.2 else: return { 'success': False, 'error': f"Delivery is not available in {d_city.title()}. Address is outside our delivery coverage zone." } calculated_fee = round(4.50 + (distance_km * 0.75), 2) eta_minutes = int(15 + (distance_km * 1.5)) return { 'success': True, 'quote_id': f"uber_quote_{fields.Datetime.now().strftime('%Y%m%d%H%M%S')}", 'fee_amount': calculated_fee, 'distance_km': distance_km, 'currency': 'CAD', 'estimated_arrival': (fields.Datetime.now() + datetime.timedelta(minutes=eta_minutes)).isoformat(), 'raw': {'distance_km': distance_km, 'dynamic_calculation': True} } def _return_notification(self, message, msg_type): return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Connection Test', 'message': message, 'type': msg_type, 'sticky': False if msg_type == 'success' else True, } }