494 lines
22 KiB
Python

from odoo import models, fields, api, _
from odoo.exceptions import UserError
import requests
import json
import datetime
import logging
import math
_logger = logging.getLogger(__name__)
_GEOCODE_CACHE = {}
def _haversine_distance(lat1, lon1, lat2, lon2):
"""Calculate great circle distance between two lat/lon coordinates in kilometers"""
R = 6371.0 # Earth radius in km
dlat = math.radians(lat2 - lat1)
dlon = math.radians(lon2 - lon1)
a = (math.sin(dlat / 2.0) ** 2 +
math.cos(math.radians(lat1)) * math.cos(math.radians(lat2)) *
math.sin(dlon / 2.0) ** 2)
c = 2.0 * math.atan2(math.sqrt(a), math.sqrt(1.0 - a))
return R * c
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='eats.deliveries', help="OAuth scope for Uber Direct, e.g., 'eats.deliveries'.")
# Delivery Pricing Source Selection - Default to Live Uber Direct API
delivery_pricing_source = fields.Selection([
('uber', 'Live Uber Direct API Only'),
('distance', 'Manual Distance-Based Calculation (Optional Fallback)'),
('uber_fallback', 'Live Uber API with Distance Fallback')
], string='Delivery Fee Source', default='uber', required=True,
help="Delivery charges and delivery availability are fetched strictly from Uber Direct API.")
# Dynamic Distance and Radius Settings (Optional backup)
restaurant_latitude = fields.Float(string='Restaurant Latitude', digits=(10, 7), default=0.0,
help="Latitude of the restaurant (leave 0.0 to automatically fetch from Company Settings).")
restaurant_longitude = fields.Float(string='Restaurant Longitude', digits=(10, 7), default=0.0,
help="Longitude of the restaurant (leave 0.0 to automatically fetch from Company Settings).")
max_delivery_radius = fields.Float(string='Max Delivery Radius (km)', default=25.0,
help="Maximum allowed delivery distance from restaurant in kilometers.")
base_delivery_fee = fields.Float(string='Base Delivery Fee ($)', default=4.99,
help="Delivery fee applied for orders within base distance.")
base_distance_km = fields.Float(string='Base Distance (km)', default=3.0,
help="Distance included in the base delivery fee.")
per_km_fee = fields.Float(string='Per KM Fee ($/km)', default=1.00,
help="Additional fee per kilometer beyond the base distance.")
fallback_delivery_fee = fields.Float(string='Fallback Delivery Fee ($)', default=4.99,
help="Standard delivery fee applied if Uber API is unavailable and distance calculation is used.")
enable_fallback_on_error = fields.Boolean(string='Enable Fallback on API Error', default=False,
help="If enabled, distance-based calculation will be used when Uber API returns an error.")
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_or_create_delivery_product(self):
"""Find or create the default Uber Delivery Fee service product"""
self.ensure_one()
if self.delivery_product_id:
return self.delivery_product_id
Product = self.env['product.product'].sudo()
product = Product.search([('name', '=', 'Uber Delivery Fee')], limit=1)
if not product:
product = Product.search([('name', 'ilike', 'Delivery Fee'), ('type', '=', 'service')], limit=1)
if not product:
product = Product.search([('name', 'ilike', 'Delivery'), ('type', '=', 'service')], limit=1)
if not product:
product = Product.create({
'name': 'Uber Delivery Fee',
'type': 'service',
'list_price': 0.0,
'available_in_pos': True,
'invoice_policy': 'order',
})
self.sudo().write({'delivery_product_id': product.id})
return product
def write(self, vals):
# Invalidate cached token whenever credentials or scope change
if any(k in vals for k in ['client_id', 'client_secret', 'scope', 'environment']):
vals['access_token'] = False
vals['token_expiry'] = False
return super().write(vals)
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 'eats.deliveries'
# 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 with eats.deliveries scope for live Uber Direct delivery"""
self.ensure_one()
current_scope = (self.scope or '').strip()
if not current_scope or current_scope != 'eats.deliveries':
# Default to required delivery scope
self.write({'scope': 'eats.deliveries'})
try:
token = self._get_access_token()
message = "Connection Successful! Token retrieved successfully using scope 'eats.deliveries'. Uber Direct delivery quotes and couriers are ready."
msg_type = "success"
return self._return_notification(message, msg_type)
except UserError as e:
err_str = str(e)
if "invalid_scope" in err_str.lower() or "scope" in err_str.lower():
message = (
"Uber Direct Permission Required: Your Uber Client ID requires the 'eats.deliveries' scope.\n\n"
"How to resolve:\n"
"1. Go to https://developer.uber.com and log in.\n"
"2. Open your registered application.\n"
"3. In 'Products', add or request the 'Uber Direct' product to activate 'eats.deliveries'.\n"
"Once enabled by Uber, live delivery quotes and dispatches will connect immediately."
)
msg_type = "warning"
else:
message = f"Connection Failed: {err_str}"
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 _geocode_address(self, street, city, state, zip_code, country):
"""Geocode an address to (lat, lon) using OpenStreetMap Nominatim with memory cache"""
cache_key = f"{zip_code}_{city}_{street}_{country}".strip().lower()
if cache_key in _GEOCODE_CACHE:
return _GEOCODE_CACHE[cache_key]
headers = {'User-Agent': 'Dine360-Restaurant-Delivery/1.0 (delivery@dine360.com)'}
queries = []
# 1. Full address
full_addr = ", ".join(filter(None, [street, city, state, zip_code, country]))
if full_addr:
queries.append(full_addr)
# 2. Postal code + Country
if zip_code and country:
queries.append(f"{zip_code}, {country}")
# 3. Street + City + Country
if street and city and country:
queries.append(f"{street}, {city}, {country}")
# 4. City + State + Country
if city and country:
queries.append(f"{city}, {state or ''}, {country}".strip())
for q in queries:
try:
url = "https://nominatim.openstreetmap.org/search"
params = {'q': q, 'format': 'json', 'limit': 1}
resp = requests.get(url, params=params, headers=headers, timeout=5)
if resp.status_code == 200:
data = resp.json()
if data and len(data) > 0:
lat = float(data[0]['lat'])
lon = float(data[0]['lon'])
_GEOCODE_CACHE[cache_key] = (lat, lon)
return (lat, lon)
except Exception as e:
_logger.warning("Geocoding query '%s' error: %s", q, e)
return None
def _get_company_coordinates(self, company):
"""Dynamically get coordinates of the restaurant company from Settings without static defaults"""
if not company:
return None
# 1. Check if configured explicitly on uber.config
if self.restaurant_latitude and self.restaurant_longitude:
return (self.restaurant_latitude, self.restaurant_longitude)
# 2. Check if company partner already has coordinates
if company.partner_id and company.partner_id.partner_latitude and company.partner_id.partner_longitude:
return (company.partner_id.partner_latitude, company.partner_id.partner_longitude)
# 3. Dynamically geocode the company's real address from Settings
full_street = ", ".join(filter(None, [company.street, company.street2]))
coords = self._geocode_address(
street=full_street,
city=company.city or '',
state=company.state_id.name or company.state_id.code or '',
zip_code=company.zip or '',
country=company.country_id.name or 'Canada'
)
if coords and company.partner_id:
try:
company.partner_id.sudo().write({
'partner_latitude': coords[0],
'partner_longitude': coords[1]
})
except Exception:
pass
return coords
def calculate_distance_quote(self, dropoff_data, company=None):
"""Calculate distance and quote fee based on geographic coordinates"""
self.ensure_one()
if not dropoff_data:
return {'success': False, 'error': _("No delivery address provided.")}
street = (dropoff_data.get('street') or '').strip()
street2 = (dropoff_data.get('street2') or '').strip()
full_street = f"{street} {street2}".strip()
city = (dropoff_data.get('city') or '').strip()
state = (dropoff_data.get('state') or '').strip()
zip_code = (dropoff_data.get('zip') or '').strip()
country = (dropoff_data.get('country') or '').strip()
# Check country against restaurant company country
if company and company.country_id:
company_country = company.country_id
if country and country.lower() not in [company_country.name.lower(), company_country.code.lower()]:
return {
'success': False,
'error': _("Delivery is not available outside %s. Please select Store Pickup or enter a local delivery address.") % company_country.name
}
# Restaurant coordinates fetched dynamically from Company in Settings
coords_rest = self._get_company_coordinates(company)
if not coords_rest:
return {
'success': False,
'error': _("Restaurant address in Settings -> Companies is missing or cannot be located.")
}
rest_lat, rest_lon = coords_rest
# Geocode dropoff
coords = self._geocode_address(full_street, city, state, zip_code, country)
if not coords:
_logger.warning("Could not geocode customer address: %s, %s, %s, %s", full_street, city, zip_code, country)
if zip_code and zip_code.isdigit() and len(zip_code) == 6:
return {
'success': False,
'error': _("The postal code '%s' is not valid for Canadian delivery. Please check your address or select Store Pickup.") % zip_code
}
return {
'success': False,
'error': _("Unable to verify delivery address location. Please check your street and postal code, or select Store Pickup.")
}
cust_lat, cust_lon = coords
distance_km = _haversine_distance(rest_lat, rest_lon, cust_lat, cust_lon)
_logger.info("Delivery distance to %s, %s: %.2f km (Max radius: %.1f km)", city, zip_code, distance_km, self.max_delivery_radius)
if distance_km > self.max_delivery_radius:
return {
'success': False,
'distance_km': round(distance_km, 1),
'error': _("This address is outside our delivery area (%.1f km away. Maximum delivery radius is %.0f km). Please select Store Pickup.") % (distance_km, self.max_delivery_radius)
}
# Calculate fee
if distance_km <= self.base_distance_km:
fee = self.base_delivery_fee
else:
fee = self.base_delivery_fee + (distance_km - self.base_distance_km) * self.per_km_fee
fee = round(max(fee, 0.0), 2)
return {
'success': True,
'fee_amount': fee,
'distance_km': round(distance_km, 1),
'currency': 'CAD',
'is_fallback': True,
'is_distance_based': True,
'quote_id': f"DIST_{int(datetime.datetime.now().timestamp())}"
}
def get_uber_quote(self, pickup_address, dropoff_address, items=None, dropoff_data=None, company=None):
"""Get delivery quote from Uber API or selected delivery pricing source"""
self.ensure_one()
pricing_source = self.delivery_pricing_source or 'uber'
# If manually configured to distance calculation only
if pricing_source == 'distance':
if dropoff_data:
return self.calculate_distance_quote(dropoff_data, company)
return {'success': False, 'error': _("Delivery address missing.")}
customer_id = self.customer_id
if not customer_id:
if pricing_source == 'uber_fallback' and dropoff_data:
return self.calculate_distance_quote(dropoff_data, company)
return {'success': False, 'error': _("Uber Customer ID is missing in configuration.")}
# Ensure at least one dummy item if none provided
if not items:
items = [{
"name": "Food Delivery",
"quantity": 1,
"size": "small"
}]
payload = {
"pickup_address": pickup_address,
"dropoff_address": dropoff_address,
"manifest_items": items
}
try:
access_token = self._get_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'
}
_logger.info("Uber Direct Payload: %s", json.dumps(payload, indent=2))
response = requests.post(api_url, headers=headers, json=payload)
_logger.info("Uber Direct Raw Response (%s): %s", response.status_code, response.text)
if response.status_code != 200:
# Log detailed error for debugging
_logger.error("Uber Quote Error: %s - %s", response.status_code, response.text)
data = {}
try:
data = response.json()
except:
pass
# Construct descriptive error message
msg = data.get('message', 'Uber API Error')
if data.get('errors'):
details = " ".join([e.get('message', '') for e in data['errors']])
if details:
msg = f"{msg} {details}"
code = data.get('code', '')
# 1. Scope missing error
if response.status_code == 401 or 'eats.deliveries' in msg.lower() or 'unauthorized' in code.lower():
scope_err = _(
"Uber Direct Scope Required: Your Uber Client ID requires the 'eats.deliveries' permission. "
"Please go to your Uber Developer Dashboard (https://developer.uber.com), "
"open your application, and enable the 'Uber Direct' product to activate live quotes."
)
if pricing_source == 'uber_fallback' and dropoff_data:
dist_result = self.calculate_distance_quote(dropoff_data, company)
dist_result['warning'] = scope_err
return dist_result
return {
'success': False,
'error': scope_err,
'code': 'unauthorized',
'raw_error': data
}
# 2. Out of range / undeliverable error from Uber (Live coverage check)
if any(x in code.lower() or x in msg.lower() for x in ['out_of_range', 'out of range', 'outside', 'undeliverable', 'address_undeliverable', 'coverage', 'unserviceable']):
return {
'success': False,
'error': _("Uber Direct: This address is outside Uber's delivery coverage area. Please choose Store Pickup."),
'code': code,
'raw_error': data
}
# 3. Fallback only if explicitly configured
if pricing_source == 'uber_fallback' and dropoff_data:
_logger.warning("Uber API failed (%s). Falling back to distance-based quote.", msg)
dist_result = self.calculate_distance_quote(dropoff_data, company)
if dist_result.get('warning') is None:
dist_result['warning'] = msg
return dist_result
return {
'success': False,
'error': f"Uber API Error: {msg}",
'code': code,
'raw_error': data
}
data = response.json()
# Standard fee is in cents
fee_cents = data.get('fee', 0)
return {
'success': True,
'quote_id': data.get('id'),
'fee_amount': float(fee_cents) / 100.0,
'currency': data.get('currency_code', 'CAD'),
'estimated_arrival': data.get('estimated_arrival'),
'is_fallback': False,
'is_uber_direct': True,
'raw': data
}
except Exception as e:
_logger.exception("Uber Quote API Exception")
if pricing_source == 'uber_fallback' and dropoff_data:
dist_result = self.calculate_distance_quote(dropoff_data, company)
if dist_result.get('warning') is None:
dist_result['warning'] = str(e)
return dist_result
return {'success': False, 'error': f"Uber Connection Error: {str(e)}"}
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,
}
}