diff --git a/.tx/config b/.tx/config index 1bd2ffbfbd492..e581f533460fc 100644 --- a/.tx/config +++ b/.tx/config @@ -2080,6 +2080,15 @@ resource_name = sale_expense_margin replace_edited_strings = false keep_translations = false +[o:odoo:p:odoo-18:r:sale_gelato] +file_filter = addons/sale_gelato/i18n/.po +source_file = addons/sale_gelato/i18n/sale_gelato.pot +type = PO +minimum_perc = 0 +resource_name = sale_gelato +replace_edited_strings = false +keep_translations = false + [o:odoo:p:odoo-18:r:sale_loyalty] file_filter = addons/sale_loyalty/i18n/.po source_file = addons/sale_loyalty/i18n/sale_loyalty.pot @@ -2953,6 +2962,15 @@ resource_name = website_sale_comparison_wishlist replace_edited_strings = false keep_translations = false +[o:odoo:p:odoo-18:r:website_sale_gelato] +file_filter = addons/website_sale_gelato/i18n/.po +source_file = addons/website_sale_gelato/i18n/website_sale_gelato.pot +type = PO +minimum_perc = 0 +resource_name = website_sale_gelato +replace_edited_strings = false +keep_translations = false + [o:odoo:p:odoo-18:r:website_sale_loyalty] file_filter = addons/website_sale_loyalty/i18n/.po source_file = addons/website_sale_loyalty/i18n/website_sale_loyalty.pot diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 35fe794abd2e3..b83ff7dea0381 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -203,13 +203,9 @@ def _compute_item_count(self): def _compute_product_document_count(self): for template in self: - template.product_document_count = template.env['product.document'].search_count([ - '|', - '&', ('res_model', '=', 'product.template'), ('res_id', '=', template.id), - '&', - ('res_model', '=', 'product.product'), - ('res_id', 'in', template.product_variant_ids.ids), - ]) + template.product_document_count = template.env['product.document'].search_count( + template._get_product_document_domain() + ) @api.depends('image_1920', 'image_1024') def _compute_can_image_1024_be_zoomed(self): @@ -630,13 +626,7 @@ def action_open_documents(self): 'default_res_id': self.id, 'default_company_id': self.company_id.id, }, - 'domain': [ - '|', - '&', ('res_model', '=', 'product.template'), ('res_id', '=', self.id), - '&', - ('res_model', '=', 'product.product'), - ('res_id', 'in', self.product_variant_ids.ids), - ], + 'domain': self._get_product_document_domain(), 'target': 'current', 'help': """

@@ -1463,6 +1453,16 @@ def _get_contextual_pricelist(self): """ return self.env['product.pricelist'].browse(self.env.context.get('pricelist')) + def _get_product_document_domain(self): + self.ensure_one() + return expression.OR([ + expression.AND([[('res_model', '=', 'product.template')], [('res_id', '=', self.id)]]), + expression.AND([ + [('res_model', '=', 'product.product')], + [('res_id', 'in', self.product_variant_ids.ids)], + ]) + ]) + ################### # DEMO DATA SETUP # ################### diff --git a/addons/sale_gelato/__init__.py b/addons/sale_gelato/__init__.py new file mode 100644 index 0000000000000..f3c4e0dd82df7 --- /dev/null +++ b/addons/sale_gelato/__init__.py @@ -0,0 +1,5 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controlers +from . import models +from . import wizards diff --git a/addons/sale_gelato/__manifest__.py b/addons/sale_gelato/__manifest__.py new file mode 100644 index 0000000000000..3adaacc092246 --- /dev/null +++ b/addons/sale_gelato/__manifest__.py @@ -0,0 +1,20 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': "Gelato", + 'summary': "Place orders through Gelato's print-on-demand service", + 'category': 'Sales/Sales', + 'depends': ['sale_management', 'delivery'], + 'data': [ + 'data/product_data.xml', + 'data/delivery_carrier_data.xml', # Depends on product_data.xml + 'data/mail_template_data.xml', + + 'views/delivery_carrier_views.xml', + 'views/product_document_views.xml', + 'views/product_product_views.xml', + 'views/product_template_views.xml', + 'wizards/res_config_settings_views.xml', + ], + 'license': 'LGPL-3', +} diff --git a/addons/sale_gelato/const.py b/addons/sale_gelato/const.py new file mode 100644 index 0000000000000..ff709f6ceddba --- /dev/null +++ b/addons/sale_gelato/const.py @@ -0,0 +1,10 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +COUNTRIES_WITHOUT_ZIPCODE = [ + 'AO', 'AG', 'AW', 'BS', 'BZ', 'BJ', 'BW', 'BF', 'BI', 'CM', 'CF', 'KM', + 'CG', 'CD', 'CK', 'CI', 'DJ', 'DM', 'GQ', 'ER', 'FJ', 'TF', 'GM', 'GH', + 'GD', 'GN', 'GY', 'HK', 'IE', 'JM', 'KE', 'KI', 'MO', 'MW', 'ML', 'MR', + 'MU', 'MS', 'NR', 'AN', 'NU', 'KP', 'PA', 'QA', 'RW', 'KN', 'LC', 'ST', + 'SC', 'SL', 'SB', 'SO', 'ZA', 'SR', 'SY', 'TZ', 'TL', 'TK', 'TO', 'TT', + 'TV', 'UG', 'AE', 'VU', 'YE', 'ZW' +] diff --git a/addons/sale_gelato/controlers/__init__.py b/addons/sale_gelato/controlers/__init__.py new file mode 100644 index 0000000000000..80ee4da1c5ecc --- /dev/null +++ b/addons/sale_gelato/controlers/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/sale_gelato/controlers/main.py b/addons/sale_gelato/controlers/main.py new file mode 100644 index 0000000000000..ed2b5aee0c0fe --- /dev/null +++ b/addons/sale_gelato/controlers/main.py @@ -0,0 +1,121 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hmac +import logging +import pprint + +from werkzeug.exceptions import Forbidden + +from odoo import SUPERUSER_ID, _ +from odoo.http import Controller, request, route + + +_logger = logging.getLogger(__name__) + + +class GelatoController(Controller): + _webhook_url = '/gelato/webhook' + + @route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False) + def gelato_webhook(self): + """ Process the notification data sent by Gelato to the webhook. + + See https://dashboard.gelato.com/docs/orders/order_details/#order-statuses for the event + codes. + + :return: An empty response to acknowledge the notification. + :rtype: odoo.http.Response + """ + event_data = request.get_json_data() + _logger.info("Webhook notification received from Gelato:\n%s", pprint.pformat(event_data)) + + if event_data['event'] == 'order_status_updated': + # Check the signature of the webhook notification. + order_id = int(event_data['orderReferenceId']) + order_sudo = request.env['sale.order'].sudo().browse(order_id).exists() + received_signature = request.httprequest.headers.get('signature', '') + self._verify_notification_signature(received_signature, order_sudo) + + # Process the event. + fulfillment_status = event_data.get('fulfillmentStatus') + if fulfillment_status == 'failed': + # Log a message on the order. + log_message = _( + "Gelato could not proceed with the fulfillment of order %(order_reference)s:" + " %(gelato_message)s", + order_reference=order_sudo.display_name, + gelato_message=event_data['comment'], + ) + order_sudo.message_post( + body=log_message, author_id=request.env.ref('base.partner_root').id + ) + elif fulfillment_status == 'canceled': + # Cancel the order. + order_sudo.with_user(SUPERUSER_ID)._action_cancel() + + # Manually cache the currency while in a sudoed environment to prevent an + # AccessError. The state of the sales order is a dependency of + # `untaxed_amount_to_invoice`, which is a monetary field. They require the currency + # to ensure the values are saved in the correct format. However, the currency cannot + # be read directly during the flush due to access rights, necessitating manual + # caching. + order_sudo.order_line.currency_id + + # Send the generic order cancellation email. + order_sudo.message_post_with_source( + source_ref=request.env.ref('sale.mail_template_sale_cancellation'), + author_id=request.env.ref('base.partner_root').id, + ) + elif fulfillment_status == 'in_transit': + # Send the Gelato order status update email. + tracking_data = self._extract_tracking_data(item_data=event_data['items']) + order_sudo.with_context({'tracking_data': tracking_data}).message_post_with_source( + source_ref=request.env.ref('sale_gelato.order_status_update'), + author_id=request.env.ref('base.partner_root').id, + ) + elif fulfillment_status == 'delivered': + # Send the Gelato order status update email. + order_sudo.with_context({'order_delivered': True}).message_post_with_source( + source_ref=request.env.ref('sale_gelato.order_status_update'), + author_id=request.env.ref('base.partner_root').id, + ) + elif fulfillment_status == 'returned': + # Log a message on the order. + log_message = _( + "Gelato has returned order %(reference)s.", reference=order_sudo.display_name + ) + order_sudo.message_post( + body=log_message, author_id=request.env.ref('base.partner_root').id + ) + return request.make_json_response('') + + @staticmethod + def _verify_notification_signature(received_signature, order_sudo): + """ Check if the received signature matches the expected one. + + :param str received_signature: The received signature. + :param sale.order order_sudo: The sales order for which the webhook notification was sent. + :return: None + :raise Forbidden: If the signatures don't match. + """ + company_sudo = order_sudo.company_id.sudo() # In sudo mode to read on the company. + expected_signature = company_sudo.gelato_webhook_secret + if not hmac.compare_digest(received_signature, expected_signature): + _logger.warning("Received notification with invalid signature.") + raise Forbidden() + + @staticmethod + def _extract_tracking_data(item_data): + """ Extract the tracking URL and code from the item data. + + :param dict item_data: The item data. + :return: The extracted tracking data. + :rtype: dict + """ + tracking_data = {} + for i in item_data: + for fulfilment_data in i['fulfillments']: + tracking_data.setdefault( + fulfilment_data['trackingUrl'], fulfilment_data['trackingCode'] + ) # Different items can have the same tracking URL. + return tracking_data diff --git a/addons/sale_gelato/data/delivery_carrier_data.xml b/addons/sale_gelato/data/delivery_carrier_data.xml new file mode 100644 index 0000000000000..831dbed0a2e17 --- /dev/null +++ b/addons/sale_gelato/data/delivery_carrier_data.xml @@ -0,0 +1,22 @@ + + + + + Standard Delivery + gelato + rate + normal + + + + + + Express Delivery + gelato + rate + express + + + + + diff --git a/addons/sale_gelato/data/mail_template_data.xml b/addons/sale_gelato/data/mail_template_data.xml new file mode 100644 index 0000000000000..bed7950d2a12e --- /dev/null +++ b/addons/sale_gelato/data/mail_template_data.xml @@ -0,0 +1,48 @@ + + + + + Gelato: Order status update + + {{ object.reference }} + {{ object.partner_id.email and object.partner_id.id or object.partner_id.parent_id.id }} + Sent to the customer when Gelato updates the status of an order + +

