Files
enterprise-addons/pos_urban_piper/models/pos_urban_piper_request.py
T

545 lines
25 KiB
Python

import logging
import requests
import json
from datetime import datetime
from odoo import _, Command
from odoo.tools import html2plaintext, format_duration
from odoo.tools.urls import urljoin as url_join
from urllib.parse import quote
_logger = logging.getLogger(__name__)
EVENT_TYPES = [
'store_creation', 'store_action', 'inventory_update', 'item_state_toggle', 'order_placed', 'order_status_update', 'rider_status_update'
]
UP_LANGUAGES = ['hi', 'ar', 'ja', 'pt', 'fr', 'es']
class UrbanPiperClient:
def __init__(self, config):
"""
Initial parameters for making api requests.
"""
self.config = config
self.session = requests.Session()
self.db_uuid = self.config.env['ir.config_parameter'].sudo().get_param('database.uuid')
def _make_api_request(self, endpoint, method='POST', data=None, timeout=10):
"""
Make an api call, return response for multiple api requests of urban piper.
"""
headers = {
'Authorization': f'apikey {self.config.env.company.sudo().pos_urbanpiper_username}:'
f'{self.config.env.company.sudo().pos_urbanpiper_apikey}',
'Content-Type': 'application/json'
}
urbanpiper_url = 'https://pos-int.urbanpiper.com/' if self.config.env['ir.config_parameter'].sudo().get_param('pos_urban_piper.is_production_mode') == 'False' else 'https://api.urbanpiper.com/'
access_url = urbanpiper_url + endpoint
pos_config = self.config
try:
# Make the API request
response = self.session.request(method, access_url, json=data, headers=headers, timeout=timeout)
# Parse the response as JSON
response_json = response.json()
pos_config.log_xml(
"API Request Successful:\nURL: %s \nMethod: %s \nPayload: %s \nResponse: %s" % (access_url, method, json.dumps(data), json.dumps(response_json)),
'_make_api_request',
'Urbanpiper API Success'
)
# raise an error if the response is not successful
response.raise_for_status()
return response_json
except requests.exceptions.ConnectionError as error:
_logger.warning('Connection Error: %r with the given URL %r', error, access_url)
pos_config.log_xml(
"Connection Error: %s \nURL: %s \nMethod: %s \nPayload: %s" % (error, access_url, method, json.dumps(data)),
'_make_api_request',
'Urbanpiper API Connection Error'
)
return {'errors': {'timeout': _('Cannot reach the server. Please try again later.')}}
except requests.exceptions.HTTPError as error:
message = response_json.get("message")
pos_config.log_xml(
"HttpError: %s \nURL: %s \nMethod: %s \nPayload: %s" % (message or error, access_url, method, json.dumps(data)),
'_make_api_request',
'Urbanpiper API HTTP Error'
)
_logger.warning('HTTPError: %r', message or error)
return {'errors': {'HTTPError': message or str(error)}}
except requests.exceptions.JSONDecodeError as error:
_logger.warning('JSONDecodeError: %r', error)
return {'errors': {'JSONDecodeError': _('Failed to parse server response.')}}
except json.decoder.JSONDecodeError as error:
_logger.warning('JSONDecodeError: %r', error)
pos_config.log_xml(
"JSONDecodeError: %s \nURL: %s \nMethod: %s \nPayload: %s" % (error, access_url, method, json.dumps(data)),
'_make_api_request',
'Urbanpiper API JSON Decode Error'
)
return {'errors': {'JSONDecodeError': _('Failed to parse server response.')}}
def configure_webhook(self):
"""
Check and register webhook if the base url is changed.
"""
base_url = self.config.get_base_url()
if base_url != self.config.urbanpiper_webhook_url:
self.config.urbanpiper_webhook_url = base_url
self._register_webhook()
def _register_webhook(self):
"""
Register webhook on Atlas for get notified for order updates.
"""
endpoint = 'external/api/v1/webhooks/'
base_url = self.config.urbanpiper_webhook_url
webhook_url = url_join(base_url, '/urbanpiper/webhook/')
for event_type in EVENT_TYPES:
controller_url = url_join(webhook_url, event_type)
payload = {
'active': True,
'event_type': event_type,
'retrial_interval_units': 'seconds',
'url': controller_url,
'headers': {
'X-Urbanpiper-Uuid': self.config.env['ir.config_parameter'].sudo().get_param('pos_urban_piper.uuid')
}
}
self._make_api_request(endpoint, data=payload)
def request_store_create(self):
"""
Request to create a store in UrbanPiper.
"""
endpoint = 'external/api/v1/stores/'
data = {
'stores': [
{
'name': self.config.with_context(lang="en_US").name,
'city': self.config.company_id.city,
'ref_id': self.config.urbanpiper_store_identifier
}
]
}
name_translations = self.config.get_field_translations('name')
data['stores'][0]['translations'] = self._get_translations(name_translations, 'name')
data = self.config.prepare_store_data(data)
response_json = self._make_api_request(endpoint, data=data)
return response_json
def request_sync_menu(self):
"""
Sync menu in urban piper.
- Sync categories, products, attributes, values and taxes.
"""
store_identifier = quote(self.config.urbanpiper_store_identifier, safe='')
endpoint = f'external/api/v1/inventory/locations/{store_identifier}/'
product_domain = [
('urbanpiper_pos_config_ids', 'in', self.config.ids),
('type', '!=', 'combo')
]
# For the US & UK regions, there is an issue on the UrbanPiper side. They require the entire product catalog, but currently, we are only sending the updated products.
full_sync_required_providers = ['justeat', 'grubhub', 'doordash', 'ubereats']
products = self.config.env['product.template'].search(product_domain)
pos_products = products
flush = self.config.env.context.get('flush')
if not flush and not any(provider.technical_name in full_sync_required_providers for provider in self.config.urbanpiper_delivery_provider_ids):
pos_products = products.filtered(lambda product: (
not product.urban_piper_status_ids or
self.config.id not in product.urban_piper_status_ids.config_id.ids or
any(ups.config_id.id in self.config.ids and not ups.is_product_linked for ups in product.urban_piper_status_ids)
))
pos_products_without_pos_categ_ids = pos_products.filtered(lambda p: not p.pos_categ_ids)
pos_other_categ_id = self.config.env['pos.category'].search([('name', 'ilike', 'other')], limit=1)
if not pos_other_categ_id:
pos_other_categ_id = self.config.env['pos.category'].create({
'name': 'Other',
})
pos_products_without_pos_categ_ids.pos_categ_ids = pos_other_categ_id
pos_categories = pos_products.pos_categ_ids
# The UrbanPiper platform only supports a single level of sub-categories.
pos_categories |= pos_categories.parent_id
pos_attribute_products = pos_products.filtered(lambda p: p.attribute_line_ids)
payload = {
'flush_categories': flush,
'categories': self._prepare_categories_data(pos_categories), # pos categories
'flush_items': flush,
'items': self._prepare_items_data(pos_products), # pos products
'flush_option_groups': flush,
'option_groups': self._prepare_option_groups_data(pos_attribute_products), # pos attributes
'flush_options': flush,
'options': self._prepare_option_data(pos_attribute_products), # pos attribute values
'flush_taxes': flush,
'taxes': self.config.prepare_taxes_data(pos_products), # pos taxes
'flush_charges': flush,
'charges': self._prepare_charges_data() # pos charges
}
# If we have multiple products, we should increase the timeout to 90 seconds.
response_json = self._make_api_request(endpoint, method='POST', data=payload, timeout=90)
if response_json.get('status') == 'success':
# logic for updating the status of the product in atlas based on config
for product in pos_products:
if (not product.urban_piper_status_ids or product.urban_piper_status_ids and
self.config.id not in product.urban_piper_status_ids.config_id.ids):
product.write({
'urban_piper_status_ids': [Command.create({
'product_tmpl_id': product.id,
'is_product_linked': True,
'config_id': self.config.id
})]
})
elif product.urban_piper_status_ids and self.config.id in product.urban_piper_status_ids.config_id.ids:
product.urban_piper_status_ids.filtered(lambda p: p.config_id == self.config).write({
'is_product_linked': True
})
if response_json.get('status') == 'success':
self.config.urbanpiper_last_sync_date = datetime.now()
return response_json
def _get_translations(self, field_translations, field):
translations = []
for translation in field_translations[0]:
lang = translation['lang'].split('_')[0]
translations.append({
'language': lang,
field: translation['value']
}) if lang in UP_LANGUAGES else None
return translations
def get_item_ref_id(self, product):
return f'{product.id}-{self.db_uuid[0:5]}'
def _prepare_categories_data(self, pos_categories):
"""
Prepare categories data for urban piper.
"""
category_lst = []
for category in pos_categories:
categ_dict = {
'ref_id': str(category.id),
'name': category.with_context(lang="en_US").name,
'sort_order': category.sequence,
'active': True,
}
if img_url := self._get_public_image_url(category):
categ_dict['img_url'] = img_url
if category.parent_id:
categ_dict['parent_ref_id'] = str(category.parent_id.id)
name_translations = category.get_field_translations('name')
categ_dict['translations'] = self._get_translations(name_translations, 'name')
category_lst.append(categ_dict)
return category_lst
def _prepare_items_data(self, pos_products):
"""
Prepare product data for urban piper.
"""
item_lst = []
for product in pos_products:
product_price = product.list_price if not self.config.urbanpiper_pricelist_id \
else self.config.urbanpiper_pricelist_id.sudo()._get_product_price(
product, 1.0, uom=product.uom_id
)
item = {
'ref_id': self.get_item_ref_id(product),
'title': product.with_context(lang="en_US").name,
'description': html2plaintext(product.with_context(lang="en_US").public_description) if product.public_description else '',
'price': product.taxes_id.compute_all(
product_price, product.currency_id, 1)[self.config._get_total_tax_tag()],
'weight': product.weight,
'food_type': product.urbanpiper_meal_type,
'category_ref_ids': [str(i) for i in product.pos_categ_ids.ids],
'recommended': product.is_recommended_on_urbanpiper,
'available': True,
'included_platforms': (
[provider.technical_name for provider in (product.urbanpiper_pos_platform_ids & self.config.urbanpiper_delivery_provider_ids)]
),
}
if img_url := self._get_public_image_url(product):
item['img_url'] = img_url
name_translations = product.get_field_translations('name')
description_translations = product.get_field_translations('public_description')
translations = []
name_dict = {t['lang'].split('_')[0]: t['value'] for t in name_translations[0] if t['lang'].split('_')[0] in UP_LANGUAGES}
desc_dict = {t['lang'].split('_')[0]: t['value'] for t in description_translations[0] if t['lang'].split('_')[0] in UP_LANGUAGES}
for lang in UP_LANGUAGES:
if lang in name_dict or lang in desc_dict:
translations.append({
'language': lang,
'title': name_dict.get(lang, ''),
'description': desc_dict.get(lang, '')
})
item['translations'] = translations
# dynamic tag syncing for provider tags and default tags
tags = item.setdefault('tags', {})
default_tags = []
for product_tag in product.product_tag_ids:
default_tags.append(product_tag.name)
alcohol_tag = 'alcohol-present' if product.is_alcoholic_on_urbanpiper else 'alcohol-absent'
if alcohol_tag not in default_tags:
default_tags.append(alcohol_tag)
tags['default'] = default_tags
for provider in (product.urbanpiper_pos_platform_ids & self.config.urbanpiper_delivery_provider_ids):
tags[provider.technical_name] = default_tags
updated_item = self.config.update_urbanpiper_item_data(item, product)
item_lst.append(updated_item)
return item_lst
def _prepare_option_groups_data(self, pos_products):
"""
Prepare option groups data for urban piper.
- Attributes are option groups.
"""
attribute_lst = []
for product in pos_products:
for attr_line in product.attribute_line_ids:
group = {
'ref_id': f'{product.id}-{attr_line.attribute_id.id}',
'title': attr_line.attribute_id.with_context(lang="en_US").name,
'active': True,
'multi_options_enabled': bool(attr_line.attribute_id.display_type == 'multi'),
'item_ref_ids': [self.get_item_ref_id(product)],
'min_selectable': 0,
'max_selectable': 30 if any(provider.technical_name == "doordash" for provider in self.config.urbanpiper_delivery_provider_ids) else -1
}
if attr_line.attribute_id.display_type != 'multi':
group['min_selectable'] = 1
group['max_selectable'] = 1
name_translations = attr_line.attribute_id.get_field_translations('name')
group['translations'] = self._get_translations(name_translations, 'title')
attribute_lst.append(group)
return attribute_lst
def _prepare_option_data(self, pos_products):
"""
Prepare options data for urban piper.
- Attribute values are options.
"""
value_lst = []
active_ptav = pos_products.attribute_line_ids.product_template_value_ids.filtered(lambda ptav: ptav.ptav_active)
for option in active_ptav:
value_dict = {
'ref_id': f'{option.product_tmpl_id.id}-{option.product_attribute_value_id.id}',
'title': option.product_attribute_value_id.with_context(lang="en_US").name,
'available': True,
'opt_grp_ref_ids': [f'{option.product_tmpl_id.id}-{i}' for i in option.attribute_id.ids],
'price': option.price_extra or option.product_attribute_value_id.default_extra_price,
'food_type': option.product_tmpl_id.urbanpiper_meal_type,
}
name_translations = option.product_attribute_value_id.get_field_translations('name')
value_dict['translations'] = self._get_translations(name_translations, 'title')
value_lst.append(value_dict)
return value_lst
def _prepare_charges_data(self):
"""
Prepare charges data for urban piper.
"""
product_packaging = self.config.env.ref('pos_urban_piper.product_packaging_charges', False)
product_delivery = self.config.env.ref('pos_urban_piper.product_delivery_charges', False)
def get_charge_data(product):
return {
'code': 'PC_F' if product == product_packaging else 'DC_F',
'title': product.with_context(lang="en_US").name,
'active': True,
'structure': {
'applicable_on': 'order.order_subtotal',
'value': product.list_price
},
'item_ref_ids': ['all']
}
return [
get_charge_data(product)
for product in [product_packaging, product_delivery]
if product and product.list_price > 0
]
def request_category_timing(self):
"""
Sync category timings from Odoo to UrbanPiper.
"""
endpoint = 'external/api/v1/inventory/categories/timing-groups/'
pos_categories = self.config.env['pos.category'].search([]).filtered(lambda categ: (categ.hour_until - categ.hour_after) != 0)
timing_groups = []
week_days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']
for category in pos_categories:
day_slots = []
for day in week_days:
day_slots.append({
'day': day,
'slots': [
{
'start_time': "23:59" if category.hour_after == 24.0 else format_duration(category.hour_after),
'end_time': "23:59" if category.hour_until == 24.0 else format_duration(category.hour_until)
}
]
})
timing_groups.append({
'title': f'{category.name} timings',
'category_ref_ids': [str(category.id)],
'day_slots': day_slots
})
payload = {
'timing_groups': timing_groups
}
response_json = self._make_api_request(endpoint, method='POST', data=payload)
return response_json
def register_item_toggle(self, products, status):
"""
Enable/Disable product on urban piper store. (If menu is synced with urban piper)
"""
product_lst_str = [self.get_item_ref_id(product) for product in products]
endpoint = 'hub/api/v1/items/'
payload = {
'location_ref_id': self.config.urbanpiper_store_identifier,
'item_ref_ids': product_lst_str,
'option_ref_ids': [],
'action': 'enable' if status else 'disable'
}
response_json = self._make_api_request(endpoint, method='POST', data=payload)
if response_json.get('status') == 'success':
self.config._notify('PRODUCT_UP_STATUS_CHANGED', {
'product_ids': [p.id for p in products],
'status': status,
})
return response_json
def urbanpiper_attribute_value_toggle(self, values, status):
"""
Enable/Disable the attribute's value on the urbanpiper store. (If menu is synced with urban piper)
"""
value_lst_str = [f'{value.product_tmpl_id.id}-{value.product_attribute_value_id.id}' for value in values]
endpoint = 'hub/api/v1/items/'
payload = {
'location_ref_id': self.config.urbanpiper_store_identifier,
'item_ref_ids': [],
'option_ref_ids': value_lst_str,
'action': 'enable' if status else 'disable'
}
response_json = self._make_api_request(endpoint, method='POST', data=payload)
return response_json
def request_status_update(self, order_id, new_status, prep_time, code=None):
"""
Update status in Urban Piper
"""
endpoint = f'external/api/v1/orders/{order_id}/status/'
payload = {
'new_status': new_status,
'reason_code': code,
}
if new_status == "Acknowledged":
payload['extra'] = {
'prep_time_mins': prep_time,
}
response_json = self._make_api_request(endpoint, method='PUT', data=payload)
if response_json:
if response_json.get('status') == 'success':
return True, ''
else:
return False, response_json.get('message') or next(iter(response_json.get("errors", {}).values()), "")
else:
return False, 'Failed to update status in Urban Piper'
def urbanpiper_order_reference_update(self, order):
"""
Update order reference in Urban Piper
"""
endpoint = f'external/api/v1/orders/{order.delivery_identifier}/'
payload = {
"reference_id": order.name
}
self._make_api_request(endpoint, method='PUT', data=payload)
def urbanpiper_store_status_update(self, status):
"""
Change store status in urban piper.
"""
provider_name = self.config.env.context.get('provider_name')
platforms = [provider_name] if provider_name else [p.technical_name for p in self.config.urbanpiper_delivery_provider_ids]
if platforms:
payload = {
'location_ref_id': self.config.urbanpiper_store_identifier,
'platforms': platforms,
'action': 'enable' if status else 'disable',
}
self._make_api_request('hub/api/v1/location/', data=payload)
def _get_jpeg_datas(self, datas):
"""
Get the image data in jpeg format.
"""
try:
data_uri = self.config.env['ir.qweb'].with_context(webp_as_jpg=True)._get_converted_image_data_uri(datas)
_header, base64_data = data_uri.split('base64,', 1)
return base64_data
except TypeError:
return datas
def _get_public_image_url(self, record):
"""
Get public image URL for the given record (product or category).
Converts webp to jpeg if necessary.
Returns False if no image is available for the record.
"""
base_url = self.config.urbanpiper_webhook_url
image_data = record.image_1920 if record._name == 'product.template' else record.image_128
if not image_data:
return False
attachment = record.env['ir.attachment'].search([
('res_model', '=', record._name),
('res_id', '=', record.id),
('type', '=', 'binary'),
('public', '=', True),
('mimetype', '=', 'image/jpg'),
], limit=1)
datas = self._get_jpeg_datas(image_data)
if attachment:
attachment.datas = datas
else:
attachment = record.env['ir.attachment'].create({
'name': record.name,
'type': 'binary',
'datas': datas,
'res_model': record._name,
'res_id': record.id,
'public': True,
'mimetype': 'image/jpg',
})
local_url = attachment.local_url
return url_join(base_url, local_url)
def request_refresh_webhooks(self):
"""
If customer uses multi db on the same URL. UrbanPiper only stores one webhook per URL.
When the user switches the database, they can press the refresh webhook button to
update the webhook parameters according to the new database.
"""
self.config.urbanpiper_webhook_url = self.config.get_base_url() # registring webhook
self._register_webhook()
wehbook_json = self._make_api_request('external/api/v1/webhooks?limit=50', method='GET')
webhooks = wehbook_json.get('webhooks')
response_json = {}
if webhooks:
for webhook in webhooks:
if self.config.urbanpiper_webhook_url and self.config.urbanpiper_webhook_url in webhook.get('url'):
payload = {
'active': True,
'event_type': webhook.get('event_type'),
'retrial_interval_units': 'seconds',
'url': webhook.get('url'),
'headers': {
'X-Urbanpiper-Uuid': self.config.env['ir.config_parameter'].sudo().get_param('pos_urban_piper.uuid')
}
}
wehbook_id = webhook.get('webhook_id')
response_json = self._make_api_request(f'external/api/v1/webhooks/{wehbook_id}/', data=payload, method='PUT')
return response_json