308 lines
14 KiB
Python
308 lines
14 KiB
Python
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,
|
|
}
|
|
}
|