+

+ Hello Brandon Freeman,

+ + + We are glad to inform you that your order is in transit. + + + Your tracking number is . +

+
+ + Your tracking numbers are: +

+ + + + + We are glad to inform you that your order has been delivered. +

+
+ Thank you, + +
+ --
Mitchell Admin
+
+

+
+ + {{ object.partner_id.lang }} + + + + diff --git a/addons/sale_gelato/data/neutralize.sql b/addons/sale_gelato/data/neutralize.sql new file mode 100644 index 0000000000000..367b82c6958c7 --- /dev/null +++ b/addons/sale_gelato/data/neutralize.sql @@ -0,0 +1,4 @@ +-- disable Gelato +UPDATE res_company + SET gelato_api_key = NULL, + gelato_webhook_secret = NULL; diff --git a/addons/sale_gelato/data/product_data.xml b/addons/sale_gelato/data/product_data.xml new file mode 100644 index 0000000000000..627810c41ab29 --- /dev/null +++ b/addons/sale_gelato/data/product_data.xml @@ -0,0 +1,24 @@ + + + + + Standard Delivery (Gelato) + normal + service + + + + 0.0 + + + + Express Delivery (Gelato) + express + service + + + + 0.0 + + + diff --git a/addons/sale_gelato/i18n/sale_gelato.pot b/addons/sale_gelato/i18n/sale_gelato.pot new file mode 100644 index 0000000000000..863b0acfbe5e6 --- /dev/null +++ b/addons/sale_gelato/i18n/sale_gelato.pot @@ -0,0 +1,326 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * sale_gelato +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 18.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-02-04 14:39+0000\n" +"PO-Revision-Date: 2025-02-04 14:39+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: sale_gelato +#: model:mail.template,body_html:sale_gelato.order_status_update +msgid "" +"
\n" +"

\n" +" Hello Brandon Freeman,

\n" +" \n" +" \n" +" We are glad to inform you that your order is in transit.\n" +" \n" +" \n" +" Your tracking number is
.\n" +"

\n" +" \n" +" \n" +" Your tracking numbers are:\n" +"

\n" +" \n" +" \n" +" \n" +" \n" +" We are glad to inform you that your order has been delivered.\n" +"

\n" +"
\n" +" Thank you,\n" +" \n" +"
\n" +" --
Mitchell Admin
\n" +"
\n" +"

\n" +"
\n" +" " +msgstr "" + +#. module: sale_gelato +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_template_form +msgid "" +"\n" +" " +msgstr "" + +#. module: sale_gelato +#: model_terms:ir.ui.view,arch_db:sale_gelato.res_config_settings_form +msgid "API Key" +msgstr "" + +#. module: sale_gelato +#: model:ir.model,name:sale_gelato.model_res_company +msgid "Companies" +msgstr "" + +#. module: sale_gelato +#: model:ir.model,name:sale_gelato.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: sale_gelato +#: model:ir.model,name:sale_gelato.model_res_partner +msgid "Contact" +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/utils.py:0 +msgid "Could not establish the connection to the Gelato API." +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/models/product_template.py:0 +msgid "Could not synchronize with Gelato" +msgstr "" + +#. module: sale_gelato +#: model:delivery.carrier,name:sale_gelato.express_delivery +#: model:ir.model.fields.selection,name:sale_gelato.selection__delivery_carrier__gelato_shipping_service_type__express +msgid "Express Delivery" +msgstr "" + +#. module: sale_gelato +#: model:product.template,name:sale_gelato.express_delivery_product_product_template +msgid "Express Delivery (Gelato)" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields.selection,name:sale_gelato.selection__delivery_carrier__delivery_type__gelato +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_product_easy_form +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_product_normal_form +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_template_form +msgid "Gelato" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_res_company__gelato_api_key +#: model:ir.model.fields,field_description:sale_gelato.field_res_config_settings__gelato_api_key +msgid "Gelato API Key" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_product_product__gelato_image_ids +#: model:ir.model.fields,field_description:sale_gelato.field_product_template__gelato_image_ids +msgid "Gelato Print Images" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_product_template__gelato_product_uid +msgid "Gelato Product UID" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_product_product__gelato_product_uid +msgid "Gelato Product Uid" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_delivery_carrier__gelato_shipping_service_type +msgid "Gelato Shipping Service Type" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_product_product__gelato_template_ref +#: model:ir.model.fields,field_description:sale_gelato.field_product_template__gelato_template_ref +msgid "Gelato Template Reference" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_res_company__gelato_webhook_secret +#: model:ir.model.fields,field_description:sale_gelato.field_res_config_settings__gelato_webhook_secret +msgid "Gelato Webhook Secret" +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/controlers/main.py:0 +msgid "" +"Gelato could not proceed with the fulfillment of order %(order_reference)s: " +"%(gelato_message)s" +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/controlers/main.py:0 +msgid "Gelato has returned order %(reference)s." +msgstr "" + +#. module: sale_gelato +#: model:mail.template,name:sale_gelato.order_status_update +msgid "Gelato: Order status update" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_product_document__is_gelato +msgid "Is Gelato" +msgstr "" + +#. module: sale_gelato +#: model_terms:ir.ui.view,arch_db:sale_gelato.res_config_settings_form +msgid "Manage Delivery Methods" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_product_product__gelato_missing_images +#: model:ir.model.fields,field_description:sale_gelato.field_product_template__gelato_missing_images +msgid "Missing Print Images" +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/models/product_template.py:0 +msgid "Missing product variants and images have been successfully created." +msgstr "" + +#. module: sale_gelato +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_template_form +msgid "Print Images" +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/models/product_document.py:0 +msgid "Print images must be set on products before they can be ordered." +msgstr "" + +#. module: sale_gelato +#: model:ir.model,name:sale_gelato.model_product_template +msgid "Product" +msgstr "" + +#. module: sale_gelato +#: model:ir.model,name:sale_gelato.model_product_document +msgid "Product Document" +msgstr "" + +#. module: sale_gelato +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_product_easy_form +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_product_normal_form +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_template_form +msgid "Product UID" +msgstr "" + +#. module: sale_gelato +#: model:ir.model,name:sale_gelato.model_product_product +msgid "Product Variant" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,field_description:sale_gelato.field_delivery_carrier__delivery_type +msgid "Provider" +msgstr "" + +#. module: sale_gelato +#: model:ir.model,name:sale_gelato.model_sale_order +msgid "Sales Order" +msgstr "" + +#. module: sale_gelato +#: model:ir.model,name:sale_gelato.model_sale_order_line +msgid "Sales Order Line" +msgstr "" + +#. module: sale_gelato +#: model:mail.template,description:sale_gelato.order_status_update +msgid "Sent to the customer when Gelato updates the status of an order" +msgstr "" + +#. module: sale_gelato +#: model:ir.model,name:sale_gelato.model_delivery_carrier +msgid "Shipping Methods" +msgstr "" + +#. module: sale_gelato +#: model:delivery.carrier,name:sale_gelato.standard_delivery +#: model:ir.model.fields.selection,name:sale_gelato.selection__delivery_carrier__gelato_shipping_service_type__normal +msgid "Standard Delivery" +msgstr "" + +#. module: sale_gelato +#: model:product.template,name:sale_gelato.standard_delivery_product_product_template +msgid "Standard Delivery (Gelato)" +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/models/product_template.py:0 +msgid "Successfully synchronized with Gelato" +msgstr "" + +#. module: sale_gelato +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_template_form +msgid "Synchronize" +msgstr "" + +#. module: sale_gelato +#: model:ir.model.fields,help:sale_gelato.field_product_product__gelato_template_ref +#: model:ir.model.fields,help:sale_gelato.field_product_template__gelato_template_ref +msgid "Synchronize to fetch variants from Gelato" +msgstr "" + +#. module: sale_gelato +#: model_terms:ir.ui.view,arch_db:sale_gelato.product_template_form +msgid "Template Reference" +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/models/delivery_carrier.py:0 +msgid "The delivery method is not available for this order." +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/models/delivery_carrier.py:0 +msgid "The following required address fields are missing: %s" +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/models/sale_order.py:0 +msgid "The order has been successfully passed on Gelato." +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/models/sale_order.py:0 +msgid "" +"The order with reference %(order_reference)s was not sent to Gelato.\n" +"Reason: %(error_message)s" +msgstr "" + +#. module: sale_gelato +#: model_terms:ir.ui.view,arch_db:sale_gelato.res_config_settings_form +msgid "Webhook Secret" +msgstr "" + +#. module: sale_gelato +#. odoo-python +#: code:addons/sale_gelato/models/sale_order.py:0 +msgid "" +"You cannot mix Gelato products with non-Gelato products in the same order." +msgstr "" + +#. module: sale_gelato +#: model:mail.template,subject:sale_gelato.order_status_update +msgid "{{ object.reference }}" +msgstr "" diff --git a/addons/sale_gelato/models/__init__.py b/addons/sale_gelato/models/__init__.py new file mode 100644 index 0000000000000..0e9e773b2997f --- /dev/null +++ b/addons/sale_gelato/models/__init__.py @@ -0,0 +1,10 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import delivery_carrier +from . import product_document +from . import product_product +from . import product_template +from . import res_company +from . import res_partner +from . import sale_order +from . import sale_order_line diff --git a/addons/sale_gelato/models/delivery_carrier.py b/addons/sale_gelato/models/delivery_carrier.py new file mode 100644 index 0000000000000..0e548820dc6fb --- /dev/null +++ b/addons/sale_gelato/models/delivery_carrier.py @@ -0,0 +1,133 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +from odoo.addons.sale_gelato import const, utils + + +class ProviderGelato(models.Model): + _inherit = 'delivery.carrier' + + delivery_type = fields.Selection( + selection_add=[('gelato', "Gelato")], ondelete={'gelato': 'cascade'} + ) + gelato_shipping_service_type = fields.Selection( + string="Gelato Shipping Service Type", + selection=[('normal', "Standard Delivery"), ('express', "Express Delivery")], + required=True, + default='normal', + ) + + # === BUSINESS METHODS === # + + def _is_available_for_order(self, order): + """ Override of `delivery` to exclude regular delivery methods from Gelato orders and Gelato + delivery methods from non-Gelato orders. + + :param sale.order order: The current order. + :return: Whether the delivery method is available for the order. + :rtype: bool + """ + is_gelato_order = any(order.order_line.product_id.mapped('gelato_product_uid')) + is_gelato_delivery = self.delivery_type == 'gelato' + if is_gelato_order and not is_gelato_delivery or not is_gelato_order and is_gelato_delivery: + return False + return super()._is_available_for_order(order) + + def available_carriers(self, partner, order): + """ Override of `delivery` to filter out regular delivery methods from Gelato orders and + Gelato delivery methods from non-Gelato orders. + + :param res.partner partner: The partner to check. + :param sale.order order: The current order. + :return: The available delivery methods. + :rtype: delivery.carrier + """ + available_delivery_methods = super().available_carriers(partner, order) + is_gelato_order = any(order.order_line.product_id.mapped('gelato_product_uid')) + if is_gelato_order: + return available_delivery_methods.filtered(lambda m: m.delivery_type == 'gelato') + else: + return available_delivery_methods.filtered(lambda m: m.delivery_type != 'gelato') + + def gelato_rate_shipment(self, order): + """ Fetch the Gelato delivery price based on products, quantity and address. + + This method is called by `delivery`'s `rate_shipment` method. + + Note: `self._ensure_one()` from `rate_shipment` + + :param sale.order order: The order for which to fetch the delivery price. + :return: The shipment rate request results. + :rtype: dict + """ + if error_message := self._ensure_partner_address_is_complete(order.partner_id): + return { + 'success': False, + 'price': 0, + 'error_message': error_message, + } + + # Fetch the delivery price from Gelato. + payload = { + 'orderReferenceId': order.id, + 'customerReferenceId': f'Odoo Partner #{order.partner_id.id}', + 'currency': order.currency_id.name, + 'allowMultipleQuotes': 'true', + 'products': order._gelato_prepare_items_payload(), + 'recipient': order.partner_shipping_id._gelato_prepare_address_payload(), + } + try: + api_key = order.company_id.sudo().gelato_api_key # In sudo mode to read on the company. + order_data = utils.make_request(api_key, 'order', 'v4', 'orders:quote', payload=payload) + except UserError as e: + return { + 'success': False, + 'price': 0, + 'error_message': str(e), + } + + # Find the total delivery price by summing all products' matching methods' minimum price. + total_delivery_price = 0 + for quote_data in order_data['quotes']: + matching_shipment_method_prices = [ + shipment_method_data['price'] + for shipment_method_data in quote_data['shipmentMethods'] + if shipment_method_data['type'] == self.gelato_shipping_service_type + ] + if not matching_shipment_method_prices: + return { + 'success': False, + 'price': 0, + 'error_message': _("The delivery method is not available for this order."), + } + else: + total_delivery_price += min(matching_shipment_method_prices) + + return { + 'success': True, + 'price': total_delivery_price, + } + + @api.model + def _ensure_partner_address_is_complete(self, partner): + """ Ensure that all partner address fields required by Gelato are set. + + :param res.partner partner: The partner address to check. + :return: An error message if the address is incomplete, None otherwise. + :rtype: str | None + """ + required_address_fields = ['city', 'country_id', 'street'] + if partner.country_id.code not in const.COUNTRIES_WITHOUT_ZIPCODE: + required_address_fields.append('zip') + missing_fields = [ + partner._fields[field_name] + for field_name in required_address_fields if not partner[field_name] + ] + if missing_fields: + translated_field_names = [f._description_string(self.env) for f in missing_fields] + return _( + "The following required address fields are missing: %s", + ", ".join(translated_field_names), + ) diff --git a/addons/sale_gelato/models/product_document.py b/addons/sale_gelato/models/product_document.py new file mode 100644 index 0000000000000..363b08813cb76 --- /dev/null +++ b/addons/sale_gelato/models/product_document.py @@ -0,0 +1,28 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import _, fields, models +from odoo.exceptions import UserError + + +class ProductDocument(models.Model): + + _inherit = 'product.document' + + # Technical field to tell apart Gelato print images from other product documents. + is_gelato = fields.Boolean(readonly=True) + + def _gelato_prepare_file_payload(self): + """ Create the payload for a single file of an 'orders' request. + + :return: The file payload. + :rtype: dict + """ + if not self.datas: + raise UserError(_("Print images must be set on products before they can be ordered.")) + + query_string = f'access_token={self.ir_attachment_id.generate_access_token()[0]}' + url = f'{self.get_base_url()}{self.ir_attachment_id.image_src}?{query_string}' + return { + 'type': self.name.lower(), # Gelato requires lowercase types. + 'url': url, + } diff --git a/addons/sale_gelato/models/product_product.py b/addons/sale_gelato/models/product_product.py new file mode 100644 index 0000000000000..8f07f1580cf5e --- /dev/null +++ b/addons/sale_gelato/models/product_product.py @@ -0,0 +1,9 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ProductProduct(models.Model): + _inherit = 'product.product' + + gelato_product_uid = fields.Char(name="Gelato Product UID", readonly=True) diff --git a/addons/sale_gelato/models/product_template.py b/addons/sale_gelato/models/product_template.py new file mode 100644 index 0000000000000..34180fd0a0565 --- /dev/null +++ b/addons/sale_gelato/models/product_template.py @@ -0,0 +1,201 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import Command, _, api, fields, models +from odoo.exceptions import UserError +from odoo.osv import expression + +from odoo.addons.sale_gelato import utils + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + gelato_template_ref = fields.Char( + string="Gelato Template Reference", help="Synchronize to fetch variants from Gelato", + ) + gelato_product_uid = fields.Char( + string="Gelato Product UID", + compute='_compute_gelato_product_uid', + inverse='_inverse_gelato_product_uid', + readonly=True, + ) + gelato_image_ids = fields.One2many( + string="Gelato Print Images", + comodel_name='product.document', + inverse_name='res_id', + domain=[('is_gelato', '=', True)], + readonly=True, + ) + gelato_missing_images = fields.Boolean( + string="Missing Print Images", compute='_compute_gelato_missing_images', + ) + + # === COMPUTE METHODS === # + + @api.depends('product_variant_ids.gelato_product_uid') + def _compute_gelato_product_uid(self): + self._compute_template_field_from_variant_field('gelato_product_uid') + + def _inverse_gelato_product_uid(self): + self._set_product_variant_field('gelato_product_uid') + + @api.depends('gelato_image_ids') + def _compute_gelato_missing_images(self): + for product in self: + product.gelato_missing_images = any( + not image.datas for image in product.gelato_image_ids + ) + + # === ACTION METHODS === # + + def action_sync_gelato_template_info(self): + """ Fetch the template information from Gelato and update the product template accordingly. + + :return: The action to display a toast notification to the user. + :rtype: dict + """ + # Fetch the template info from Gelato. + try: + endpoint = f'templates/{self.gelato_template_ref}' + template_info = utils.make_request( + self.env.company.sudo().gelato_api_key, 'ecommerce', 'v1', endpoint, method='GET' + ) # In sudo mode to read the API key from the company. + except UserError as e: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'danger', + 'title': _("Could not synchronize with Gelato"), + 'message': str(e), + 'sticky': True, + } + } + + # Apply the necessary changes on the product template. + self._create_attributes_from_gelato_info(template_info) + self._create_print_images_from_gelato_info(template_info) + + # Display a toaster notification to the user if all went well. + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'title': _("Successfully synchronized with Gelato"), + 'message': _("Missing product variants and images have been successfully created."), + 'sticky': False, + 'next': { + 'type': 'ir.actions.client', + 'tag': 'soft_reload' + } + } + } + + # === BUSINESS METHODS === # + + def _create_attributes_from_gelato_info(self, template_info): + """ Create attributes for the current product template. + + :param dict template_info: The template information fetched from Gelato. + :return: None + """ + if len(template_info['variants']) == 1: # The template has no attribute. + self.gelato_product_uid = template_info['variants'][0]['productUid'] + else: # The template has multiple attributes. + # Iterate over the variants to find and create the possible attributes. + for variant_data in template_info['variants']: + current_variant_pavs = self.env['product.attribute.value'] + for attribute_data in variant_data['variantOptions']: # Attribute name and value. + # Search for the existing attribute with the proper variant creation policy and + # create it if not found. + attribute = self.env['product.attribute'].search( + [('name', '=', attribute_data['name']), ('create_variant', '=', 'always')], + limit=1, + ) + if not attribute: + attribute = self.env['product.attribute'].create({ + 'name': attribute_data['name'] + }) + + # Search for the existing attribute value and create it if not found. + attribute_value = self.env['product.attribute.value'].search([ + ('name', '=', attribute_data['value']), + ('attribute_id', '=', attribute.id), + ], limit=1) + if not attribute_value: + attribute_value = self.env['product.attribute.value'].create({ + 'name': attribute_data['value'], + 'attribute_id': attribute.id + }) + current_variant_pavs += attribute_value + + # Search for the existing PTAL and create it if not found. + ptal = self.env['product.template.attribute.line'].search( + [('product_tmpl_id', '=', self.id), ('attribute_id', '=', attribute.id)], + limit=1, + ) + if not ptal: + self.env['product.template.attribute.line'].create({ + 'product_tmpl_id': self.id, + 'attribute_id': attribute.id, + 'value_ids': [Command.link(attribute_value.id)] + }) + else: # The PTAL already exists. + ptal.value_ids = [Command.link(attribute_value.id)] # Link the value. + + # Find the variant that was automatically created and set the Gelato UID. + for variant in self.product_variant_ids: + corresponding_ptavs = variant.product_template_attribute_value_ids + corresponding_pavs = corresponding_ptavs.product_attribute_value_id + if corresponding_pavs == current_variant_pavs: + variant.gelato_product_uid = variant_data['productUid'] + break + + # Delete the incompatible variants that were created but not allowed by Gelato. + variants_without_gelato = self.env['product.product'].search([ + ('product_tmpl_id', '=', self.id), + ('gelato_product_uid', '=', False) + ]) + variants_without_gelato.unlink() + + def _create_print_images_from_gelato_info(self, template_info): + """ Create print image for the current product template. + + :param dict template_info: The template information fetched from Gelato. + :return: None + """ + # Iterate over the print image data listed in the info of the first variant, as we don't + # support varying image placements between variants. + for print_image_data in template_info['variants'][0]['imagePlaceholders']: + # Gelato might send image placements that are named '1' or 'front' that are not accepted + # by their API when placing order. + if print_image_data['printArea'].lower() in ('1', 'front'): + print_image_data['printArea'] = 'default' # Use 'default' which is accepted. + + # Gelato might send several print images for the same placement if several layers were + # defined, but we keep only one because their API only accepts one image per placement. + print_image_found = bool(self.env['product.document'].search_count([ + ('name', 'ilike', print_image_data['printArea']), + ('res_id', '=', self.id), + ('res_model', '=', 'product.template'), + ('is_gelato', '=', True), # Avoid finding regular documents with the same name. + ])) + if not print_image_found: + self.gelato_image_ids = [Command.create({ + 'name': print_image_data['printArea'].lower(), + 'res_id': self.id, + 'res_model': 'product.template', + 'is_gelato': True, + })] + + # === GETTER METHODS === # + + def _get_related_fields_variant_template(self): + """ Override of `product` to add `gelato_product_uid` as a related field. """ + return super()._get_related_fields_variant_template() + ['gelato_product_uid'] + + def _get_product_document_domain(self): + """ Override of `product` to filter out gelato print images. """ + domain = super()._get_product_document_domain() + return expression.AND([domain, [('is_gelato', '=', False)]]) diff --git a/addons/sale_gelato/models/res_company.py b/addons/sale_gelato/models/res_company.py new file mode 100644 index 0000000000000..b7781254705a3 --- /dev/null +++ b/addons/sale_gelato/models/res_company.py @@ -0,0 +1,10 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + gelato_api_key = fields.Char(string="Gelato API Key") + gelato_webhook_secret = fields.Char(string="Gelato Webhook Secret") diff --git a/addons/sale_gelato/models/res_partner.py b/addons/sale_gelato/models/res_partner.py new file mode 100644 index 0000000000000..fd58af241d9fe --- /dev/null +++ b/addons/sale_gelato/models/res_partner.py @@ -0,0 +1,25 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models + +from odoo.addons.payment import utils as payment_utils + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + def _gelato_prepare_address_payload(self): + first_name, last_name = payment_utils.split_partner_name(self.name) + return { + 'companyName': self.commercial_company_name or '', + 'firstName': first_name or last_name, # Gelato require a first name. + 'lastName': last_name, + 'addressLine1': self.street, + 'addressLine2': self.street2 or '', + 'state': self.state_id.code, + 'city': self.city, + 'postCode': self.zip, + 'country': self.country_id.code, + 'email': self.email, + 'phone': self.phone or '' + } diff --git a/addons/sale_gelato/models/sale_order.py b/addons/sale_gelato/models/sale_order.py new file mode 100644 index 0000000000000..169d71a704960 --- /dev/null +++ b/addons/sale_gelato/models/sale_order.py @@ -0,0 +1,118 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint + +from odoo import _, models +from odoo.exceptions import UserError, ValidationError + +from odoo.addons.sale_gelato import utils + + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = 'sale.order' + + # === CRUD METHODS === # + + def _prevent_mixing_gelato_and_non_gelato_products(self): + """ Ensure that the order lines don't mix Gelato and non-Gelato products. + + This method is not a constraint and is called from the `create` and `write` methods of + `sale.order.line` to cover the cases where adding/writing on order lines would not trigger a + constraint check (e.g., adding products through the Catalog). + + :return: None + :raise ValidationError: If Gelato and non-Gelato products are mixed. + """ + for order in self: + gelato_lines = order.order_line.filtered(lambda l: l.product_id.gelato_product_uid) + non_gelato_lines = (order.order_line - gelato_lines).filtered( + lambda l: l.product_id.sale_ok and l.product_id.type != 'service' + ) # Filter out non-saleable (sections, etc.) and non-deliverable products. + if gelato_lines and non_gelato_lines: + raise ValidationError( + _("You cannot mix Gelato products with non-Gelato products in the same order.")) + + # === ACTION METHODS === # + + def action_open_delivery_wizard(self): + """ Override of `delivery` to set a Gelato delivery method by default in the wizard. """ + res = super().action_open_delivery_wizard() + + if ( + not self.env.context.get('carrier_recompute') + and any(line.product_id.gelato_product_uid for line in self.order_line) + ): + gelato_delivery_method = self.env['delivery.carrier'].search( + [('delivery_type', '=', 'gelato')], limit=1 + ) + res['context']['default_carrier_id'] = gelato_delivery_method.id + return res + + def action_confirm(self): + """ Override of `sale` to send the order to Gelato on confirmation. """ + res = super().action_confirm() + for order in self.filtered( + lambda o: any(o.order_line.product_id.mapped('gelato_product_uid')) + ): + order._create_order_on_gelato() + return res + + # === BUSINESS METHODS === # + + def _create_order_on_gelato(self): + """ Send the order creation request to Gelato and log the request result on the chatter. + + :return: None + """ + delivery_line = self.order_line.filtered( + lambda l: l.is_delivery and l.product_id.default_code in ('normal', 'express') + ) + payload = { + 'orderType': 'order', + 'orderReferenceId': self.id, + 'customerReferenceId': f'Odoo Partner #{self.partner_id.id}', + 'currency': self.currency_id.name, + 'items': self._gelato_prepare_items_payload(), + 'shipmentMethodUid': delivery_line.product_id.default_code or 'cheapest', + 'shippingAddress': self.partner_shipping_id._gelato_prepare_address_payload(), + } + try: + api_key = self.company_id.sudo().gelato_api_key # In sudo mode to read on the company. + data = utils.make_request(api_key, 'order', 'v4', 'orders', payload=payload) + except UserError as e: + raise UserError(_( + "The order with reference %(order_reference)s was not sent to Gelato.\n" + "Reason: %(error_message)s", + order_reference=self.display_name, + error_message=str(e), + )) + + _logger.info("Notification received from Gelato with data:\n%s", pprint.pformat(data)) + self.message_post( + body=_("The order has been successfully passed on Gelato."), + author_id=self.env.ref('base.partner_root').id, + ) + + def _gelato_prepare_items_payload(self): + """ Create the payload for the 'items' key of an 'orders' request. + + :return: The items payload. + :rtype: dict + """ + items_payload = [] + for gelato_line in self.order_line.filtered(lambda l: l.product_id.gelato_product_uid): + item_data = { + 'itemReferenceId': gelato_line.product_id.id, + 'productUid': gelato_line.product_id.gelato_product_uid, + 'files': [ + image._gelato_prepare_file_payload() + for image in gelato_line.product_id.product_tmpl_id.gelato_image_ids + ], + 'quantity': int(gelato_line.product_uom_qty), + } + items_payload.append(item_data) + return items_payload diff --git a/addons/sale_gelato/models/sale_order_line.py b/addons/sale_gelato/models/sale_order_line.py new file mode 100644 index 0000000000000..ac06ed742e7f6 --- /dev/null +++ b/addons/sale_gelato/models/sale_order_line.py @@ -0,0 +1,20 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class SaleOrderLine(models.Model): + _inherit = 'sale.order.line' + + # === CRUD METHODS === # + + @api.model_create_multi + def create(self, vals_list): + order_lines = super().create(vals_list) + order_lines.order_id._prevent_mixing_gelato_and_non_gelato_products() + return order_lines + + def write(self, vals): + res = super().write(vals) + self.order_id._prevent_mixing_gelato_and_non_gelato_products() + return res diff --git a/addons/sale_gelato/static/description/icon.png b/addons/sale_gelato/static/description/icon.png new file mode 100644 index 0000000000000..875e2f8113680 Binary files /dev/null and b/addons/sale_gelato/static/description/icon.png differ diff --git a/addons/sale_gelato/static/description/icon.svg b/addons/sale_gelato/static/description/icon.svg new file mode 100644 index 0000000000000..3f3aaade3e7d7 --- /dev/null +++ b/addons/sale_gelato/static/description/icon.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/addons/sale_gelato/utils.py b/addons/sale_gelato/utils.py new file mode 100644 index 0000000000000..279a27bc0d639 --- /dev/null +++ b/addons/sale_gelato/utils.py @@ -0,0 +1,44 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +import requests + +from odoo import _ +from odoo.exceptions import UserError + + +_logger = logging.getLogger(__name__) + + +def make_request(api_key, subdomain, version, endpoint, payload=None, method='POST'): + """ Make a request to the Gelato API and return the JSON-formatted content of the response. + + :param str api_key: The Gelato API key used for signing requests. + :param str subdomain: The subdomain of the Gelato API. + :param str version: The version of the Gelato API. + :param str endpoint: The API endpoint to call. + :param dict payload: The payload of the request. + :param str method: The HTTP method of the request. + :return: The JSON-formatted content of the response. + :rtype: dict + """ + url = f'https://{subdomain}.gelatoapis.com/{version}/{endpoint}' + headers = { + 'X-API-KEY': api_key or None + } + try: + if method == 'GET': + response = requests.get(url=url, params=payload, headers=headers, timeout=10) + else: + response = requests.post(url=url, json=payload, headers=headers, timeout=10) + response_content = response.json() + try: + response.raise_for_status() + except requests.exceptions.HTTPError: + _logger.exception("Invalid API request at %s with data %s", url, payload) + raise UserError(response_content.get('message', '')) + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + _logger.exception("Unable to reach endpoint at %s", url) + raise UserError(_("Could not establish the connection to the Gelato API.")) + return response.json() diff --git a/addons/sale_gelato/views/delivery_carrier_views.xml b/addons/sale_gelato/views/delivery_carrier_views.xml new file mode 100644 index 0000000000000..8580212eabe89 --- /dev/null +++ b/addons/sale_gelato/views/delivery_carrier_views.xml @@ -0,0 +1,24 @@ + + + + + Delivery Carrier Form + delivery.carrier + + + + + + + + + + + + + + diff --git a/addons/sale_gelato/views/product_document_views.xml b/addons/sale_gelato/views/product_document_views.xml new file mode 100644 index 0000000000000..74590c7052ffa --- /dev/null +++ b/addons/sale_gelato/views/product_document_views.xml @@ -0,0 +1,17 @@ + + + + + Product Document Form + product.document + + +
+ + + +
+
+
+ +
diff --git a/addons/sale_gelato/views/product_product_views.xml b/addons/sale_gelato/views/product_product_views.xml new file mode 100644 index 0000000000000..88d63e7a1f336 --- /dev/null +++ b/addons/sale_gelato/views/product_product_views.xml @@ -0,0 +1,34 @@ + + + + + Product Product Normal Form + product.product + + + + + + + + + + + + Product Product Easy Form + product.product + + + + + + + + + + + diff --git a/addons/sale_gelato/views/product_template_views.xml b/addons/sale_gelato/views/product_template_views.xml new file mode 100644 index 0000000000000..badfa14d214d4 --- /dev/null +++ b/addons/sale_gelato/views/product_template_views.xml @@ -0,0 +1,69 @@ + + + + + Product Template Form + product.template + + + + + + + + + +