From 876ab5a1a2a60e4b48c98c99ad7ac3b9d5c4d4a2 Mon Sep 17 00:00:00 2001 From: Kimkhoi3010 Date: Wed, 25 Feb 2026 11:26:29 +0700 Subject: [PATCH 01/11] [FIX] website_sale_require_legal: fix race condition in UI tour test --- website_sale_require_legal/static/tests/tours/tour.js | 8 +++++--- website_sale_require_legal/tests/test_ui.py | 4 +--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website_sale_require_legal/static/tests/tours/tour.js b/website_sale_require_legal/static/tests/tours/tour.js index 0f251334ce..a3dde9ebd3 100644 --- a/website_sale_require_legal/static/tests/tours/tour.js +++ b/website_sale_require_legal/static/tests/tours/tour.js @@ -59,13 +59,15 @@ odoo.define("website_sale_require_legal.tour", function (require) { trigger: "div[name='o_checkbox_container'] input", }, { - trigger: ".btn-primary:contains('Pay Now')", + trigger: '#payment_method label:contains("Dummy Provider")', }, { - trigger: '#payment_method label:contains("Dummy Provider")', + trigger: 'button[name="o_payment_submit_button"]:not([disabled])', }, { - trigger: 'button[name="o_payment_submit_button"]', + content: "Wait for payment to be processed and redirect", + trigger: "form.oe_product_cart", + timeout: 30000, }, ]; diff --git a/website_sale_require_legal/tests/test_ui.py b/website_sale_require_legal/tests/test_ui.py index ed1f5d1693..9e5d8048fb 100644 --- a/website_sale_require_legal/tests/test_ui.py +++ b/website_sale_require_legal/tests/test_ui.py @@ -38,9 +38,7 @@ def setUp(self): # Create a dummy payment provider to ensure that the tour has at least one # available to it. arch = """ -
- - +
""" redirect_form = self.env["ir.ui.view"].create( From 590ab78c7e681b411d0861bccff6c0b050245a17 Mon Sep 17 00:00:00 2001 From: Kimkhoi3010 Date: Thu, 15 Jan 2026 12:26:31 +0700 Subject: [PATCH 02/11] [FIX] website_sale_cart_expire: fix eslint warnings --- .../static/src/js/website_sale_cart_expire.js | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/website_sale_cart_expire/static/src/js/website_sale_cart_expire.js b/website_sale_cart_expire/static/src/js/website_sale_cart_expire.js index a78b39bffc..b50bad0392 100644 --- a/website_sale_cart_expire/static/src/js/website_sale_cart_expire.js +++ b/website_sale_cart_expire/static/src/js/website_sale_cart_expire.js @@ -44,10 +44,11 @@ odoo.define("website_sale_cart_expire", (require) => { * @param {String|Date} expireDate */ _setExpirationDate: function (expireDate) { - if (typeof expireDate === "string") { - expireDate = time.str_to_datetime(expireDate); + let formattedExpireDate = expireDate; + if (typeof formattedExpireDate === "string") { + formattedExpireDate = time.str_to_datetime(formattedExpireDate); } - this.expireDate = expireDate ? moment(expireDate) : false; + this.expireDate = formattedExpireDate ? moment(formattedExpireDate) : false; }, /** * @returns {Number} @@ -83,7 +84,10 @@ odoo.define("website_sale_cart_expire", (require) => { } }, /** - * Updates the remaining time on the dom + * Updates the remaining time on the dom. + * + * @param {Number} remainingMs - Remaining time in milliseconds. + * @returns {void} */ _renderTimer: function (remainingMs) { const remainingMsRounded = Math.ceil(remainingMs / 1000) * 1000; From 0e3e3eb749f4f01ce7c0447c2179c054ed752a0b Mon Sep 17 00:00:00 2001 From: Kimkhoi3010 Date: Thu, 15 Jan 2026 12:11:16 +0700 Subject: [PATCH 03/11] [FIX] website_sale_product_assortment: fix eslint radix warning in VariantMixin --- website_sale_product_assortment/static/src/js/variant_mixin.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website_sale_product_assortment/static/src/js/variant_mixin.js b/website_sale_product_assortment/static/src/js/variant_mixin.js index a19961a47b..25094c3ad9 100644 --- a/website_sale_product_assortment/static/src/js/variant_mixin.js +++ b/website_sale_product_assortment/static/src/js/variant_mixin.js @@ -20,7 +20,7 @@ odoo.define("website_sale_product_assortment.VariantMixin", function (require) { const isMainProduct = combination.product_id && ($parent.is(".js_main_product") || $parent.is(".main_product")) && - combination.product_id === parseInt(product_id); + combination.product_id === parseInt(product_id, 10); if (!this.isWebsite || !isMainProduct) { return; } From 65a88ce877bb64b66c245a5110800fa9aaa67420 Mon Sep 17 00:00:00 2001 From: Kimkhoi3010 Date: Thu, 15 Jan 2026 14:10:24 +0700 Subject: [PATCH 04/11] [FIX] website_sale_*: remove useless $.when.apply --- .../static/src/js/assortment_list_preview.js | 6 +++--- .../static/src/js/website_sale_stock_list_preview.js | 5 +---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/website_sale_product_assortment/static/src/js/assortment_list_preview.js b/website_sale_product_assortment/static/src/js/assortment_list_preview.js index be239b0a3b..0e6c6c4365 100644 --- a/website_sale_product_assortment/static/src/js/assortment_list_preview.js +++ b/website_sale_product_assortment/static/src/js/assortment_list_preview.js @@ -8,10 +8,10 @@ odoo.define("website_sale_product_assortment.assortment_preview", function (requ selector: "#products_grid", start: function () { - return $.when.apply($, [ + return $.when( this._super.apply(this, arguments), - this.render_assortments(), - ]); + this.render_assortments() + ); }, render_assortments: function () { const $products = $(".o_wsale_product_grid_wrapper"); diff --git a/website_sale_stock_list_preview/static/src/js/website_sale_stock_list_preview.js b/website_sale_stock_list_preview/static/src/js/website_sale_stock_list_preview.js index 6979e962a7..ef2930f1c0 100644 --- a/website_sale_stock_list_preview/static/src/js/website_sale_stock_list_preview.js +++ b/website_sale_stock_list_preview/static/src/js/website_sale_stock_list_preview.js @@ -15,10 +15,7 @@ odoo.define("website_sale_stock_list_preview.shop_stock", function (require) { "click .btn.btn-primary.a-submit": "_onAddToCartClicked", }, start: function () { - return $.when.apply($, [ - this._super.apply(this, arguments), - this.render_stock(), - ]); + return $.when(this._super.apply(this, arguments), this.render_stock()); }, render_stock: function () { const $products = $(".o_wsale_product_grid_wrapper"); From 3c0b366317b56df8df6030bd2488d4de27cf9e24 Mon Sep 17 00:00:00 2001 From: Kimkhoi3010 Date: Mon, 18 Aug 2025 17:04:01 +0700 Subject: [PATCH 05/11] [ADD] sale_saleor: Add module sale_saleor --- sale_saleor/README.rst | 356 ++ sale_saleor/__init__.py | 7 + sale_saleor/__manifest__.py | 62 + sale_saleor/controllers/__init__.py | 4 + sale_saleor/controllers/saleor_webhook.py | 827 +++ .../data/condition_operation_type_data.xml | 31 + sale_saleor/data/saleor_abandoned_cron.xml | 13 + sale_saleor/helpers.py | 578 ++ sale_saleor/models/__init__.py | 43 + sale_saleor/models/account_tax.py | 98 + .../models/condition_operation_type.py | 18 + sale_saleor/models/delivery_carrier.py | 232 + sale_saleor/models/discount_rule.py | 119 + sale_saleor/models/discount_rule_condition.py | 98 + sale_saleor/models/loyalty_program.py | 115 + sale_saleor/models/product_attribute.py | 78 + sale_saleor/models/product_category.py | 131 + sale_saleor/models/product_collection.py | 132 + sale_saleor/models/product_image.py | 119 + sale_saleor/models/product_product.py | 427 ++ sale_saleor/models/product_template.py | 403 ++ sale_saleor/models/product_type_meta_line.py | 38 + sale_saleor/models/res_partner.py | 21 + sale_saleor/models/sale_order.py | 168 + sale_saleor/models/saleor_account.py | 5153 +++++++++++++++++ .../models/saleor_attribute_meta_line.py | 32 + .../models/saleor_category_meta_line.py | 24 + sale_saleor/models/saleor_channel.py | 244 + .../models/saleor_collection_meta_line.py | 28 + sale_saleor/models/saleor_gift_card.py | 208 + sale_saleor/models/saleor_gift_card_tag.py | 11 + .../models/saleor_giftcard_meta_line.py | 32 + .../models/saleor_product_meta_line.py | 28 + sale_saleor/models/saleor_product_type.py | 80 + .../models/saleor_shipping_meta_line.py | 24 + .../saleor_shipping_order_value_line.py | 34 + .../saleor_shipping_postal_code_range.py | 14 + .../models/saleor_shipping_pricing_line.py | 21 + sale_saleor/models/saleor_shipping_zone.py | 180 + .../models/saleor_shipping_zone_meta_line.py | 24 + sale_saleor/models/saleor_tax_meta_line.py | 32 + sale_saleor/models/saleor_voucher.py | 322 + sale_saleor/models/saleor_voucher_code.py | 51 + .../models/saleor_voucher_discount_line.py | 40 + .../models/saleor_voucher_meta_line.py | 32 + .../saleor_voucher_minimal_order_value.py | 29 + sale_saleor/models/stock_location.py | 210 + sale_saleor/models/stock_quant.py | 124 + sale_saleor/models/stock_warehouse.py | 179 + sale_saleor/pyproject.toml | 3 + sale_saleor/readme/CONFIGURE.md | 65 + sale_saleor/readme/CONTRIBUTORS.md | 2 + sale_saleor/readme/DESCRIPTION.md | 95 + sale_saleor/readme/USAGE.md | 98 + sale_saleor/security/ir.model.access.csv | 81 + sale_saleor/security/saleor_security.xml | 24 + sale_saleor/static/description/icon.png | Bin 0 -> 600 bytes sale_saleor/static/description/icon.svg | 13 + sale_saleor/static/description/index.html | 688 +++ .../static/src/scss/kanban_record.scss | 17 + .../static/src/scss/sale_saleor_backend.scss | 67 + sale_saleor/utils.py | 2968 ++++++++++ sale_saleor/views/account_tax_views.xml | 69 + sale_saleor/views/delivery_carrier_views.xml | 157 + sale_saleor/views/discount_rule_views.xml | 118 + sale_saleor/views/loyalty_program_views.xml | 105 + sale_saleor/views/product_attribute_views.xml | 70 + sale_saleor/views/product_category_views.xml | 86 + .../views/product_collection_views.xml | 120 + sale_saleor/views/product_image_views.xml | 108 + sale_saleor/views/product_template_views.xml | 132 + sale_saleor/views/product_views.xml | 58 + sale_saleor/views/sale_order_views.xml | 36 + sale_saleor/views/sale_saleor_menuitem.xml | 176 + sale_saleor/views/saleor_account_views.xml | 58 + sale_saleor/views/saleor_channel_views.xml | 113 + sale_saleor/views/saleor_gift_card_views.xml | 141 + sale_saleor/views/saleor_product_type.xml | 100 + .../views/saleor_shipping_zone_views.xml | 113 + sale_saleor/views/saleor_voucher_views.xml | 227 + sale_saleor/views/stock_location_views.xml | 61 + sale_saleor/views/stock_warehouse_views.xml | 57 + sale_saleor/wizard/__init__.py | 3 + sale_saleor/wizard/choose_delivery_carrier.py | 85 + .../wizard/choose_delivery_carrier.xml | 24 + .../voucher_code_auto_generate_wizard.py | 47 + .../voucher_code_auto_generate_wizard.xml | 31 + .../wizard/voucher_code_manual_wizard.py | 34 + .../wizard/voucher_code_manual_wizard.xml | 30 + 89 files changed, 17754 insertions(+) create mode 100644 sale_saleor/README.rst create mode 100644 sale_saleor/__init__.py create mode 100644 sale_saleor/__manifest__.py create mode 100644 sale_saleor/controllers/__init__.py create mode 100644 sale_saleor/controllers/saleor_webhook.py create mode 100644 sale_saleor/data/condition_operation_type_data.xml create mode 100644 sale_saleor/data/saleor_abandoned_cron.xml create mode 100644 sale_saleor/helpers.py create mode 100644 sale_saleor/models/__init__.py create mode 100644 sale_saleor/models/account_tax.py create mode 100644 sale_saleor/models/condition_operation_type.py create mode 100644 sale_saleor/models/delivery_carrier.py create mode 100644 sale_saleor/models/discount_rule.py create mode 100644 sale_saleor/models/discount_rule_condition.py create mode 100644 sale_saleor/models/loyalty_program.py create mode 100644 sale_saleor/models/product_attribute.py create mode 100644 sale_saleor/models/product_category.py create mode 100644 sale_saleor/models/product_collection.py create mode 100644 sale_saleor/models/product_image.py create mode 100644 sale_saleor/models/product_product.py create mode 100644 sale_saleor/models/product_template.py create mode 100644 sale_saleor/models/product_type_meta_line.py create mode 100644 sale_saleor/models/res_partner.py create mode 100644 sale_saleor/models/sale_order.py create mode 100644 sale_saleor/models/saleor_account.py create mode 100644 sale_saleor/models/saleor_attribute_meta_line.py create mode 100644 sale_saleor/models/saleor_category_meta_line.py create mode 100644 sale_saleor/models/saleor_channel.py create mode 100644 sale_saleor/models/saleor_collection_meta_line.py create mode 100644 sale_saleor/models/saleor_gift_card.py create mode 100644 sale_saleor/models/saleor_gift_card_tag.py create mode 100644 sale_saleor/models/saleor_giftcard_meta_line.py create mode 100644 sale_saleor/models/saleor_product_meta_line.py create mode 100644 sale_saleor/models/saleor_product_type.py create mode 100644 sale_saleor/models/saleor_shipping_meta_line.py create mode 100644 sale_saleor/models/saleor_shipping_order_value_line.py create mode 100644 sale_saleor/models/saleor_shipping_postal_code_range.py create mode 100644 sale_saleor/models/saleor_shipping_pricing_line.py create mode 100644 sale_saleor/models/saleor_shipping_zone.py create mode 100644 sale_saleor/models/saleor_shipping_zone_meta_line.py create mode 100644 sale_saleor/models/saleor_tax_meta_line.py create mode 100644 sale_saleor/models/saleor_voucher.py create mode 100644 sale_saleor/models/saleor_voucher_code.py create mode 100644 sale_saleor/models/saleor_voucher_discount_line.py create mode 100644 sale_saleor/models/saleor_voucher_meta_line.py create mode 100644 sale_saleor/models/saleor_voucher_minimal_order_value.py create mode 100644 sale_saleor/models/stock_location.py create mode 100644 sale_saleor/models/stock_quant.py create mode 100644 sale_saleor/models/stock_warehouse.py create mode 100644 sale_saleor/pyproject.toml create mode 100644 sale_saleor/readme/CONFIGURE.md create mode 100644 sale_saleor/readme/CONTRIBUTORS.md create mode 100644 sale_saleor/readme/DESCRIPTION.md create mode 100644 sale_saleor/readme/USAGE.md create mode 100644 sale_saleor/security/ir.model.access.csv create mode 100644 sale_saleor/security/saleor_security.xml create mode 100644 sale_saleor/static/description/icon.png create mode 100644 sale_saleor/static/description/icon.svg create mode 100644 sale_saleor/static/description/index.html create mode 100644 sale_saleor/static/src/scss/kanban_record.scss create mode 100644 sale_saleor/static/src/scss/sale_saleor_backend.scss create mode 100644 sale_saleor/utils.py create mode 100644 sale_saleor/views/account_tax_views.xml create mode 100644 sale_saleor/views/delivery_carrier_views.xml create mode 100644 sale_saleor/views/discount_rule_views.xml create mode 100644 sale_saleor/views/loyalty_program_views.xml create mode 100644 sale_saleor/views/product_attribute_views.xml create mode 100644 sale_saleor/views/product_category_views.xml create mode 100644 sale_saleor/views/product_collection_views.xml create mode 100644 sale_saleor/views/product_image_views.xml create mode 100644 sale_saleor/views/product_template_views.xml create mode 100644 sale_saleor/views/product_views.xml create mode 100644 sale_saleor/views/sale_order_views.xml create mode 100644 sale_saleor/views/sale_saleor_menuitem.xml create mode 100644 sale_saleor/views/saleor_account_views.xml create mode 100644 sale_saleor/views/saleor_channel_views.xml create mode 100644 sale_saleor/views/saleor_gift_card_views.xml create mode 100644 sale_saleor/views/saleor_product_type.xml create mode 100644 sale_saleor/views/saleor_shipping_zone_views.xml create mode 100644 sale_saleor/views/saleor_voucher_views.xml create mode 100644 sale_saleor/views/stock_location_views.xml create mode 100644 sale_saleor/views/stock_warehouse_views.xml create mode 100644 sale_saleor/wizard/__init__.py create mode 100644 sale_saleor/wizard/choose_delivery_carrier.py create mode 100644 sale_saleor/wizard/choose_delivery_carrier.xml create mode 100644 sale_saleor/wizard/voucher_code_auto_generate_wizard.py create mode 100644 sale_saleor/wizard/voucher_code_auto_generate_wizard.xml create mode 100644 sale_saleor/wizard/voucher_code_manual_wizard.py create mode 100644 sale_saleor/wizard/voucher_code_manual_wizard.xml diff --git a/sale_saleor/README.rst b/sale_saleor/README.rst new file mode 100644 index 0000000000..6bdac017ed --- /dev/null +++ b/sale_saleor/README.rst @@ -0,0 +1,356 @@ +================ +Saleor Connector +================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3121e15b61abc31e2b22374b9a2b21d602f1e17a276a9e4b3a3436917b32691d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fe--commerce-lightgray.png?logo=github + :target: https://github.com/OCA/e-commerce/tree/18.0/sale_saleor + :alt: OCA/e-commerce +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/e-commerce-18-0/e-commerce-18-0-sale_saleor + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/e-commerce&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Saleor Connector for Odoo +========================= + +The ``sale_saleor`` module provides a two-way connector between Odoo and +the Saleor e-commerce platform. It focuses on synchronizing sales +channels, orders, payments, promotions, and vouchers while keeping Odoo +as the central business backend, with explicit flows in both directions: + +- From Odoo to Saleor: push sales orders, payment status, product + variant stock levels by warehouse, loyalty programs and vouchers + defined in Odoo. +- From Saleor to Odoo: receive orders, order updates and payment events + via webhooks and reflect them in ``sale.order`` records in Odoo. + +Scope +----- + +This module does not replace Odoo's standard sales and inventory flows. +Instead, it extends them so that you can: + +- Link a single Saleor account to your Odoo database. +- Synchronize Saleor channels with Odoo currencies, countries, + warehouses and locations. +- Exchange order and payment information between Saleor and Odoo. +- Push Odoo loyalty programs and vouchers to Saleor promotions and + vouchers. +- Mark Saleor-origin quotations as abandoned in Odoo based on + per-channel delays. + +Key Features +------------ + +- **Saleor account management (``saleor.account``)** + + - Stores the Saleor base URL, credentials and SSL verification + settings. + - Automatically generates webhook target URLs (customer, order, draft + order, payment) from the configured Odoo base URL. + - Manages the Saleor App ID, token, webhook IDs and shared secret used + for HMAC verification. + - Enforces that only one Saleor account can be active at a time. + +- **Saleor channels (``saleor.channel``)** + + - Maps Saleor channels to Odoo currencies, default countries, shipping + zones, warehouses and locations. + - Synchronizes channels to Saleor, including linked + warehouses/locations that are marked as Saleor warehouses. + - Prevents changing the currency once a channel has been synced to + Saleor (unless explicitly bypassed from context). + - Provides a cron job to mark Saleor quotations as abandoned based on + a channel-specific delay. + +- **Sales orders (``sale.order``)** + + - Extends sales orders with fields such as ``saleor_order_id``, + ``saleor_channel_id`` and detailed Saleor payment state. + - Provides an action to push orders from Odoo to Saleor by creating + and completing a draft order with addresses and order lines. + - Provides an action to mark the related Saleor order as paid from + Odoo. + - Validates required data before syncing (Saleor channel, product + variants, address requirements for specific countries, etc.). + +- **Webhooks from Saleor** + + - ``/saleor/webhook/order_created_updated`` handles ``ORDER_CREATED`` + and ``ORDER_UPDATED`` events: + + - Fetches full order details from Saleor via API. + - Skips orders that are explicitly marked as originating from Odoo + in metadata (to avoid loops). + - Creates or updates the corresponding ``sale.order`` in Odoo. + + - ``/saleor/webhook/order_payment`` handles ``ORDER_PAID`` and + ``ORDER_FULLY_PAID`` events: + + - Locates the related ``sale.order`` using ``saleor_order_id``. + - Updates payment-related fields and posts messages on the order. + +- **Promotions and loyalty programs (``loyalty.program``)** + + - Supports programs of type ``saleor``. + - Builds a minimal promotion payload (type, description, validity + dates) to reduce compatibility issues across Saleor versions. + - Synchronizes programs to Saleor promotions and upserts promotion + rules. + +- **Saleor vouchers (``saleor.voucher``)** + + - Prepares Saleor voucher payloads including discount type/value, date + and usage limits, countries, channel listings and requirements. + - Collects and sends voucher codes, and adds additional codes after + creation/update when needed. + - Automatically activates voucher codes and ensures a start date is + set. + +- **Stock and variants** + + - Provides a job to update Saleor variant stock quantities by + warehouse. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Initial Configuration +--------------------- + +Configure the Saleor account + +:: + + + 1. Open the menu that manages ``saleor.account`` records. + 2. Create a new record with at least: + + * **Name**: a descriptive name (for example, ``Saleor Production``). + * **Saleor Base URL**: the base URL of the Saleor API. + * **Email / Password**: credentials of a Saleor staff user to obtain JWT + tokens (or an app token if supported by your setup). + * **Odoo Base URL**: the public URL of the Odoo instance. + + 3. Enable the **Active** flag on the account that should be used in + production. Only one Saleor account can be active at a time. + + Once saved, the module will compute webhook target URLs (customer, order, + draft order, payment) from the Odoo base URL. You should configure + corresponding webhooks in Saleor using these URLs and the shared secret. + + Configure Saleor channels + ~~~~~~~~~~~~~~~~~~~~~~~~~ + + 1. Open the ``saleor.channel`` menu. + 2. Create a channel and configure: + + * **Name** and **Slug** to match the channel in Saleor. + * **Status** set to *Active* when the channel is ready to sync. + * **Currency** and **Default Country** to match Saleor settings. + * **Shipping Zones** using corresponding Saleor shipping zones. + * **Warehouses / Locations** that are marked as Saleor warehouses and have a + remote Saleor warehouse ID. + + 3. Save the channel. When a channel is created in *Active* status or key + fields are changed, the connector will automatically synchronize it to + Saleor. + + .. warning:: + + Once a channel has been synchronized to Saleor, its currency cannot be + changed unless explicitly bypassed via technical context. + + Configure promotions (optional) + +- For loyalty programs and promotions based on ``loyalty.program``: + + - Create programs with ``program_type = 'saleor'``. + - Configure the discount type (catalogue/order), description, date + range, rules and channels. + - Use the *Saleor Promotion Sync* action to push programs to Saleor + promotions. Batch synchronization is supported. + +Configure vouchers (optional) + +:: + + + * For vouchers based on ``saleor.voucher``: + + * Define the voucher type and value, limits, minimum requirements, countries + and channel listings. + * Add one or more voucher codes; the module will automatically activate + codes and set a start date if missing. + * Use the *Saleor Sync* action on vouchers to push them to Saleor. + +Usage +===== + +Requirements +------------ + +- A running Saleor instance reachable from the Odoo server. +- An Odoo URL that Saleor can reach in order to call webhooks. + +Main Flows +---------- + +Pushing orders from Odoo to Saleor + +:: + + + This flow is used when orders are created in Odoo but should also exist in + Saleor: + + 1. Create a ``sale.order`` in Odoo as usual. + 2. Set the **Saleor Channel** field to a synced ``saleor.channel``. + 3. Ensure all products that must be sent to Saleor have a + ``saleor_variant_id``. + 4. Use the *Sync to Saleor* action on the order. + + The connector will: + + * Build the order payload including billing/shipping addresses, order lines + (variant and quantity) and customer identity (user or email). + * Create or update a draft order in Saleor, apply the shipping method and + complete the order. + * Store the ``saleor_order_id`` on the Odoo order and post links to the Saleor + dashboard in the chatter. + + You can also use the *Mark paid in Saleor* action to notify Saleor that the + order has been paid in Odoo (for example, offline payments). + + Note: + For customers in certain countries (for example, US/CA), a state/province + may be required. The connector validates this and raises an error if needed + information is missing. + + Receiving orders and updates from Saleor (webhooks) + +When Saleor is the main source of order creation: + +1. In Saleor, configure webhooks: + + - **Order created/updated** → + ``/saleor/webhook/order_created_updated``. + - **Order paid / fully paid** → ``/saleor/webhook/order_payment``. + +2. Use the same App/account and secret that are stored on the + ``saleor.account`` in Odoo. + +When events occur: + +- ``ORDER_CREATED`` / ``ORDER_UPDATED``: + + - Odoo fetches the full order from Saleor. + - The connector creates or updates a ``sale.order`` in Odoo. + +- ``ORDER_PAID`` / ``ORDER_FULLY_PAID``: + + - The connector locates the related ``sale.order`` using + ``saleor_order_id``. + - Payment-related fields are updated and a message is posted in the + chatter. + +Orders explicitly marked as originating from Odoo (metadata +``odoo_origin``) are ignored by the webhook flow to avoid loops. + +Abandoned cart / quotation handling + +:: + + + The cron method ``cron_mark_abandoned_saleor_orders`` periodically: + + * Finds Saleor-origin quotations (with ``saleor_order_id``) still in + ``draft``/``sent`` state and not yet marked as abandoned. + * Compares their age with the ``abandoned_cart_delay_hours`` configured on the + related ``saleor.channel``. + * Marks qualifying quotations as abandoned and posts an explanatory message on + each order. + + Stock Synchronization + --------------------- + + The connector exposes a job to update Saleor product variant stock quantities + by warehouse (``job_variant_stock_update``) to push stock changes to Saleor. + + Best Practices + -------------- + + * Keep exactly one ``saleor.account`` active to avoid ambiguity when + processing webhooks. + * Ensure ``odoo_base_url`` points to the external URL that Saleor can reach + and configure SSL verification appropriately. + * Avoid changing channel currencies after initial synchronization. + * Regularly verify that product variants, warehouses and locations are synced + and have their corresponding Saleor IDs. + * Monitor logs and queue jobs for synchronization errors and fix data issues + early. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Kencove + +Contributors +------------ + +- `Trobz `__: + + - Khoi (Kien Kim) + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/e-commerce `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/sale_saleor/__init__.py b/sale_saleor/__init__.py new file mode 100644 index 0000000000..264a7321d4 --- /dev/null +++ b/sale_saleor/__init__.py @@ -0,0 +1,7 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import models +from . import utils +from . import controllers +from . import wizard diff --git a/sale_saleor/__manifest__.py b/sale_saleor/__manifest__.py new file mode 100644 index 0000000000..05a53718aa --- /dev/null +++ b/sale_saleor/__manifest__.py @@ -0,0 +1,62 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +{ + "name": "Saleor Connector", + "summary": """ + Synchronize categories, collections, and products with Saleor + """, + "category": "Sales/Sales", + "author": "Kencove", # pylint:disable=C8101 + "version": "18.0.1.0.0", + "depends": [ + "account", + "base", + "delivery", + "loyalty", + "mail", + "product", + "queue_job", + "sale", + "stock", + ], + "data": [ + "security/saleor_security.xml", + "security/ir.model.access.csv", + "data/condition_operation_type_data.xml", + "data/saleor_abandoned_cron.xml", + "views/saleor_account_views.xml", + "views/product_views.xml", + "views/product_category_views.xml", + "views/product_collection_views.xml", + "views/product_template_views.xml", + "views/product_attribute_views.xml", + "views/product_image_views.xml", + "views/account_tax_views.xml", + "views/saleor_channel_views.xml", + "views/saleor_shipping_zone_views.xml", + "views/delivery_carrier_views.xml", + "views/stock_location_views.xml", + "views/stock_warehouse_views.xml", + "views/loyalty_program_views.xml", + "views/discount_rule_views.xml", + "wizard/voucher_code_auto_generate_wizard.xml", + "wizard/voucher_code_manual_wizard.xml", + "wizard/choose_delivery_carrier.xml", + "views/saleor_voucher_views.xml", + "views/saleor_gift_card_views.xml", + "views/saleor_product_type.xml", + "views/sale_saleor_menuitem.xml", + "views/sale_order_views.xml", + ], + "external_dependencies": { + "python": ["bs4", "text_unidecode"], + }, + "assets": { + "web.assets_backend": [ + "sale_saleor/static/src/scss/sale_saleor_backend.scss", + "sale_saleor/static/src/scss/kanban_record.scss", + ], + }, + "license": "AGPL-3", + "installable": True, +} diff --git a/sale_saleor/controllers/__init__.py b/sale_saleor/controllers/__init__.py new file mode 100644 index 0000000000..da15bb52fe --- /dev/null +++ b/sale_saleor/controllers/__init__.py @@ -0,0 +1,4 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import saleor_webhook diff --git a/sale_saleor/controllers/saleor_webhook.py b/sale_saleor/controllers/saleor_webhook.py new file mode 100644 index 0000000000..147da92172 --- /dev/null +++ b/sale_saleor/controllers/saleor_webhook.py @@ -0,0 +1,827 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import hmac +import json +import logging +from hashlib import sha256 +from urllib.parse import urlparse + +from odoo import fields, http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def _saleor_match_account(headers): + """Match incoming webhook to an active Saleor account by exact origin. + + - Accept either Saleor-Domain or Saleor-Api-Url headers (including X- variants). + - Normalize both header and account base_url to an origin string: + scheme://host[:port] (no path, no trailing slash), lowercased. + - Compare origins exactly; no fallback to the first active account. + """ + Account = request.env["saleor.account"].sudo() + + header_val = ( + headers.get("Saleor-Domain") + or headers.get("X-Saleor-Domain") + or headers.get("Saleor-Api-Url") + or headers.get("X-Saleor-Api-Url") + ) + if not header_val: + return None + + def to_origin(value: str) -> str: + v = (value or "").strip() + # If value lacks scheme, assume https for normalization + if not v.lower().startswith(("http://", "https://")): + v = "https://" + v + try: + p = urlparse(v) + if not p.hostname: + return "" + scheme = (p.scheme or "https").lower() + host = (p.hostname or "").lower() + # Preserve explicit port if present and non-default for scheme + port = p.port + if port and not ( + (scheme == "http" and port == 80) or (scheme == "https" and port == 443) + ): + netloc = f"{host}:{port}" + else: + netloc = host + return f"{scheme}://{netloc}" + except Exception: + return "" + + hdr_origin = to_origin(header_val) + if not hdr_origin: + return None + + candidates = Account.search([("active", "=", True), ("base_url", "!=", False)]) + for acc in candidates: + acc_origin = to_origin(acc.base_url) + if acc_origin and acc_origin == hdr_origin: + return acc + return None + + +def _saleor_verify_signature(account, headers, body): + secret_str = (account.saleor_webhook_secret or "").strip() + if not secret_str: + _logger.error( + "Saleor webhook: secret not configured for account %s", account.id + ) + resp = request.make_response( + "secret not configured", + headers=[("Content-Type", "text/plain")], + status=500, + ) + return False, resp + + secret = secret_str.encode("utf-8") + sig_header = headers.get("Saleor-Signature") or headers.get("X-Saleor-Signature") + if not sig_header: + _logger.warning( + "Saleor webhook: secret configured but signature header missing" + ) + resp = request.make_response( + "missing signature", headers=[("Content-Type", "text/plain")], status=401 + ) + return False, resp + try: + expected_hex = hmac.new(secret, body, sha256).hexdigest() + # Some deployments prefix with algo, some do not; some may send base64 + sig_value = sig_header.strip() + if "=" in sig_value: + # e.g., sha256= + sig_value = sig_value.split("=", 1)[-1].strip() + valid = False + # Try hex compare + if hmac.compare_digest(expected_hex, sig_value): + valid = True + else: + # Try base64 + try: + import base64 + + expected_b64 = base64.b64encode(bytes.fromhex(expected_hex)).decode( + "ascii" + ) + if hmac.compare_digest(expected_b64, sig_value): + valid = True + except Exception: + valid = False + if not valid: + _logger.warning("Invalid Saleor webhook signature: %s", sig_header) + resp = request.make_response( + "bad signature", headers=[("Content-Type", "text/plain")], status=401 + ) + return False, resp + except Exception as e: + _logger.exception("Error verifying Saleor signature: %s", e) + resp = request.make_response( + "error", headers=[("Content-Type", "text/plain")], status=400 + ) + return False, resp + return True, None + + +def _saleor_parse_payload(body, headers): + try: + payload = json.loads(body.decode("utf-8") or "{}") + except Exception: + payload = {} + payload_event = payload.get("event") if isinstance(payload, dict) else None + event_type_raw = ( + headers.get("Saleor-Event") + or headers.get("X-Saleor-Event") + or headers.get("Event-Type") + or headers.get("X-Event-Type") + or payload_event + ) + event_type = (event_type_raw or "").upper() + if isinstance(payload, list): + data = payload + elif isinstance(payload, dict): + data = payload.get("payload") or payload.get("data") or {} + else: + data = {} + return event_type, event_type_raw, data, payload + + +def _saleor_extract_order_id(data, payload): + """Extract Saleor order ID from webhook payload data structures.""" + try: + if isinstance(data, dict): + return (data.get("order") or {}).get("id") or data.get("id") + if isinstance(data, list) and data: + first = data[0] or {} + return (first.get("order") or {}).get("id") or first.get("id") + if isinstance(payload, dict): + return (payload.get("order") or {}).get("id") or payload.get("id") + except Exception: + return None + return None + + +def _saleor_parse_order_payment_details(account, order_id, data): + """Return (status, number, payment_dict) using payload first then API fallback.""" + + def _first(lst): + return (lst or [{}])[0] or {} + + status = None + number = None + pay = {} + try: + data_obj = None + if isinstance(data, dict): + data_obj = data + elif isinstance(data, list) and data: + data_obj = _first(data) + if isinstance(data_obj, dict): + status = data_obj.get("status") or status + num_val = data_obj.get("number") + if num_val is not None: + number = str(num_val) + pays = data_obj.get("payments") or [] + if isinstance(pays, list) and pays: + pay = _first(pays) + except Exception as pe: + _logger.debug("Saleor webhook payload parse error: %s", pe) + + if not status or not number: + try: + client = account._get_client() + account._refresh_token(client) + api_order = client.order_get_by_id(order_id) or {} + status = status or api_order.get("status") + number = number or api_order.get("number") + except Exception as e: + _logger.debug("Saleor API fetch error for order %s: %s", order_id, e) + return status, number, pay + + +def _saleor_build_payment_vals(so, status, number, pay, raw_body): + """Build values dict to write on sale.order from parsed payment info.""" + vals = {} + # Basic order vals + vals.update(_saleor_basic_order_vals(so, status, number)) + # Payment fields + vals.update(_saleor_payment_field_vals(so, pay)) + # Meta (payload, timestamp) + vals.update(_saleor_payment_meta_vals(so, raw_body)) + return vals + + +def _saleor_basic_order_vals(so, status, number): + vals = {} + if hasattr(so, "saleor_mark_as_paid"): + vals["saleor_mark_as_paid"] = True + if hasattr(so, "saleor_status") and status: + vals["saleor_status"] = status + if hasattr(so, "saleor_number") and number is not None: + vals["saleor_number"] = str(number) + return vals + + +def _saleor_payment_field_vals(so, pay): + vals = {} + try: + if isinstance(pay, dict) and pay: + pid, gateway, cstatus, tot, cap, curr, psp = _saleor_extract_payment_fields( + pay + ) + if hasattr(so, "saleor_payment_id") and pid: + vals["saleor_payment_id"] = pid + if hasattr(so, "saleor_payment_gateway") and gateway: + vals["saleor_payment_gateway"] = gateway + if hasattr(so, "saleor_payment_charge_status") and cstatus: + vals["saleor_payment_charge_status"] = cstatus + if hasattr(so, "saleor_payment_total") and tot is not None: + vals["saleor_payment_total"] = tot + if hasattr(so, "saleor_payment_captured_amount") and cap is not None: + vals["saleor_payment_captured_amount"] = cap + if hasattr(so, "saleor_payment_currency") and curr: + vals["saleor_payment_currency"] = curr + if hasattr(so, "saleor_payment_psp_reference") and psp: + vals["saleor_payment_psp_reference"] = psp + except Exception as pe: + _logger.debug("Saleor payment parse error: %s", pe) + return vals + + +def _saleor_payment_meta_vals(so, raw_body): + vals = {} + try: + if hasattr(so, "saleor_payment_payload") and raw_body: + vals["saleor_payment_payload"] = raw_body + if hasattr(so, "saleor_payment_updated"): + vals["saleor_payment_updated"] = fields.Datetime.now() + except Exception as te: + _logger.debug("Saleor payment meta store error: %s", te) + return vals + + +def _saleor_extract_payment_fields(pay): + """Extract normalized payment fields from Saleor payment dict.""" + pid = pay.get("id") + gateway = pay.get("gateway") + cstatus = pay.get("charge_status") + + def _to_float(x): + try: + return float(x) + except Exception: + return None + + tot = _to_float(pay.get("total")) + cap = _to_float(pay.get("captured_amount")) + curr = pay.get("currency") + psp = pay.get("psp_reference") + return pid, gateway, cstatus, tot, cap, curr, psp + + +def _saleor_post_payment_message(so, status, number): + try: + msg = "Saleor payment update received" + details = [] + if status: + details.append(f"status: {status}") + if number is not None: + details.append(f"number: {number}") + if details: + msg = f"{msg} ({', '.join(details)})" + so.message_post(body=msg) + except Exception as me: + _logger.debug( + "Failed to post payment update chatter on sale.order %s: %s", + so.id, + me, + ) + + +def _saleor_extract_customer(data, payload, account): + # Normalize data entry + if isinstance(data, list): + data_norm = data[0] if data else {} + elif isinstance(data, dict): + data_norm = data + else: + data_norm = {} + # Determine id + customer_id = ( + data_norm.get("id") + or (data_norm.get("user") or {}).get("id") + or (payload.get("id") if isinstance(payload, dict) else None) + ) + if not customer_id: + return None, None, None, None + # Build from payload + try: + cust = { + "id": customer_id, + "email": data_norm.get("email") or data_norm.get("emailAddress"), + "firstName": data_norm.get("first_name") or data_norm.get("firstName"), + "lastName": data_norm.get("last_name") or data_norm.get("lastName"), + } + ship = ( + data_norm.get("default_shipping_address") + or data_norm.get("defaultShippingAddress") + or {} + ) + bill = ( + data_norm.get("default_billing_address") + or data_norm.get("defaultBillingAddress") + or {} + ) + cust["defaultShippingAddress"] = ship + cust["defaultBillingAddress"] = bill + if not cust.get("email") and account: + client = account._get_client() + api_cust = client.customer_get_by_id(customer_id) or {} + if api_cust: + cust = api_cust + ship = cust.get("defaultShippingAddress") or {} + bill = cust.get("defaultBillingAddress") or {} + except Exception as e: + _logger.exception("Saleor webhook: error normalizing payload: %s", e) + cust, ship, bill = {}, {}, {} + if not cust: + return customer_id, None, None, None + return customer_id, cust, ship, bill + + +def _resolve_country_state(a): + Country = request.env["res.country"].sudo() + State = request.env["res.country.state"].sudo() + country_id = False + state_id = False + c = a.get("country") + if c: + dom = [ + "|", + ("code", "=ilike", str(c).strip()), + ("name", "=ilike", str(c).strip()), + ] + country = Country.search(dom, limit=1) + if country: + country_id = country.id + s = a.get("state") + if s: + sdom = [ + ("country_id", "=", country.id), + "|", + ("code", "=ilike", str(s).strip()), + ("name", "=ilike", str(s).strip()), + ] + state = State.search(sdom, limit=1) + if state: + state_id = state.id + return country_id, state_id + + +def _norm_addr(addr): + if not isinstance(addr, dict): + return {} + return { + "first_name": addr.get("first_name") or addr.get("firstName"), + "last_name": addr.get("last_name") or addr.get("lastName"), + "company_name": addr.get("company_name") or addr.get("companyName"), + "street1": addr.get("street_address_1") or addr.get("streetAddress1"), + "street2": addr.get("street_address_2") or addr.get("streetAddress2"), + "city": addr.get("city"), + "zip": addr.get("postal_code") or addr.get("postalCode"), + "country": addr.get("country") or ((addr.get("countryInfo") or {}).get("code")), + "state": addr.get("country_area") or addr.get("countryArea"), + "phone": addr.get("phone") or addr.get("phoneNumber"), + } + + +def _build_partner_vals(cust, shipping, billing): + vals = {} + email = cust.get("email") + first = cust.get("firstName") or cust.get("first_name") or "" + last = cust.get("lastName") or cust.get("last_name") or "" + name = (first + " " + last).strip() or email + if name: + vals["name"] = name + if email: + vals["email"] = email + phone = None + if isinstance(shipping, dict): + phone = shipping.get("phone") or shipping.get("phoneNumber") + if not phone and isinstance(billing, dict): + phone = billing.get("phone") or billing.get("phoneNumber") + if phone: + vals["phone"] = phone + return vals + + +def _find_or_create_partner(account, customer_id, vals): + Partner = request.env["res.partner"].sudo() + partner = Partner.search( + [ + ("saleor_customer_id", "=", customer_id), + ("saleor_account_id", "=", account.id), + ], + limit=1, + ) + if partner: + partner.write(vals) + return partner + vals = dict( + vals, + saleor_customer_id=customer_id, + saleor_account_id=account.id, + type="contact", + company_type="person", + ) + return Partner.create(vals) + + +def _update_main_address(partner, shipping, billing): + ship_norm = _norm_addr(shipping) + bill_norm = _norm_addr(billing) + main_addr = ship_norm or bill_norm + if not main_addr: + return + addr_vals = {} + if main_addr.get("street1"): + addr_vals["street"] = main_addr.get("street1") + if main_addr.get("street2"): + addr_vals["street2"] = main_addr.get("street2") + if main_addr.get("city"): + addr_vals["city"] = main_addr.get("city") + if main_addr.get("zip"): + addr_vals["zip"] = main_addr.get("zip") + c_id, s_id = _resolve_country_state(main_addr) + if c_id: + addr_vals["country_id"] = c_id + if s_id: + addr_vals["state_id"] = s_id + if addr_vals: + partner.write(addr_vals) + + +def _upsert_child_contact(partner, kind, data_norm): + if not data_norm: + return + Partner = request.env["res.partner"].sudo() + child = Partner.search( + [ + ("parent_id", "=", partner.id), + ("type", "=", kind), + ], + limit=1, + ) + ch_vals = {} + if data_norm.get("street1"): + ch_vals["street"] = data_norm.get("street1") + if data_norm.get("street2"): + ch_vals["street2"] = data_norm.get("street2") + if data_norm.get("city"): + ch_vals["city"] = data_norm.get("city") + if data_norm.get("zip"): + ch_vals["zip"] = data_norm.get("zip") + c_id, s_id = _resolve_country_state(data_norm) + if c_id: + ch_vals["country_id"] = c_id + if s_id: + ch_vals["state_id"] = s_id + if data_norm.get("phone"): + ch_vals["phone"] = data_norm.get("phone") + name_parts = [ + p for p in [data_norm.get("first_name"), data_norm.get("last_name")] if p + ] + if name_parts: + ch_vals["name"] = " ".join(name_parts) + if data_norm.get("company_name"): + ch_vals["company_name"] = data_norm.get("company_name") + if not ch_vals: + return + if child: + child.write(ch_vals) + else: + ch_vals.update({"type": kind, "parent_id": partner.id}) + Partner.create(ch_vals) + + +def _upsert_partner_and_addresses(account, customer_id, cust, ship, bill): + shipping = ( + cust.get("defaultShippingAddress") or cust.get("default_shipping_address") or {} + ) + billing = ( + cust.get("defaultBillingAddress") or cust.get("default_billing_address") or {} + ) + vals = _build_partner_vals(cust, shipping, billing) + _logger.info( + "Saleor webhook: upserting partner for customer %s, vals=%s", + customer_id, + {k: vals[k] for k in vals.keys() if k in ("name", "email", "phone")}, + ) + partner = _find_or_create_partner(account, customer_id, vals) + _update_main_address(partner, shipping, billing) + _upsert_child_contact(partner, "delivery", _norm_addr(shipping)) + _upsert_child_contact(partner, "invoice", _norm_addr(billing)) + + +class SaleorWebhookController(http.Controller): + @http.route( + "/saleor/webhook/order_created_updated", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def saleor_order_created_updated(self, **kwargs): # pylint: disable=unused-argument + body = request.httprequest.get_data() or b"" + headers = request.httprequest.headers + account = _saleor_match_account(headers) + if not account: + _logger.warning("Saleor order webhook: no active account matched") + return request.make_response( + "no account", headers=[("Content-Type", "text/plain")], status=404 + ) + ok, resp = _saleor_verify_signature(account, headers, body) + if not ok: + return resp + event_type, event_type_raw, data, payload = _saleor_parse_payload(body, headers) + _logger.info( + "Saleor order webhook: event=%s raw=%s", event_type, event_type_raw + ) + + if event_type not in {"ORDER_CREATED", "ORDER_UPDATED"}: + return request.make_response( + "ignored", headers=[("Content-Type", "text/plain")], status=200 + ) + + # Extract order id and fetch full order from API + order_id = _saleor_extract_order_id(data, payload) + if not order_id: + return request.make_response( + "no id", headers=[("Content-Type", "text/plain")], status=200 + ) + try: + client = account._get_client() + order = client.order_get_by_id(order_id) or {} + except Exception as e: + _logger.exception( + "Saleor order webhook: failed to fetch order %s: %s", order_id, e + ) + return request.make_response( + "error", headers=[("Content-Type", "text/plain")], status=500 + ) + + # Skip orders that originated from Odoo (metadata/privateMetadata marker) + try: + pub_meta = { + m.get("key"): m.get("value") for m in (order.get("metadata") or []) + } + priv_meta = { + m.get("key"): m.get("value") + for m in (order.get("privateMetadata") or []) + } + meta = {**pub_meta, **priv_meta} + val = str(meta.get("odoo_origin", "")).strip().lower() + if val in {"1", "true", "yes"}: + _logger.info( + "Saleor order webhook: skipping Odoo-origin order %s", order_id + ) + return request.make_response( + "skipped", headers=[("Content-Type", "text/plain")], status=200 + ) + except Exception as e: + _logger.debug( + "Saleor order webhook: error checking Odoo-origin flag: %s", e + ) + + # Upsert into Odoo + try: + account.sudo()._import_saleor_order(order) + return request.make_response( + "ok", headers=[("Content-Type", "text/plain")], status=200 + ) + except Exception as e: + _logger.exception("Saleor order webhook: error upserting order: %s", e) + return request.make_response( + "error", headers=[("Content-Type", "text/plain")], status=500 + ) + + @http.route( + "/saleor/webhook/draft_order", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def saleor_draft_order_webhook(self, **kwargs): # pylint: disable=unused-argument + body = request.httprequest.get_data() or b"" + headers = request.httprequest.headers + account = _saleor_match_account(headers) + if not account: + _logger.warning("Saleor draft order webhook: no active account matched") + return request.make_response( + "no account", headers=[("Content-Type", "text/plain")], status=404 + ) + ok, resp = _saleor_verify_signature(account, headers, body) + if not ok: + return resp + event_type, event_type_raw, data, payload = _saleor_parse_payload(body, headers) + _logger.info( + "Saleor draft order webhook: event=%s raw=%s", event_type, event_type_raw + ) + + if event_type not in {"DRAFT_ORDER_CREATED", "DRAFT_ORDER_UPDATED"}: + return request.make_response( + "ignored", headers=[("Content-Type", "text/plain")], status=200 + ) + + order_id = _saleor_extract_order_id(data, payload) + if not order_id: + return request.make_response( + "no id", headers=[("Content-Type", "text/plain")], status=200 + ) + try: + client = account._get_client() + order = client.order_get_by_id(order_id) or {} + except Exception as e: + _logger.exception( + "Saleor draft order webhook: failed to fetch order %s: %s", order_id, e + ) + return request.make_response( + "error", headers=[("Content-Type", "text/plain")], status=500 + ) + + try: + pub_meta = { + m.get("key"): m.get("value") for m in (order.get("metadata") or []) + } + priv_meta = { + m.get("key"): m.get("value") + for m in (order.get("privateMetadata") or []) + } + meta = {**pub_meta, **priv_meta} + val = str(meta.get("odoo_origin", "")).strip().lower() + if val in {"1", "true", "yes"}: + _logger.info( + "Saleor draft order webhook: skipping Odoo-origin order %s", + order_id, + ) + return request.make_response( + "skipped", + headers=[("Content-Type", "text/plain")], + status=200, + ) + except Exception as e: + _logger.debug( + "Saleor draft order webhook: error checking Odoo-origin flag: %s", e + ) + + try: + account.sudo()._import_saleor_order(order) + return request.make_response( + "ok", headers=[("Content-Type", "text/plain")], status=200 + ) + except Exception as e: + _logger.exception( + "Saleor draft order webhook: error upserting order: %s", e + ) + return request.make_response( + "error", headers=[("Content-Type", "text/plain")], status=500 + ) + + @http.route( + "/saleor/webhook/customer", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def saleor_customer_webhook(self, **kwargs): # pylint: disable=unused-argument + # Read raw body for signature verification + body = request.httprequest.get_data() or b"" + headers = request.httprequest.headers + # Determine account + account = _saleor_match_account(headers) + if not account: + _logger.warning("Saleor webhook: no matching or active account configured") + return request.make_response( + "no account", headers=[("Content-Type", "text/plain")], status=404 + ) + # Verify signature (warn on missing header if secret set) + ok, resp = _saleor_verify_signature(account, headers, body) + if not ok: + return resp + # Parse payload and event + event_type, event_type_raw, data, payload = _saleor_parse_payload(body, headers) + if event_type != "CUSTOMER_UPDATED": + _logger.info("Saleor webhook: event ignored: %s", event_type_raw) + return request.make_response( + "ignored", headers=[("Content-Type", "text/plain")], status=200 + ) + # Extract customer and upsert + customer_id, cust, shipping, billing = _saleor_extract_customer( + data, payload, account + ) + if not customer_id: + _logger.warning( + "Saleor customer webhook without id, payload type=%s", + type(payload).__name__, + ) + return request.make_response( + "no id", headers=[("Content-Type", "text/plain")], status=200 + ) + if not cust: + _logger.info("Saleor webhook: empty customer payload for %s", customer_id) + return request.make_response( + "empty", headers=[("Content-Type", "text/plain")], status=200 + ) + _upsert_partner_and_addresses(account, customer_id, cust, shipping, billing) + + # All good + return request.make_response( + "ok", headers=[("Content-Type", "text/plain")], status=200 + ) + + @http.route( + "/saleor/webhook/order_payment", + type="http", + auth="public", + methods=["POST"], + csrf=False, + ) + def saleor_order_payment_webhook(self, **kwargs): # pylint: disable=unused-argument + body = request.httprequest.get_data() or b"" + headers = request.httprequest.headers + account = _saleor_match_account(headers) + if not account: + _logger.warning("Saleor order payment webhook: no active account matched") + return request.make_response( + "no account", headers=[("Content-Type", "text/plain")], status=404 + ) + ok, resp = _saleor_verify_signature(account, headers, body) + if not ok: + return resp + event_type, event_type_raw, data, payload = _saleor_parse_payload(body, headers) + _logger.info( + "Saleor order payment webhook: event=%s raw=%s", event_type, event_type_raw + ) + + # Only process payment-related events + if event_type not in {"ORDER_PAID", "ORDER_FULLY_PAID"}: + return request.make_response( + "ignored", headers=[("Content-Type", "text/plain")], status=200 + ) + + # Extract order id + order_id = _saleor_extract_order_id(data, payload) + + if not order_id: + _logger.warning( + "Saleor order payment webhook without order id, payload type=%s", + type(payload).__name__, + ) + return request.make_response( + "no id", headers=[("Content-Type", "text/plain")], status=200 + ) + + # Update corresponding sale.order + try: + SaleOrder = request.env["sale.order"].sudo() + so = SaleOrder.search([("saleor_order_id", "=", order_id)], limit=1) + if not so: + _logger.info( + "Saleor order payment webhook: no sale.order linked to %s", order_id + ) + return request.make_response( + "ok", headers=[("Content-Type", "text/plain")], status=200 + ) + + status, number, pay = _saleor_parse_order_payment_details( + account, order_id, data + ) + raw = None + try: + raw = body.decode("utf-8") + except Exception: + raw = None + vals = _saleor_build_payment_vals(so, status, number, pay, raw) + if vals: + try: + so.write(vals) + except Exception as we: + _logger.warning( + "Failed to write payment flag on sale.order %s: %s", so.id, we + ) + _saleor_post_payment_message(so, status, number) + return request.make_response( + "ok", headers=[("Content-Type", "text/plain")], status=200 + ) + except Exception as e: + _logger.exception("Error handling order payment webhook: %s", e) + return request.make_response( + "error", headers=[("Content-Type", "text/plain")], status=500 + ) diff --git a/sale_saleor/data/condition_operation_type_data.xml b/sale_saleor/data/condition_operation_type_data.xml new file mode 100644 index 0000000000..5094629d91 --- /dev/null +++ b/sale_saleor/data/condition_operation_type_data.xml @@ -0,0 +1,31 @@ + + + + + Is + IS + catalogue + + + + + Equals + EQ + order + + + Greater + GTE + order + + + Lower + LTE + order + + + Between + RANGE + order + + diff --git a/sale_saleor/data/saleor_abandoned_cron.xml b/sale_saleor/data/saleor_abandoned_cron.xml new file mode 100644 index 0000000000..c4ccd2503e --- /dev/null +++ b/sale_saleor/data/saleor_abandoned_cron.xml @@ -0,0 +1,13 @@ + + + + Saleor: Mark Abandoned Quotations + + code + model.env['saleor.channel'].cron_mark_abandoned_saleor_orders() + 1 + hours + + diff --git a/sale_saleor/helpers.py b/sale_saleor/helpers.py new file mode 100644 index 0000000000..ddd5ed1863 --- /dev/null +++ b/sale_saleor/helpers.py @@ -0,0 +1,578 @@ +import base64 +import logging +import re +import time +import unicodedata + +from bs4 import BeautifulSoup, NavigableString +from markupsafe import Markup +from text_unidecode import unidecode + +from odoo import api, fields +from odoo.exceptions import UserError + + +# Borrowed from Django's django.utils.text.slugify +def slugify(value, allow_unicode: bool = False): + """Convert text to a slug. + + Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated + dashes to single dashes. Remove characters that aren't alphanumerics, + underscores, or hyphens. Convert to lowercase. Also strip leading and + trailing whitespace, dashes, and underscores. + """ + + value = str(value) + if allow_unicode: + value = unicodedata.normalize("NFKC", value) + else: + value = ( + unicodedata.normalize("NFKD", value) + .encode("ascii", "ignore") + .decode("ascii") + ) + value = re.sub(r"[^\w\s-]", "", value.lower()) + return re.sub(r"[-\s]+", "-", value).strip("-_") + + +def html_to_editorjs(html: str | None) -> dict | None: + """Convert HTML to an EditorJS JSON structure.""" + html = (html or "").strip() + if not html: + return None + + soup = BeautifulSoup(html, "html.parser") + blocks = [] + + container = soup.body or soup + for child in container.children: + process_child(child, blocks) + + if not blocks: + return None + + return { + "time": int(time.time() * 1000), + "blocks": blocks, + "version": "2.24.3", + } + + +def process_child(child, blocks: list): + """Process a child element or string.""" + if isinstance(child, NavigableString): + add_text_block(str(child).strip(), blocks) + return + handle_element(child, blocks) + + +def add_text_block(text: str, blocks: list): + """Add a paragraph block if text is not empty.""" + if text: + blocks.append({"type": "paragraph", "data": {"text": text}}) + + +def handle_element(el, blocks: list): + """Handle HTML element and append corresponding EditorJS block.""" + if getattr(el, "name", None) is None: + return + + name = el.name.lower() + + if name.startswith("h") and name[1].isdigit(): + add_header_block(el, blocks) + elif name in {"p", "div"}: + add_text_block(el.decode_contents().strip(), blocks) + elif name in {"ul", "ol"}: + add_list_block(el, blocks) + elif name == "blockquote": + add_quote_block(el, blocks) + else: + add_text_block(el.decode_contents().strip(), blocks) + + +def add_header_block(el, blocks: list): + level = int(el.name[1]) + text = el.get_text(strip=True) + if text: + blocks.append({"type": "header", "data": {"text": text, "level": level}}) + + +def add_list_block(el, blocks: list): + items = [ + li.decode_contents().strip() + for li in el.find_all("li", recursive=False) + if li.decode_contents().strip() + ] + if items: + blocks.append( + { + "type": "list", + "data": { + "items": items, + "style": "ordered" if el.name == "ol" else "unordered", + }, + } + ) + + +def add_quote_block(el, blocks: list): + text = el.decode_contents().strip() + if text: + blocks.append({"type": "quote", "data": {"text": text, "alignment": "left"}}) + + +def decode_image_field( + b64: str | bytes | None, base_filename: str = "category" +) -> tuple[bytes | None, str | None, str]: + """Decode an Odoo Binary field and guess content-type and filename. + + Returns: (bytes or None, filename or None, content_type) + """ + if not b64: + return None, None, "application/octet-stream" + try: + raw = base64.b64decode(b64) + except Exception: + return None, None, "application/octet-stream" + + filename = base_filename + content_type = "application/octet-stream" + if raw.startswith(b"\x89PNG"): + content_type = "image/png" + filename += ".png" + elif raw.startswith(b"\xff\xd8"): + content_type = "image/jpeg" + filename += ".jpg" + elif raw.startswith(b"GIF8"): + content_type = "image/gif" + filename += ".gif" + elif raw.startswith(b"RIFF") and b"WEBP" in raw[0:16]: + content_type = "image/webp" + filename += ".webp" + else: + # default extension-less filename + pass + + return raw, filename, content_type + + +def upsert_tax_class(client, tax, payload: dict) -> str | None: + """Create or update a Saleor TaxClass from an Odoo tax record. + + Returns the Saleor TaxClass ID (str) or None. + """ + # Update by existing ID + if getattr(tax, "saleor_tax_class_id", None): + update_payload = dict(payload) + update_payload.pop("createCountryRates", None) + if payload.get("createCountryRates"): + update_payload["updateCountryRates"] = payload["createCountryRates"] + res = client.tax_class_update(tax.saleor_tax_class_id, update_payload) + return (res or {}).get("id") or tax.saleor_tax_class_id + + # Otherwise try find by name, then update + existing = client.tax_class_search_by_name(payload.get("name")) + if existing and existing.get("id"): + update_payload = dict(payload) + update_payload.pop("createCountryRates", None) + if payload.get("createCountryRates"): + update_payload["updateCountryRates"] = payload["createCountryRates"] + res = client.tax_class_update(existing["id"], update_payload) + return (res or {}).get("id") or existing["id"] + + # Finally create new + res = client.tax_class_create(payload) + return (res or {}).get("id") + + +# -------- Discount Rule helpers -------- +def compute_merged_description_editorjs( + rule_html: str | None, cond_html_parts: list[str] | None +) -> dict | None: + """Merge rule and conditions descriptions (HTML) and convert to EditorJS. + + Returns EditorJS payload or None. + """ + rule_html = rule_html or "" + cond_html_parts = [p for p in (cond_html_parts or []) if p] + + merged_html: str | None = None + if rule_html and cond_html_parts: + merged_html = f"{rule_html}
" + "".join( + [f"

{p}

" for p in cond_html_parts] + ) + elif cond_html_parts: + merged_html = "".join([f"

{p}

" for p in cond_html_parts]) + elif rule_html: + merged_html = rule_html + if not merged_html: + return None + return html_to_editorjs(merged_html) + + +def build_catalogue_predicate(conditions, env) -> dict: + """Aggregate IDs per condition type, validate missing Saleor IDs, + and build the `cataloguePredicate` dict. + + - conditions: iterable of `discount.rule.condition` + - env: Odoo environment for translations + """ + ids = { + "product_ids": [], + "variant_ids": [], + "collection_ids": [], + "category_ids": [], + } + missing = { + "missing_products": [], + "missing_variants": [], + "missing_collections": [], + "missing_categories": [], + } + + for cond in conditions: + _collect_ids_for_condition(cond, ids, missing) + + _raise_missing_catalogue_errors(env, conditions, missing) + + cp: dict = {} + predicate_map = [ + ("product_ids", "productPredicate"), + ("variant_ids", "variantPredicate"), + ("collection_ids", "collectionPredicate"), + ("category_ids", "categoryPredicate"), + ] + for ids_key, pred_key in predicate_map: + if ids[ids_key]: + cp[pred_key] = {"ids": sorted(set(ids[ids_key]))} + return cp + + +def _collect_ids_for_condition(cond, ids: dict, missing: dict) -> None: + """Collect Saleor IDs per condition type into ids/missing dicts.""" + ctype = getattr(cond, "catalogue_predicate_type", None) + mappings = [ + ( + "product", + "product_template_ids", + "saleor_product_id", + "product_ids", + "missing_products", + ), + ( + "variant", + "product_variant_ids", + "saleor_variant_id", + "variant_ids", + "missing_variants", + ), + ( + "collection", + "product_collection_ids", + "saleor_collection_id", + "collection_ids", + "missing_collections", + ), + ( + "category", + "product_category_ids", + "saleor_category_id", + "category_ids", + "missing_categories", + ), + ] + for t, rel, saleor_field, ids_key, miss_key in mappings: + if ctype != t: + continue + recs = getattr(cond, rel, None) + if not recs: + continue + for rec in recs: + sid = getattr(rec, saleor_field, None) + (ids[ids_key] if sid else missing[miss_key]).append(sid or rec.display_name) + + +def _raise_missing_catalogue_errors(env, conditions, missing: dict) -> None: + """Raise a single UserError if any target records are missing Saleor IDs.""" + msg_specs = [ + ("missing_products", "Products not synced to Saleor: %s"), + ("missing_variants", "Variants not synced to Saleor: %s"), + ("missing_collections", "Collections not synced to Saleor: %s"), + ("missing_categories", "Categories not synced to Saleor: %s"), + ] + missing_msgs: list[str] = [] + for key, fmt in msg_specs: + values = missing.get(key) or [] + if values: + missing_msgs.append(env._(fmt, ", ".join(sorted(set(values))))) + if not missing_msgs: + return + + rule_name = ( + getattr(conditions, "discount_rule_id", None) + and conditions.discount_rule_id.display_name + ) or "" + raise UserError( + env._( + "Cannot sync discount rule '%s'" + " to Saleor because some condition targets are not synced." + "\n%s", + rule_name, + "\n".join(missing_msgs), + ) + ) + + +def prepare_unique_slug(slug: str, slug_values): + """Prepare unique slug value based on provided list of existing slug values. + + Mirrors Saleor's ``prepare_unique_slug`` logic but works on a simple + iterable of slug strings. + """ + unique_slug = slug + extension = 1 + + existing = {v for v in slug_values if v} + while unique_slug in existing: + extension += 1 + unique_slug = f"{slug}-{extension}" + + return unique_slug + + +def generate_unique_slug( + instance, + slugable_value: str, + slug_field_name: str = "slug", + *, + additional_search_domain=None, +) -> str: + slug = slugify(unidecode(slugable_value)) + + if slug == "": + slug = "-" + + domain = [ + (slug_field_name, "!=", False), + ("id", "!=", instance.id or 0), + ] + if additional_search_domain: + domain.extend(additional_search_domain) + + candidates = instance.search(domain) + slug_values = [] + base = slug + prefix = f"{base}-" + for rec in candidates: + value = getattr(rec, slug_field_name, None) + if not value: + continue + if value == base: + slug_values.append(value) + continue + if value.startswith(prefix) and value[len(prefix) :].isdigit(): + slug_values.append(value) + + unique_slug = prepare_unique_slug(slug, slug_values) + return unique_slug + + +def apply_reward_mapping( + input_data: dict, reward_type: str | None, reward_unit: str | None, reward_value +) -> None: + """Populate reward fields in input_data according to rule configuration. + + - reward_type: expects "sub" for subtotal discount to be applicable + - reward_unit: "percent" or "currency" + - reward_value: numeric value (0 allowed) + """ + if reward_type != "sub": + return + if reward_unit == "percent": + input_data["rewardValueType"] = "PERCENTAGE" + elif reward_unit == "currency": + input_data["rewardValueType"] = "FIXED" + if "rewardValueType" in input_data: + input_data["rewardValue"] = float(reward_value or 0.0) + + +# -------- Datetime helpers -------- +def to_saleor_datetime(dt): + """Return ISO8601 string with 'T' separator and 'Z' suffix for UTC. + + Ensures compatibility with Saleor GraphQL DateTime inputs. + Accepts Odoo datetime (string) or python datetime. + """ + if not dt: + return None + py_dt = fields.Datetime.to_datetime(dt) + if not py_dt: + return None + iso = py_dt.replace(microsecond=0).isoformat() + if "T" not in iso: + iso = iso.replace(" ", "T") + if "+" not in iso and iso.rfind("-") <= 10 and not iso.endswith("Z"): + iso = iso + "Z" + return iso + + +# -------- Account helpers -------- +def get_active_saleor_account(env, raise_if_missing: bool = True): + """Return all active Saleor accounts. + + If none found and raise_if_missing is True, raise a UserError. + """ + account = env["saleor.account"].search([("active", "=", True)], limit=1) + if not account and raise_if_missing: + raise UserError(env._("No active Saleor account configured.")) + return account + + +def format_batch_errors_message(title, items): + changes = Markup("\n").join( + [ + Markup( + """ +
  • + %(name)s + %(reason)s +
  • + """ + ) + % {"name": name, "reason": reason} + for (name, reason) in items + ] + ) + body = Markup("

    %(title)s

      %(changes)s
    ") % { + "title": title, + "changes": changes, + } + return body + + +def format_note(env, template, *args): + text = env._(template, *args) + return Markup("

    %s

    ") % text + + +def format_kv_list(title, items): + lis = Markup("\n").join( + [ + Markup("
  • %s: %s
  • ") % (k, (v if v is not None else "-")) + for (k, v) in items + ] + ) + return Markup("

    %s

      %s
    ") % (title, lis) + + +def post_to_current_job_committed( + env, record, body, subject=None, message_type="comment" +): + """Post to current queue.job chatter in a separate transaction and commit.""" + _logger = logging.getLogger(__name__) + try: + registry = env.registry + with registry.cursor() as cr: + env2 = api.Environment(cr, env.uid, dict(env.context or {})) + # resolve job in the new env + ctx = env2.context or {} + job = None + job_id = ctx.get("job_id") + if job_id: + job = env2["queue.job"].browse(job_id) + job = job if job and job.exists() else None + if job is None: + job_uuid = ctx.get("job_uuid") + if job_uuid: + job = ( + env2["queue.job"] + .sudo() + .search([("uuid", "=", job_uuid)], limit=1) + or None + ) + target = job + if target is None and record is not None: + target = env2[record._name].browse(record.id) + if target is None: + return + try: + target.message_post( + body=body, subject=subject, message_type=message_type + ) + except Exception as e: + _logger.warning( + "Failed to post message (committed) to %s: %s", + getattr(target, "_name", "unknown"), + e, + ) + except Exception as e: + _logger.debug("Committed post failed: %s", e) + + +# -------- Saleor dashboard link helpers -------- +def saleor_dashboard_links( + base_url: str | None, + kind: str, + *, + object_id: str | None = None, + number: str | None = None, + zone_id: str | None = None, + product_id: str | None = None, + **kwargs, +) -> tuple[str | None, str | None]: + """Return (dashboard_url, object_url) for Saleor Dashboard.""" + base = (base_url or "").rstrip("/") + if not base: + return None, None + dashboard = f"{base}/dashboard" + path = None + id_val = object_id or kwargs.get("id") + + if kind == "channel": + ident = id_val + path = f"/channels/{ident}" if ident else None + elif kind == "shipping_zone": + path = f"/shipping/{id_val}" if id_val else None + elif kind == "shipping_method": + if zone_id and id_val: + path = f"/shipping/{zone_id}/{id_val}" + else: + path = f"/shipping/{id_val}" if id_val else None + elif kind == "product": + path = f"/products/{id_val}" if id_val else None + elif kind == "product_variant": + if product_id and id_val: + path = f"/products/{product_id}/variant/{id_val}" + else: + path = ( + f"/products/{product_id or id_val}" if (product_id or id_val) else None + ) + elif kind == "collection": + path = f"/collections/{id_val}" if id_val else None + elif kind == "attribute": + path = f"/attributes/{id_val}" if id_val else None + elif kind == "tax": + path = f"/taxes/tax-classes/{id_val}" if id_val else None + elif kind == "order": + ident = id_val + path = f"/orders/{ident}" if ident else None + elif kind == "voucher": + path = f"/discounts/vouchers/{id_val}" if id_val else None + elif kind == "gift_card": + path = f"/gift-cards/{id_val}" if id_val else "/gift-cards" + + return dashboard, (dashboard + path) if path else dashboard + + +def make_link(text: str, href: str | None) -> Markup: + href = href or "#" + safe_text = text or href + return Markup('%s') % ( + href, + safe_text, + ) diff --git a/sale_saleor/models/__init__.py b/sale_saleor/models/__init__.py new file mode 100644 index 0000000000..3136c1b191 --- /dev/null +++ b/sale_saleor/models/__init__.py @@ -0,0 +1,43 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import saleor_account +from . import product_category +from . import product_collection +from . import saleor_category_meta_line +from . import saleor_collection_meta_line +from . import saleor_product_type +from . import product_type_meta_line +from . import product_template +from . import saleor_product_meta_line +from . import product_attribute +from . import saleor_attribute_meta_line +from . import product_product +from . import product_image +from . import account_tax +from . import saleor_tax_meta_line +from . import saleor_channel +from . import delivery_carrier +from . import saleor_shipping_zone +from . import saleor_shipping_zone_meta_line +from . import saleor_shipping_meta_line +from . import saleor_shipping_order_value_line +from . import saleor_shipping_pricing_line +from . import saleor_shipping_postal_code_range +from . import stock_warehouse +from . import stock_location +from . import res_partner +from . import stock_quant +from . import loyalty_program +from . import discount_rule +from . import condition_operation_type +from . import discount_rule_condition +from . import saleor_voucher +from . import saleor_voucher_meta_line +from . import saleor_voucher_code +from . import saleor_voucher_discount_line +from . import saleor_voucher_minimal_order_value +from . import saleor_gift_card +from . import saleor_gift_card_tag +from . import saleor_giftcard_meta_line +from . import sale_order diff --git a/sale_saleor/models/account_tax.py b/sale_saleor/models/account_tax.py new file mode 100644 index 0000000000..dcb06bfed6 --- /dev/null +++ b/sale_saleor/models/account_tax.py @@ -0,0 +1,98 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models +from odoo.exceptions import UserError + +from ..helpers import get_active_saleor_account + + +class AccountTax(models.Model): + _inherit = "account.tax" + + saleor_metadata_line_ids = fields.One2many( + "saleor.tax.meta.line", + "tax_id", + string="Saleor Metadata", + ) + saleor_private_metadata_line_ids = fields.One2many( + "saleor.tax.private.meta.line", + "tax_id", + string="Saleor Private Metadata", + ) + + # Store Saleor TaxClass ID + saleor_tax_class_id = fields.Char( + string="Saleor TaxClass ID", copy=False, index=True, help="ID in Saleor" + ) + + def _saleor_prepare_tax_payload(self): + self.ensure_one() + if self.amount_type != "percent" or self.type_tax_use != "sale": + raise UserError( + self.env._("Only percent Sales taxes can be synced to Saleor TaxClass.") + ) + + # Base payload + payload = { + "name": self.name, + } + + # Metadata + meta = [ + {"key": line.key, "value": line.value} + for line in (self.saleor_metadata_line_ids or []) + ] + priv = [ + {"key": line.key, "value": line.value} + for line in (self.saleor_private_metadata_line_ids or []) + ] + if meta: + payload["metadata"] = meta + if priv: + payload["privateMetadata"] = priv + + # Country rates: prefer the tax's country if set, otherwise fallback to company + country = ( + getattr(self, "country_id", False) + or getattr(self.company_id, "country_id", False) + or getattr(self.env.company, "country_id", False) + ) + country_code = getattr(country, "code", None) + if country_code: + payload["createCountryRates"] = [ + {"countryCode": country_code, "rate": float(self.amount or 0.0)} + ] + return payload + + def _saleor_validate_tax_for_sync(self): + self.ensure_one() + if self.amount_type != "percent" or self.type_tax_use != "sale": + raise UserError( + self.env._("Only percent Sales taxes can be synced to Saleor TaxClass.") + ) + + def action_saleor_tax_sync(self): + """Sync this tax to Saleor as a TaxClass.""" + account = get_active_saleor_account(self.env, raise_if_missing=True) + + if len(self) == 1: + tax = self + tax._saleor_validate_tax_for_sync() + payload = tax._saleor_prepare_tax_payload() + account.job_tax_sync(tax.id, payload) + else: + # Multiple: batch + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for tax in self: + tax._saleor_validate_tax_for_sync() + payload = tax._saleor_prepare_tax_payload() + items.append({"id": tax.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_tax_sync_batch(chunk) + else: + account.job_tax_sync_batch(chunk) + return True diff --git a/sale_saleor/models/condition_operation_type.py b/sale_saleor/models/condition_operation_type.py new file mode 100644 index 0000000000..e9334edaee --- /dev/null +++ b/sale_saleor/models/condition_operation_type.py @@ -0,0 +1,18 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ConditionOperationType(models.Model): + _name = "condition.operation.type" + _description = "Condition Operation Type" + + name = fields.Char(required=True) + code = fields.Char(required=True) + type = fields.Selection( + selection=[ + ("catalogue", "Catalog"), + ("order", "Order"), + ] + ) diff --git a/sale_saleor/models/delivery_carrier.py b/sale_saleor/models/delivery_carrier.py new file mode 100644 index 0000000000..a678a85e14 --- /dev/null +++ b/sale_saleor/models/delivery_carrier.py @@ -0,0 +1,232 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import json as _json +import logging + +from odoo import Command, api, fields, models + +from ..helpers import html_to_editorjs + +_logger = logging.getLogger(__name__) + + +class DeliveryCarrier(models.Model): + _inherit = "delivery.carrier" + + delivery_type = fields.Selection( + selection_add=[ + ("saleor", "Saleor Delivery"), + ], + ondelete={"saleor": "cascade"}, + ) + shipping_method_type = fields.Selection( + selection=[ + ("price", "Price"), + ("weight", "Weight"), + ], + required=True, + default="price", + ) + product_id = fields.Many2one( + "product.product", + string="Product", + required=False, + ondelete="cascade", + ) + tax_id = fields.Many2one( + "account.tax", + domain=[("type_tax_use", "=", "sale")], + ) + saleor_shipping_method_id = fields.Char( + string="Saleor Shipping Method ID", copy=False, index=True + ) + zone_id = fields.Many2one("saleor.shipping.zone", string="Shipping Zone") + zone_channel_ids = fields.Many2many( + "saleor.channel", string="Channels", related="zone_id.channel_ids" + ) + description = fields.Html() + min_delivery_time = fields.Float() + max_delivery_time = fields.Float() + order_value = fields.Boolean( + help="Restrict order value.\nThis rate will apply to all orders", + ) + saleor_order_value_line_ids = fields.One2many( + "order.value.line", + "carrier_id", + ) + saleor_shipping_pricing_line_ids = fields.One2many( + "shipping.pricing.line", + "carrier_id", + string="Pricing", + ) + # Postal codes + postal_filter_mode = fields.Selection( + selection=[ + ("exclude", "Exclude postal codes"), + ("include", "Include postal codes"), + ], + default="exclude", + required=True, + string="Postal Codes Mode", + ) + postal_code_range_ids = fields.One2many( + "postal.code.range", "carrier_id", string="Postal Code Ranges" + ) + excluded_product_ids = fields.Many2many( + "product.template", + string="Excluded Products", + ) + # Metadata for Price Based Rates + shipping_method_metadata_line_ids = fields.One2many( + "shipping.method.meta.line", "carrier_id", string="Metadata" + ) + shipping_method_private_metadata_line_ids = fields.One2many( + "shipping.method.private.meta.line", "carrier_id", string="Private Metadata" + ) + + @api.onchange("postal_filter_mode") + def _onchange_postal_filter_mode(self): + for rec in self: + rec.postal_code_range_ids = [Command.clear()] + + @api.model_create_multi + def create(self, vals_list): + carriers = super().create(vals_list) + ProductTemplate = self.env["product.template"] + + for carrier in carriers: + if carrier.delivery_type == "saleor" and not carrier.product_id: + product_tmpl = ProductTemplate.create( + { + "name": carrier.name, + "type": "service", + "sale_ok": False, + "purchase_ok": False, + "list_price": 0.0, + "taxes_id": [Command.clear()], + "invoice_policy": "order", + } + ) + carrier.product_id = product_tmpl.product_variant_id.id + # If carrier has a tax set, add it to the created product's taxes + if carrier.tax_id: + product_tmpl.write({"taxes_id": [Command.link(carrier.tax_id.id)]}) + + # Ensure any existing product linked to a Saleor carrier also receives its tax + for carrier in carriers: + if ( + carrier.delivery_type == "saleor" + and carrier.product_id + and carrier.tax_id + ): + carrier.product_id.product_tmpl_id.write( + {"taxes_id": [Command.link(carrier.tax_id.id)]} + ) + + return carriers + + def write(self, vals): + res = super().write(vals) + # When tax or product changes on a Saleor carrier, add tax to the product + if any(k in vals for k in ("tax_id", "product_id", "delivery_type")): + for carrier in self: + try: + if ( + carrier.delivery_type == "saleor" + and carrier.product_id + and carrier.tax_id + ): + carrier.product_id.product_tmpl_id.write( + {"taxes_id": [Command.link(carrier.tax_id.id)]} + ) + except Exception: + # Do not block writes for ancillary errors + _logger.debug("Skipping tax propagation for carrier %s", carrier.id) + return res + + def _saleor_shipping_method_prepare_payload(self): + """Build payload for Saleor shipping method create/update.""" + self.ensure_one() + payload = { + "name": self.name, + } + + # Type mapping + if self.shipping_method_type == "price": + payload["type"] = "PRICE" + elif self.shipping_method_type == "weight": + payload["type"] = "WEIGHT" + + # Description: convert HTML to EditorJS JSON string + if self.description: + desc = html_to_editorjs(self.description) + if desc is not None: + payload["description"] = _json.dumps(desc) + + # Delivery time (days) - always include (0 if not set) + payload["minimumDeliveryDays"] = int(self.min_delivery_time or 0) + payload["maximumDeliveryDays"] = int(self.max_delivery_time or 0) + + # Weight constraints for weight-based shipping methods + if self.shipping_method_type == "weight": + # Get weight constraints from order value lines + if self.saleor_order_value_line_ids: + # Use the first order value line for weight constraints + # In weight-based methods, min_value/max_value represent weight limits + weight_line = self.saleor_order_value_line_ids[0] + if weight_line.min_value: + payload["minimumOrderWeight"] = float(weight_line.min_value) + if weight_line.max_value: + payload["maximumOrderWeight"] = float(weight_line.max_value) + + # Postal code rules + inclusion = "INCLUDE" if self.postal_filter_mode == "include" else "EXCLUDE" + payload["inclusionType"] = inclusion + add_rules = [ + { + "start": (rng.start_zip or ""), + "end": (rng.end_zip or ""), + } + for rng in self.postal_code_range_ids + if (rng.start_zip or rng.end_zip) + ] + if add_rules: + payload["addPostalCodeRules"] = add_rules + + # Metadata and private metadata + meta = [ + {"key": line.key, "value": line.value} + for line in (self.shipping_method_metadata_line_ids or []) + ] + priv = [ + {"key": line.key, "value": line.value} + for line in (self.shipping_method_private_metadata_line_ids or []) + ] + if meta: + payload["metadata"] = meta + if priv: + payload["privateMetadata"] = priv + + # Excluded products + payload["excludedProducts"] = [ + tmpl.saleor_product_id + for tmpl in self.excluded_product_ids + if getattr(tmpl, "saleor_product_id", None) + ] + + # Inject tax class if available on carrier's tax + try: + tax_class_id = getattr( + getattr(self, "tax_id", None), "saleor_tax_class_id", None + ) + if tax_class_id: + payload["taxClass"] = tax_class_id + except Exception as e: + _logger.warning( + "Failed to inject taxClass for carrier %s: %s", + getattr(self, "id", None), + e, + ) + + return payload diff --git a/sale_saleor/models/discount_rule.py b/sale_saleor/models/discount_rule.py new file mode 100644 index 0000000000..c17013a514 --- /dev/null +++ b/sale_saleor/models/discount_rule.py @@ -0,0 +1,119 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo import api, fields, models + +from ..helpers import ( + apply_reward_mapping, + build_catalogue_predicate, + compute_merged_description_editorjs, +) + + +class DiscountRule(models.Model): + _name = "discount.rule" + _description = "Discount Rule" + + name = fields.Char(required=True) + channel_id = fields.Many2one("saleor.channel", required=True) + program_id = fields.Many2one( + "loyalty.program", + string="Loyalty Program", + ondelete="cascade", + ) + predicate_type = fields.Selection(related="program_id.discount_type", readonly=True) + condition_ids = fields.One2many( + "discount.rule.condition", "discount_rule_id", string="Conditions" + ) + reward_type = fields.Selection( + selection=[ + ("gift", "Gift"), + ("sub", "Subtotal discount"), + ], + default="sub", + ) + reward_unit = fields.Selection( + selection=[ + ("percent", "%"), + ("currency", "Currency"), + ], + required=True, + default="percent", + ) + display_unit = fields.Char() + reward_value = fields.Float() + description = fields.Html(string="Description (HTML)") + duplicate_type_warning = fields.Text(compute="_compute_duplicate_type_warning") + + # Saleor linkage + saleor_promotion_rule_id = fields.Char( + string="Saleor Promotion Rule ID", + copy=False, + index=True, + help="ID of this rule in Saleor", + ) + + @api.onchange("reward_unit") + def _compute_display_unit(self): + for line in self: + if line.reward_unit == "currency": + line.display_unit = line.channel_id.currency_id.name or "" + elif line.reward_unit == "percent": + line.display_unit = "%" + else: + line.display_unit = "" + + @api.depends("condition_ids.catalogue_predicate_type") + def _compute_duplicate_type_warning(self): + for rule in self: + warning = "" + types = rule.condition_ids.mapped("catalogue_predicate_type") + duplicates = [t for t in set(types) if t and types.count(t) > 1] + + if duplicates: + sel = ( + self.env["discount.rule.condition"] + ._fields["catalogue_predicate_type"] + .selection + ) + labels = [dict(sel).get(t) for t in duplicates if t] + labels = [lbl for lbl in labels if isinstance(lbl, str)] + warning = self.env._( + "Warning: Rule '%s' has duplicate condition types: %s.", + rule.display_name, + ", ".join(labels), + ) + rule.duplicate_type_warning = warning + + # --- Saleor helpers --- + def _saleor_prepare_rule_input(self): + self.ensure_one() + # Minimal compatible input: promotion, name, optional description + input_data = { + "name": self.name or "", + } + + # Description via helpers + desc = compute_merged_description_editorjs( + self.description or "", + [ + c.description + for c in self.condition_ids + if getattr(c, "description", None) + ], + ) + if desc is not None: + input_data["description"] = desc + + # Catalogue predicate via helpers + if self.predicate_type == "catalogue": + input_data["cataloguePredicate"] = build_catalogue_predicate( + self.condition_ids, self.env + ) + + # Reward mapping via helpers + apply_reward_mapping( + input_data, self.reward_type, self.reward_unit, self.reward_value + ) + return input_data diff --git a/sale_saleor/models/discount_rule_condition.py b/sale_saleor/models/discount_rule_condition.py new file mode 100644 index 0000000000..f8d8297349 --- /dev/null +++ b/sale_saleor/models/discount_rule_condition.py @@ -0,0 +1,98 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class DiscountRuleCondition(models.Model): + _name = "discount.rule.condition" + _description = "Discount Rule Condition" + + discount_rule_id = fields.Many2one( + "discount.rule", + string="Discount Rule", + ondelete="cascade", + ) + predicate_type = fields.Selection( + related="discount_rule_id.predicate_type", + readonly=True, + ) + catalogue_predicate_type = fields.Selection( + selection=[ + ("product", "Product"), + ("collection", "Collection"), + ("variant", "Variant"), + ("category", "Category"), + ], + ) + order_predicate_type = fields.Selection( + selection=[ + ("subtotal", "Subtotal Price"), + ("total", "Total Price"), + ], + ) + operator_id = fields.Many2one( + "condition.operation.type", + string="Operator", + ) + program_id = fields.Many2one( + related="discount_rule_id.program_id", + store=True, + ) + + # Catalogue target selections + product_template_ids = fields.Many2many( + "product.template", + "discount_rule_condition_product_template_rel", + "condition_id", + "product_tmpl_id", + string="Products", + ) + product_variant_ids = fields.Many2many( + "product.product", + "discount_rule_condition_product_variant_rel", + "condition_id", + "product_id", + string="Variants", + ) + product_collection_ids = fields.Many2many( + "product.collection", + "discount_rule_condition_product_collection_rel", + "condition_id", + "collection_id", + string="Collections", + ) + product_category_ids = fields.Many2many( + "product.category", + "discount_rule_condition_product_category_rel", + "condition_id", + "category_id", + string="Categories", + ) + + # Optional human description shown in Saleor (merged into rule description) + description = fields.Html(string="Condition Description (HTML)") + + @api.onchange("catalogue_predicate_type") + def _onchange_catalogue_predicate_type(self): + """Clear values of fields not matching the current type""" + if self.catalogue_predicate_type != "product": + self.product_template_ids = [(5, 0, 0)] + if self.catalogue_predicate_type != "variant": + self.product_variant_ids = [(5, 0, 0)] + if self.catalogue_predicate_type != "collection": + self.product_collection_ids = [(5, 0, 0)] + if self.catalogue_predicate_type != "category": + self.product_category_ids = [(5, 0, 0)] + + @api.constrains("catalogue_predicate_type") + def _check_catalogue_predicate_type_not_empty(self): + for rec in self: + if not rec.catalogue_predicate_type: + raise ValidationError( + self.env._( + "Condition type cannot be empty." + " Please select a Catalogue Predicate Type." + ) + ) diff --git a/sale_saleor/models/loyalty_program.py b/sale_saleor/models/loyalty_program.py new file mode 100644 index 0000000000..faf41002ec --- /dev/null +++ b/sale_saleor/models/loyalty_program.py @@ -0,0 +1,115 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + + +from odoo import fields, models +from odoo.exceptions import UserError + +from ..helpers import get_active_saleor_account, html_to_editorjs, to_saleor_datetime + + +class LoyaltyProgram(models.Model): + _inherit = "loyalty.program" + + program_type = fields.Selection( + selection_add=[ + ("saleor", "Saleor Discount"), + ], + ondelete={"saleor": "cascade"}, + ) + discount_type = fields.Selection( + selection=[("catalogue", "Catalog"), ("order", "Order")], + required=True, + default="catalogue", + ) + saleor_description = fields.Html(string="Saleor Description (HTML)") + set_end_date = fields.Boolean() + active_date_from = fields.Datetime(string="Start Date (Saleor)") + active_date_to = fields.Datetime(string="End Date (Saleor)") + discount_rule_ids = fields.One2many("discount.rule", "program_id", string="Rules") + saleor_promotion_id = fields.Char( + string="Saleor Promotion ID", + copy=False, + index=True, + help="ID of this promotion in Saleor", + ) + + def _program_items_name(self): + res = super()._program_items_name() + res.update( + { + "saleor": self.env._("Sale Orders"), + } + ) + return res + + def _saleor_prepare_promotion_payload(self): + self.ensure_one() + # Minimal payload to avoid schema mismatches across Saleor versions + # Map Odoo discount_type to Saleor PromotionTypeEnum + type_map = { + "catalogue": "CATALOGUE", + "order": "ORDER", + } + saleor_type = type_map.get(self.discount_type) + payload = { + "name": self.name, + "type": saleor_type, + } + if self.saleor_description: + # Convert HTML to EditorJS + desc = html_to_editorjs(self.saleor_description) + if desc is not None: + # Saleor expects JSONString + payload["description"] = desc + # Optional: include dates if present; field names may vary by Saleor version + if self.active_date_from: + payload["startDate"] = to_saleor_datetime(self.active_date_from) + if self.set_end_date and self.active_date_to: + payload["endDate"] = to_saleor_datetime(self.active_date_to) + return payload + + def action_saleor_sync(self): + programs = self.filtered(lambda p: p.program_type == "saleor") + if not programs: + raise UserError( + self.env._("Only programs with type 'Saleor Discount' can be synced.") + ) + account = get_active_saleor_account(self.env, raise_if_missing=True) + if len(programs) == 1: + prog = programs + payload = prog._saleor_prepare_promotion_payload() + account.job_promotion_sync(prog.id, payload) + queued = False + else: + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for prog in programs: + payload = prog._saleor_prepare_promotion_payload() + items.append({"id": prog.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_promotion_sync_batch(chunk) + else: + account.job_promotion_sync_batch(chunk) + queued = True + # Notify via client action + msg = self.env._( + "Queued sync of %s promotion(s) to Saleor.", + len(programs) + if queued + else self.env._( + "Triggered sync of %s promotion(s) to Saleor.", len(programs) + ), + ) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": self.env._("Saleor Promotion Sync"), + "message": msg, + "type": "success", + "sticky": False, + }, + } diff --git a/sale_saleor/models/product_attribute.py b/sale_saleor/models/product_attribute.py new file mode 100644 index 0000000000..ece2a48e07 --- /dev/null +++ b/sale_saleor/models/product_attribute.py @@ -0,0 +1,78 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + +from ..helpers import generate_unique_slug, get_active_saleor_account + + +class ProductAttribute(models.Model): + _name = "product.attribute" + _inherit = ["product.attribute", "mail.thread", "mail.activity.mixin"] + + saleor_slug = fields.Char(help="URL-friendly unique identifier in Saleor") + saleor_attribute_id = fields.Char( + string="Saleor Attribute ID", copy=False, index=True, help="ID in Saleor" + ) + + saleor_metadata_line_ids = fields.One2many( + "saleor.attribute.meta.line", "attribute_id", string="Saleor Metadata" + ) + saleor_private_metadata_line_ids = fields.One2many( + "saleor.attribute.private.meta.line", + "attribute_id", + string="Saleor Private Metadata", + ) + + _sql_constraints = [ + ( + "saleor_attribute_slug_unique", + "unique(saleor_slug)", + "Saleor slug must be unique on product attributes.", + ) + ] + + def _saleor_prepare_attribute_payload(self): + self.ensure_one() + if not self.saleor_slug and self.name: + self.saleor_slug = generate_unique_slug( + self, self.name, slug_field_name="saleor_slug" + ) + payload = { + "name": self.name, + "slug": self.saleor_slug, + # metadata fields + "metadata": [ + {"key": line.key, "value": line.value} + for line in self.saleor_metadata_line_ids + ], + "privateMetadata": [ + {"key": line.key, "value": line.value} + for line in self.saleor_private_metadata_line_ids + ], + } + # include current values' names + values = self.value_ids.mapped("name") if hasattr(self, "value_ids") else [] + payload["values"] = values + return payload + + def action_saleor_sync(self): + account = get_active_saleor_account(self.env, raise_if_missing=True) + if len(self) == 1: + rec = self + payload = rec._saleor_prepare_attribute_payload() + account.job_attribute_sync(rec.id, payload) + else: + # Multiple: batch + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for rec in self: + payload = rec._saleor_prepare_attribute_payload() + items.append({"id": rec.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_attribute_sync_batch(chunk) + else: + account.job_attribute_sync_batch(chunk) + return True diff --git a/sale_saleor/models/product_category.py b/sale_saleor/models/product_category.py new file mode 100644 index 0000000000..cddf4b6d76 --- /dev/null +++ b/sale_saleor/models/product_category.py @@ -0,0 +1,131 @@ +import json as _json + +from odoo import fields, models +from odoo.exceptions import UserError + +from ..helpers import generate_unique_slug, get_active_saleor_account, html_to_editorjs + + +class ProductCategory(models.Model): + _name = "product.category" + _inherit = ["product.category", "mail.thread", "mail.activity.mixin"] + + saleor_slug = fields.Char(help="URL-friendly unique identifier in Saleor") + saleor_description = fields.Html(string="Saleor Description (HTML)") + saleor_seo_title = fields.Char(string="Saleor SEO Title") + saleor_seo_description = fields.Char(string="Saleor SEO Description") + saleor_category_id = fields.Char( + string="Saleor Category ID", + copy=False, + index=True, + help="ID of this category in Saleor", + ) + saleor_metadata_line_ids = fields.One2many( + "saleor.category.meta.line", + "category_id", + ) + saleor_private_metadata_line_ids = fields.One2many( + "saleor.category.private.meta.line", "category_id" + ) + saleor_background_image = fields.Binary() + + _sql_constraints = [ + ( + "saleor_category_slug_unique", + "unique(saleor_slug)", + "Saleor slug must be unique on categories.", + ) + ] + + # Channels linkage + channel_ids = fields.Many2many( + "saleor.channel", + "saleor_channel_product_category_rel", + "category_id", + "channel_id", + string="Channels", + help="Saleor channels where this category is available.", + ) + + def _saleor_prepare_payload(self): + self.ensure_one() + name = self.name + + if not self.saleor_slug and name: + self.saleor_slug = generate_unique_slug( + self, name, slug_field_name="saleor_slug" + ) + + payload = { + "name": name, + "slug": self.saleor_slug, + } + # Saleor expects JSONString, i.e., a JSON-encoded string, not a dict + if self.saleor_description: + desc = html_to_editorjs(self.saleor_description) + if desc is not None: + payload["description"] = _json.dumps(desc) + seo = {} + if self.saleor_seo_title: + seo["title"] = self.saleor_seo_title + if self.saleor_seo_description: + seo["description"] = self.saleor_seo_description + if seo: + payload["seo"] = seo + # Channels + if self.channel_ids: + channel_saleor_ids = [ + ch.saleor_channel_id for ch in self.channel_ids if ch.saleor_channel_id + ] + missing = [ + ch.display_name for ch in self.channel_ids if not ch.saleor_channel_id + ] + if missing: + raise UserError( + self.env._( + "Please sync the following channels to Saleor first: %s", + ", ".join(missing), + ) + ) + if channel_saleor_ids: + payload["addChannels"] = channel_saleor_ids + # Parent linkage: if parent has a stored Saleor ID, reference it + parent_saleor_id = self.parent_id and self.parent_id.saleor_category_id or False + if parent_saleor_id: + payload["parent"] = parent_saleor_id + # Map Odoo lines to Saleor metadata fields + meta_lines = self.saleor_metadata_line_ids + payload["metadata"] = ( + [{"key": line.key, "value": line.value} for line in meta_lines] + if meta_lines + else [] + ) + priv_lines = self.saleor_private_metadata_line_ids + payload["privateMetadata"] = ( + [{"key": line.key, "value": line.value} for line in priv_lines] + if priv_lines + else [] + ) + return payload + + def action_saleor_sync(self): + # Sync this category to all active Saleor accounts + account = get_active_saleor_account(self.env, raise_if_missing=True) + if len(self) == 1: + cat = self + payload = cat._saleor_prepare_payload() + account.job_category_sync(cat.id, payload) + else: + # Batch multi records + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for cat in self: + payload = cat._saleor_prepare_payload() + items.append({"id": cat.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_category_sync_batch(chunk) + else: + account.job_category_sync_batch(chunk) + return True diff --git a/sale_saleor/models/product_collection.py b/sale_saleor/models/product_collection.py new file mode 100644 index 0000000000..a63ade5b42 --- /dev/null +++ b/sale_saleor/models/product_collection.py @@ -0,0 +1,132 @@ +import json as _json + +from odoo import fields, models +from odoo.exceptions import UserError + +from ..helpers import generate_unique_slug, get_active_saleor_account, html_to_editorjs + + +class ProductCollection(models.Model): + _name = "product.collection" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Product Collection" + + name = fields.Char(required=True) + saleor_collection_id = fields.Char( + string="Saleor Collection ID", + copy=False, + index=True, + help="ID of this collection in Saleor", + ) + saleor_collection_slug = fields.Char( + help="URL-friendly unique identifier in Saleor" + ) + saleor_collection_description = fields.Html(string="Saleor Description (HTML)") + saleor_collection_seo_title = fields.Char(string="Saleor SEO Title") + saleor_collection_seo_description = fields.Char(string="Saleor SEO Description") + saleor_collection_metadata_line_ids = fields.One2many( + "saleor.collection.meta.line", + "collection_id", + ) + saleor_collection_private_metadata_line_ids = fields.One2many( + "saleor.collection.private.meta.line", "collection_id" + ) + saleor_background_image = fields.Binary() + + _sql_constraints = [ + ( + "saleor_collection_slug_unique", + "unique(saleor_collection_slug)", + "Saleor slug must be unique on collections.", + ) + ] + + # Channels linkage + channel_ids = fields.Many2many( + "saleor.channel", + "saleor_channel_product_collection_rel", + "collection_id", + "channel_id", + string="Channels", + help="Saleor channels where this collection is available.", + ) + + def _saleor_collection_prepare_payload(self): + self.ensure_one() + name = self.name + + if not self.saleor_collection_slug and name: + self.saleor_collection_slug = generate_unique_slug( + self, name, slug_field_name="saleor_collection_slug" + ) + + payload = { + "name": name, + "slug": self.saleor_collection_slug, + } + # Saleor expects JSONString, i.e., a JSON-encoded string, not a dict + if self.saleor_collection_description: + desc = html_to_editorjs(self.saleor_collection_description) + if desc is not None: + payload["description"] = _json.dumps(desc) + seo = {} + if self.saleor_collection_seo_title: + seo["title"] = self.saleor_collection_seo_title + if self.saleor_collection_seo_description: + seo["description"] = self.saleor_collection_seo_description + if seo: + payload["seo"] = seo + # Channels + if self.channel_ids: + channel_saleor_ids = [ + ch.saleor_channel_id for ch in self.channel_ids if ch.saleor_channel_id + ] + missing = [ + ch.display_name for ch in self.channel_ids if not ch.saleor_channel_id + ] + if missing: + raise UserError( + self.env._( + "Please sync the following channels to Saleor first: %s", + ", ".join(missing), + ) + ) + if channel_saleor_ids: + payload["addChannels"] = channel_saleor_ids + + # Map Odoo lines to Saleor metadata fields + meta_lines = self.saleor_collection_metadata_line_ids + payload["metadata"] = ( + [{"key": line.key, "value": line.value} for line in meta_lines] + if meta_lines + else [] + ) + priv_lines = self.saleor_collection_private_metadata_line_ids + payload["privateMetadata"] = ( + [{"key": line.key, "value": line.value} for line in priv_lines] + if priv_lines + else [] + ) + return payload + + def action_saleor_collection_sync(self): + # Sync selected collections to all active Saleor accounts + account = get_active_saleor_account(self.env, raise_if_missing=True) + if len(self) == 1: + col = self + payload = col._saleor_collection_prepare_payload() + account.job_collection_sync(col.id, payload) + else: + # Multiple: batch + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for col in self: + payload = col._saleor_collection_prepare_payload() + items.append({"id": col.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_collection_sync_batch(chunk) + else: + account.job_collection_sync_batch(chunk) + return True diff --git a/sale_saleor/models/product_image.py b/sale_saleor/models/product_image.py new file mode 100644 index 0000000000..cd3bd81e67 --- /dev/null +++ b/sale_saleor/models/product_image.py @@ -0,0 +1,119 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import base64 +import logging + +from odoo import api, fields, models +from odoo.exceptions import ValidationError +from odoo.tools.image import is_image_size_above + +from odoo.addons.web_editor.tools import get_video_embed_code, get_video_thumbnail + +from ..helpers import get_active_saleor_account + +_logger = logging.getLogger(__name__) + + +class ProductImage(models.Model): + _name = "saleor.product.image" + _description = "Product Image" + _inherit = ["image.mixin"] + _order = "sequence, id" + + name = fields.Char(required=True) + sequence = fields.Integer(default=10) + image_1920 = fields.Image() + product_tmpl_id = fields.Many2one( + string="Product Template", + comodel_name="product.template", + ondelete="cascade", + index=True, + required=True, + ) + video_url = fields.Char( + string="Video URL", + help="URL of a video for showcasing your product.", + ) + embed_code = fields.Html(compute="_compute_embed_code", sanitize=False) + can_image_1024_be_zoomed = fields.Boolean( + string="Can Image 1024 be zoomed", + compute="_compute_can_image_1024_be_zoomed", + store=True, + ) + saleor_image_id = fields.Char( + string="Saleor Image ID", + copy=False, + index=True, + help="ID of this image in Saleor", + ) + + @api.depends("image_1920") + def _compute_can_image_1024_be_zoomed(self): + for image in self: + image.can_image_1024_be_zoomed = image.image_1920 and is_image_size_above( + image.image_1920, image.image_1024 + ) + + @api.depends("video_url") + def _compute_embed_code(self): + for image in self: + if image.video_url: + image.embed_code = get_video_embed_code(image.video_url) + else: + image.embed_code = False + + def unlink(self): + """ + Override unlink to delete corresponding image from Saleor + when an image is deleted in Odoo. + """ + account = get_active_saleor_account(self.env) + + # Delete images from Saleor if they exist + for image in self.filtered("saleor_image_id"): + try: + client = account._get_client() + account._delete_product_image(client, image.saleor_image_id) + except Exception as e: + _logger.error( + "Failed to delete image %s from Saleor: %s", + image.saleor_image_id, + str(e), + exc_info=True, + ) + + # Call the original unlink method + return super().unlink() + + @api.onchange("video_url") + def _onchange_video_url(self): + if not self.image_1920: + thumbnail = get_video_thumbnail(self.video_url) + self.image_1920 = thumbnail and base64.b64encode(thumbnail) or False + + @api.constrains("video_url") + def _check_valid_video_url(self): + for image in self: + if image.video_url and not image.embed_code: + raise ValidationError( + self.env._( + "Provided video URL for %s is not valid." + " Please enter a valid video URL.", + image.name, + ) + ) + + @api.model_create_multi + def create(self, vals_list): + """ + Create product images with the provided values. + Ensures product_tmpl_id is set either from context or values. + """ + for vals in vals_list: + if ( + "product_tmpl_id" not in vals + and "default_product_tmpl_id" in self.env.context + ): + vals["product_tmpl_id"] = self.env.context["default_product_tmpl_id"] + return super().create(vals_list) diff --git a/sale_saleor/models/product_product.py b/sale_saleor/models/product_product.py new file mode 100644 index 0000000000..dcca4fbe40 --- /dev/null +++ b/sale_saleor/models/product_product.py @@ -0,0 +1,427 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from markupsafe import Markup + +from odoo import fields, models +from odoo.exceptions import UserError + +from ..helpers import get_active_saleor_account + +_logger = logging.getLogger(__name__) + + +class ProductProduct(models.Model): + _name = "product.product" + _inherit = ["product.product", "mail.thread", "mail.activity.mixin"] + + # Saleor sync fields + saleor_variant_id = fields.Char( + string="Saleor Variant ID", + copy=False, + index=True, + help="ID of this product variant in Saleor", + ) + # Variant-level channels (override template channels if set) + channel_ids = fields.Many2many( + "saleor.channel", + "saleor_channel_product_product_rel", + "product_id", + "channel_id", + help=( + "Saleor channels where this variant is available." + " Overrides template channels if set." + ), + ) + saleor_sku = fields.Char( + string="Saleor SKU", + related="default_code", + store=True, + readonly=False, + help=""" + This field is synchronized with the default_code (Internal Reference) field + """, + ) + + def _variant_add_channels(self, payload): + def _collect_channels(recs): + ids_ = [ch.saleor_channel_id for ch in recs if ch.saleor_channel_id] + miss = [ch.display_name for ch in recs if not ch.saleor_channel_id] + return ids_, miss + + variant_channels = getattr(self, "channel_ids", False) and self.channel_ids + if variant_channels: + channel_saleor_ids, missing = _collect_channels(variant_channels) + if missing: + raise UserError( + self.env._( + "Please sync the following channels to Saleor first: %s", + ", ".join(missing), + ) + ) + if channel_saleor_ids: + payload["addChannels"] = channel_saleor_ids + else: + tmpl = self.product_tmpl_id + if getattr(tmpl, "channel_ids", False) and tmpl.channel_ids: + channel_saleor_ids, missing = _collect_channels(tmpl.channel_ids) + if missing: + raise UserError( + self.env._( + "Please sync the following channels to Saleor first: %s", + ", ".join(missing), + ) + ) + if channel_saleor_ids: + payload["addChannels"] = channel_saleor_ids + + def _variant_add_cost_price(self, payload): + try: + currency_code = self.product_tmpl_id.currency_id.name + if currency_code and self.standard_price is not None: + payload["costPrice"] = { + "amount": float(self.standard_price), + "currency": currency_code, + } + except Exception as e: + _logger.debug( + "Saleor: skip costPrice mapping for %s: %s", + self.display_name, + e, + ) + + def _variant_add_channel_listings(self, payload): + try: + ch_ids = payload.get("addChannels") or [] + if not ch_ids: + return + preferred = ( + self.channel_ids + or getattr(self.product_tmpl_id, "channel_ids", False) + and self.product_tmpl_id.channel_ids + or self.env["saleor.channel"] + ) + by_saleor_id = { + ch.saleor_channel_id: ch for ch in preferred if ch.saleor_channel_id + } + listings = [] + for cid in ch_ids: + ch = by_saleor_id.get(cid) + if not ch: + continue + listings.append( + { + "channelId": cid, + # Saleor expects PositiveDecimal (string) + "price": str(float(self.lst_price or 0.0)), + } + ) + if listings: + payload["channelListings"] = listings + except Exception as e: + _logger.debug( + "Saleor: skip channelListings build for %s: %s", + self.display_name, + e, + ) + + def _variant_add_weight(self, payload): + try: + weight_val = float(self.weight or 0.0) + if weight_val > 0: + payload["weight"] = weight_val + except Exception as e: + _logger.debug( + "Saleor: skip weight mapping for %s: %s", + self.display_name, + e, + ) + + def _saleor_prepare_variant_payload(self, product_id): + """Prepare the payload for syncing a product variant to Saleor.""" + self.ensure_one() + payload = { + "product": product_id, + "name": self.name, + # Allow empty SKU if default_code is not set + "sku": self.default_code or "", + "trackInventory": True, + } + + # Enrich payload via helpers + self._variant_add_channels(payload) + self._variant_add_cost_price(payload) + self._variant_add_channel_listings(payload) + self._variant_add_weight(payload) + + return payload + + def _saleor_sync_variant(self, saleor_account, product_id): + """Sync this product variant to Saleor.""" + self.ensure_one() + + if not self.default_code: + _logger.warning( + "Skipping variant sync: Missing default_code for product variant ID %s", + self.id, + ) + return None + + client = saleor_account._get_client() + + try: + if self.saleor_variant_id: + # Update existing variant + saleor_account._refresh_token(client) + payload = self._saleor_prepare_variant_payload(product_id) + result = client.product_variant_update(self.saleor_variant_id, payload) + else: + # Create new variant + saleor_account._refresh_token(client) + result = client.product_variant_create( + product_id=product_id, + # Allow empty SKU if default_code is not set + sku=self.default_code or "", + name=self.name, + attributes=[], + weight=( + float(self.weight) if self.weight and self.weight > 0 else None + ), + ) + + # Save the Saleor variant ID and post success message + if result and result.get("id"): + self.sudo().write({"saleor_variant_id": result["id"]}) + # Use the existing _post_success method from saleor.account + saleor_account._post_success( + rec=self, + object_type="product_variant", + slug=self.default_code or str(self.id), + payload={ + "name": self.name, + "sku": self.default_code or str(self.id), + }, + saleor_id=result["id"], + ) + + # If variant/template has channels, update variant channels now + preferred = self.channel_ids or self.product_tmpl_id.channel_ids + channel_saleor_ids = [ + ch.saleor_channel_id for ch in preferred if ch.saleor_channel_id + ] + if channel_saleor_ids: + try: + saleor_account._refresh_token(client) + # Build per-channel listing updates with price and costPrice + update_channels = [] + for ch in preferred: + if not ch.saleor_channel_id: + continue + entry = { + "channelId": ch.saleor_channel_id, + # Saleor expects PositiveDecimal (string) + "price": str(float(self.lst_price or 0.0)), + } + try: + tmpl_currency = ( + self.product_tmpl_id.currency_id.name + ) + if ( + tmpl_currency + and self.standard_price is not None + ): + # PositiveDecimal (string) + entry["costPrice"] = str( + float(self.standard_price) + ) + except Exception as e: + _logger.debug( + "Saleor: skip per-channel costPrice for %s: %s", + self.display_name, + e, + ) + update_channels.append(entry) + + if update_channels: + client.product_variant_channel_listing_update( + result["id"], update_channels + ) + except Exception as e: + _logger.warning( + "Failed to set channels for variant %s" + " after create: %s", + self.default_code or self.id, + str(e), + ) + + return result + + except Exception as e: + _logger.error( + "Failed to sync variant %s to Saleor: %s", + self.default_code or self.id, + str(e), + ) + raise UserError( + self.env._("Failed to sync variant to Saleor: %s", str(e)) + ) from e + + def action_saleor_sync(self): + """Action to sync product variants to all active Saleor accounts. + + This method can be called from the UI to sync one or multiple variants. + """ + account = get_active_saleor_account(self.env, raise_if_missing=True) + if len(self) == 1: + variant = self + product_tmpl = variant.product_tmpl_id + if not product_tmpl.saleor_product_id: + raise UserError( + self.env._( + "Parent product %s is not synced with Saleor yet." + " Please sync the product first.", + product_tmpl.name, + ) + ) + payload = variant._saleor_prepare_variant_payload( + product_tmpl.saleor_product_id + ) + account.job_product_variant_sync( + variant.id, product_tmpl.saleor_product_id, payload + ) + else: + # Batch multi variants + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for variant in self: + product_tmpl = variant.product_tmpl_id + if not product_tmpl.saleor_product_id: + raise UserError( + self.env._( + "Parent product %s is not synced with Saleor yet." + " Please sync the product first.", + product_tmpl.name, + ) + ) + payload = variant._saleor_prepare_variant_payload( + product_tmpl.saleor_product_id + ) + items.append( + { + "variant_id": variant.id, + "product_saleor_id": product_tmpl.saleor_product_id, + "payload": payload, + } + ) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_product_variant_sync_batch(chunk) + else: + account.job_product_variant_sync_batch(chunk) + return True + + def _notify_saleor_sync(self, warehouse, success=True, error_msg=None): + """Post chatter notification for Saleor stock sync""" + if success: + body = f""" +

    Updated stock in Saleor:

    +
      +
    • Product: {self.display_name}
    • +
    • Warehouse: {warehouse.display_name}
    • +
    + """ + else: + body = f""" +

    Failed to update stock in Saleor:

    +
      +
    • Product: {self.display_name}
    • +
    • Warehouse: {warehouse.display_name}
    • +
    • Reason: {error_msg or self.env._("Unknown error")}
    • +
    + """ + + self.message_post(body=Markup(body)) + + def action_sync_product_quantities(self): + """Sync this product's stock quantities to all Saleor warehouses/locations.""" + account = get_active_saleor_account(self.env, raise_if_missing=True) + + for product in self: + if not product.saleor_variant_id: + product._notify_saleor_sync( + success=False, error_msg=self.env._("Missing Saleor Variant ID") + ) + continue + + # Collect all Saleor-enabled warehouses and locations + saleor_sources = [] + warehouses = self.env["stock.warehouse"].search( + [ + ("is_saleor_warehouse", "=", True), + ("include_in_saleor_inventory", "=", True), + ] + ) + locations = self.env["stock.location"].search( + [ + ("is_saleor_warehouse", "=", True), + ("include_in_saleor_inventory", "=", True), + ] + ) + + for wh in warehouses: + saleor_sources.append( + ("warehouse", wh, wh.view_location_id.id, wh.saleor_warehouse_id) + ) + for loc in locations: + saleor_sources.append( + ("location", loc, loc.id, loc.saleor_warehouse_id) + ) + + if not saleor_sources: + raise UserError( + self.env._("No Saleor warehouses or locations configured.") + ) + + for _source_type, source_rec, location_id, saleor_wh_id in saleor_sources: + if not saleor_wh_id: + continue + + # Aggregate stock at this location/warehouse + qty = ( + self.env["stock.quant"].read_group( + domain=[ + ("product_id", "=", product.id), + ("location_id", "child_of", location_id), + ], + fields=["quantity:sum"], + groupby=[], + )[0]["quantity"] + or 0.0 + ) + + try: + _logger.info( + "Syncing product %s (variant=%s) qty=%s to Saleor warehouse %s", + product.display_name, + product.saleor_variant_id, + qty, + source_rec.display_name, + ) + account.with_delay().job_variant_stock_update( + variant_id=product.saleor_variant_id, + warehouse_id=saleor_wh_id, + quantity=qty, + ) + product._notify_saleor_sync(success=True, warehouse=source_rec) + except Exception as e: + _logger.exception( + "Failed to sync product %s to Saleor warehouse %s", + product.display_name, + source_rec.display_name, + ) + product._notify_saleor_sync( + success=False, error_msg=str(e), warehouse=source_rec + ) diff --git a/sale_saleor/models/product_template.py b/sale_saleor/models/product_template.py new file mode 100644 index 0000000000..d03bdacd37 --- /dev/null +++ b/sale_saleor/models/product_template.py @@ -0,0 +1,403 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import json as _json +import logging + +from markupsafe import Markup + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from ..helpers import generate_unique_slug, get_active_saleor_account, html_to_editorjs + +_logger = logging.getLogger(__name__) + + +class ProductTemplate(models.Model): + _name = "product.template" + _inherit = ["product.template", "mail.thread", "mail.activity.mixin"] + + # Saleor sync fields + saleor_slug = fields.Char(help="URL-friendly unique identifier in Saleor") + product_type_id = fields.Many2one( + "saleor.product.type", string="Saleor Product Type" + ) + saleor_seo_title = fields.Char(string="Saleor SEO Title") + saleor_seo_description = fields.Char(string="Saleor SEO Description") + saleor_product_description = fields.Html(string="Saleor Description (HTML)") + saleor_product_id = fields.Char( + string="Saleor Product ID", + copy=False, + index=True, + help="ID of this product in Saleor", + ) + + # Channels linkage + channel_ids = fields.Many2many( + "saleor.channel", + "saleor_channel_product_template_rel", + "product_tmpl_id", + "channel_id", + string="Channels", + help="Saleor channels where this product is available.", + ) + + # Optional: link to a collection to sync the product into Saleor collection + saleor_collection_id = fields.Many2one( + "product.collection", + string="Collection", + help="If set, the product will be added to this Saleor collection during sync.", + ) + + # New requirement + product_rating = fields.Integer() + + # Track fetch status + is_metadata_fetched = fields.Boolean( + string="Metadata Fetched", + default=False, + help="Indicates if metadata was successfully fetched from Saleor", + copy=False, + ) + + # Metadata holders + saleor_product_metadata_line_ids = fields.One2many( + "saleor.product.meta.line", "product_tmpl_id", string="Saleor Metadata" + ) + + # Product images + saleor_image_ids = fields.One2many( + "saleor.product.image", + "product_tmpl_id", + string="Product Images", + help="Images of the product variant, displayed in the eCommerce.", + ) + + saleor_product_private_metadata_line_ids = fields.One2many( + "saleor.product.private.meta.line", + "product_tmpl_id", + string="Saleor Private Metadata", + ) + + _sql_constraints = [ + ( + "saleor_slug_unique", + "unique(saleor_slug)", + "Saleor slug must be unique on products.", + ) + ] + + def _saleor_prepare_payload(self): + self.ensure_one() + if not self.saleor_slug and self.name: + self.saleor_slug = generate_unique_slug( + self, self.name, slug_field_name="saleor_slug" + ) + payload = { + # Store the current image IDs to track deleted images + "_saleor_current_image_ids": self.saleor_image_ids.filtered( + "saleor_image_id" + ).mapped("saleor_image_id"), + "name": self.name, + "slug": self.saleor_slug, + } + # Saleor expects JSONString, i.e., a JSON-encoded string, not a dict + if self.saleor_product_description: + desc = html_to_editorjs(self.saleor_product_description) + if desc is not None: + payload["description"] = _json.dumps(desc) + seo = {} + if self.saleor_seo_title: + seo["title"] = self.saleor_seo_title + if self.saleor_seo_description: + seo["description"] = self.saleor_seo_description + if seo: + payload["seo"] = seo + + # Channels + if self.channel_ids: + # Collect Saleor channel IDs and ensure every selected channel is synced + channel_saleor_ids = [ + ch.saleor_channel_id for ch in self.channel_ids if ch.saleor_channel_id + ] + missing = [ + ch.display_name for ch in self.channel_ids if not ch.saleor_channel_id + ] + if missing: + raise UserError( + self.env._( + "Please sync the following channels to Saleor first: %s", + ", ".join(missing), + ) + ) + if channel_saleor_ids: + payload["addChannels"] = channel_saleor_ids + + # Map metadata lines + meta_lines = self.saleor_product_metadata_line_ids + payload["metadata"] = ( + [{"key": line.key, "value": line.value} for line in meta_lines] + if meta_lines + else [] + ) + priv_lines = self.saleor_product_private_metadata_line_ids + payload["privateMetadata"] = ( + [{"key": line.key, "value": line.value} for line in priv_lines] + if priv_lines + else [] + ) + + # Map product_rating to Saleor 'rating' field + if self.product_rating is not None: + try: + payload["rating"] = int(self.product_rating) + except Exception as e: + _logger.warning( + "Saleor product rating is non-numeric for product %s: %r (%s)", + self.display_name, + self.product_rating, + e, + ) + payload["rating"] = self.product_rating + + return payload + + def action_saleor_sync(self): + account = get_active_saleor_account(self.env, raise_if_missing=True) + # If only one record selected, run immediate (no queue) + if len(self) == 1: + tmpl = self + payload = tmpl._saleor_prepare_payload() + account.job_product_sync(tmpl.id, payload) + else: + # Multiple records: batch into groups + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for tmpl in self: + payload = tmpl._saleor_prepare_payload() + items.append({"id": tmpl.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_product_sync_batch(chunk) + else: + account.job_product_sync_batch(chunk) + return True + + @api.model + def write(self, vals): + res = super().write(vals) + # When attributes matrix changes, resync variants with Saleor + if "attribute_line_ids" in vals: + account = get_active_saleor_account(self.env, raise_if_missing=False) + if account: + templates = self.filtered(lambda t: t.saleor_product_id) + for tmpl in templates: + # Post chatter note about attribute change and resync + try: + variants = tmpl.product_variant_ids + variant_lines = [] + for v in variants: + # Collect attribute values if available + values = getattr( + v, + "product_template_attribute_value_ids", + self.env["product.template.attribute.value"], + ) + if values: + vals_str = ", ".join(values.mapped("name")) + variant_lines.append( + f"
  • Variant: {v.display_name}" + f" — {vals_str}
  • " + ) + else: + variant_lines.append( + f"
  • Variant: {v.display_name}
  • " + ) + variants_html = "".join(variant_lines) if variant_lines else "" + body = ( + "

    Product attributes changed; " + "queued Saleor variants resync.

    " + "
      " + f"
    • Template: {tmpl.display_name}
    • " + "
    " + ) + if variants_html: + body += "
      " f"{variants_html}" "
    " + tmpl.message_post(body=Markup(body)) + except Exception as e: + _logger.warning( + "Failed to post attribute change note for %s: %s", + tmpl.display_name, + e, + ) + try: + if hasattr(account, "with_delay"): + account.with_delay().job_product_variants_resync(tmpl.id) + else: + account.job_product_variants_resync(tmpl.id) + except Exception as e: + _logger.warning( + "Failed to enqueue Saleor variants resync for %s: %s", + tmpl.display_name, + e, + ) + return res + + def action_fetch_metadata(self): + """Fetch metadata and private metadata from selected Saleor account. + + For single record, process immediately. For multiple records, use queue_job. + """ + # Resolve the single active Saleor account + account = get_active_saleor_account(self.env, raise_if_missing=True) + + # For a single record, process immediately + if len(self) == 1: + self.write({"is_metadata_fetched": False}) + success = account.job_product_metadata_fetch(self.id) + if success: + _logger.info("Successfully fetched metadata from %s", account.name) + return success + + # For multiple records, use queue_job if available + self.write({"is_metadata_fetched": False}) + if hasattr(account, "with_delay"): + account.with_delay().job_saleor_fetch(self.ids, self._name) + else: + account.job_saleor_fetch(self.ids, self._name) + + _logger.info( + "Started fetching metadata for %s products from %s in the background", + len(self), + account.name, + ) + return True + + def _notify_saleor_sync(self, warehouse, success=True, error_msg=None): + """Notify sync result in chatter""" + if success: + body = f""" +

    Updated stock in Saleor:

    +
      +
    • Product: {self.display_name}
    • +
    • Warehouse: {warehouse.display_name}
    • +
    + """ + else: + body = f""" +

    Failed to update stock in Saleor:

    +
      +
    • Product: {self.display_name}
    • +
    • Warehouse: {warehouse.display_name}
    • +
    • Reason: {error_msg or self.env._('Unknown error')}
    • +
    + """ + self.message_post(body=Markup(body)) + + def action_sync_product_quantities(self): + account = get_active_saleor_account(self.env, raise_if_missing=True) + + saleor_warehouses = self.env["stock.warehouse"].search( + [ + ("is_saleor_warehouse", "=", True), + ("include_in_saleor_inventory", "=", True), + ] + ) + saleor_locations = self.env["stock.location"].search( + [ + ("is_saleor_warehouse", "=", True), + ("include_in_saleor_inventory", "=", True), + ] + ) + + if not saleor_warehouses and not saleor_locations: + raise UserError( + self.env._("No warehouses or locations marked for Saleor sync.") + ) + + # Collect all variants with Saleor IDs across selected templates + variants = self.mapped("product_variant_ids") + missing = variants.filtered(lambda v: not v.saleor_variant_id) + for v in missing: + reason_text = self.env._("Does not have a Saleor Variant ID") + body = f""" +

    Variant skipped during inventory synchronization:

    +
      +
    • Variant: {v.display_name}
    • +
    • Reason: {reason_text}
    • +
    + """ + v.product_tmpl_id.message_post(body=Markup(body)) + + variants = variants - missing + if not variants: + return True + + variant_by_id = {v.id: v for v in variants} + variant_ids = list(variant_by_id.keys()) + Quant = self.env["stock.quant"].sudo() + + # Batch by warehouses using _read_group + def _push_qty(prod, warehouse_id, qty, target): + qty = qty or 0.0 + try: + if hasattr(account, "with_delay"): + account.with_delay().job_variant_stock_update( + variant_id=prod.saleor_variant_id, + warehouse_id=warehouse_id, + quantity=qty, + ) + else: + account.job_variant_stock_update( + variant_id=prod.saleor_variant_id, + warehouse_id=warehouse_id, + quantity=qty, + ) + prod._notify_saleor_sync(target, success=True) + except Exception as e: + prod._notify_saleor_sync(target, success=False, error_msg=str(e)) + + for wh in saleor_warehouses: + domain = [ + ("location_id", "child_of", wh.view_location_id.id), + ("product_id", "in", variant_ids), + ] + rows = Quant._read_group( + domain, groupby=["product_id"], aggregates=["quantity:sum"] + ) + for group_val, qty in rows: + # group_val is a product.product recordset (prefetched) + prod_id = group_val and group_val.id + if not prod_id: + continue + prod = variant_by_id.get(prod_id) + if not prod: + continue + _push_qty(prod, wh.saleor_warehouse_id, qty, wh) + + # Batch by exact locations using _read_group + for loc in saleor_locations: + domain = [("location_id", "=", loc.id), ("product_id", "in", variant_ids)] + rows = Quant._read_group( + domain, groupby=["product_id"], aggregates=["quantity:sum"] + ) + for group_val, qty in rows: + prod_id = group_val and group_val.id + if not prod_id: + continue + prod = variant_by_id.get(prod_id) + if not prod: + continue + _push_qty(prod, loc.saleor_warehouse_id, qty, loc) + + # Post completion messages per template + for template in self: + body = f""" +

    Successfully synchronized inventory for template:

    +
      +
    • Template: {template.display_name}
    • +
    + """ + template.message_post(body=Markup(body)) diff --git a/sale_saleor/models/product_type_meta_line.py b/sale_saleor/models/product_type_meta_line.py new file mode 100644 index 0000000000..6cf93d3502 --- /dev/null +++ b/sale_saleor/models/product_type_meta_line.py @@ -0,0 +1,38 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ProductTypeMetaLine(models.Model): + _name = "product.type.meta.line" + _description = "Product Type Meta Line" + + product_type_id = fields.Many2one( + "saleor.product.type", + string="Product Type", + required=True, + ) + key = fields.Char( + required=True, + ) + value = fields.Char( + required=True, + ) + + +class ProductTypePrivateMetaLine(models.Model): + _name = "product.type.private.meta.line" + _description = "Product Type Private Meta Line" + + product_type_id = fields.Many2one( + "saleor.product.type", + string="Product Type", + required=True, + ) + key = fields.Char( + required=True, + ) + value = fields.Char( + required=True, + ) diff --git a/sale_saleor/models/res_partner.py b/sale_saleor/models/res_partner.py new file mode 100644 index 0000000000..e1342af194 --- /dev/null +++ b/sale_saleor/models/res_partner.py @@ -0,0 +1,21 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + saleor_customer_id = fields.Char( + string="Saleor Customer ID", + index=True, + copy=False, + help="ID of the customer record in Saleor", + ) + saleor_account_id = fields.Many2one( + "saleor.account", + string="Saleor Account", + copy=False, + help="Saleor account this customer is linked to", + ) diff --git a/sale_saleor/models/sale_order.py b/sale_saleor/models/sale_order.py new file mode 100644 index 0000000000..8cb406f356 --- /dev/null +++ b/sale_saleor/models/sale_order.py @@ -0,0 +1,168 @@ +import logging + +from odoo import fields, models +from odoo.exceptions import UserError + +from ..helpers import get_active_saleor_account + +_logger = logging.getLogger(__name__) + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + saleor_order_id = fields.Char(copy=False, index=True) + saleor_channel_id = fields.Many2one("saleor.channel", string="Saleor Channel") + saleor_delivery_carrier_id = fields.Many2one( + "delivery.carrier", + string="Delivery Carrier", + ) + saleor_mark_as_paid = fields.Boolean(copy=False, default=False) + # --- Saleor order/payment info (populated by webhook) --- + saleor_status = fields.Char(copy=False) + saleor_number = fields.Char(copy=False) + saleor_payment_id = fields.Char(copy=False) + saleor_payment_gateway = fields.Char(copy=False) + saleor_payment_charge_status = fields.Char(copy=False) + saleor_payment_total = fields.Monetary(currency_field="currency_id", copy=False) + saleor_payment_captured_amount = fields.Monetary( + currency_field="currency_id", copy=False + ) + saleor_payment_currency = fields.Char(copy=False) + saleor_payment_psp_reference = fields.Char(copy=False) + saleor_payment_updated = fields.Datetime(copy=False) + saleor_payment_payload = fields.Text(copy=False) + is_abandoned = fields.Boolean(default=False, readonly=True) + + def _saleor_prepare_address(self, partner): + if not partner: + return None + country_area = None + try: + if partner.state_id: + country_area = partner.state_id.code or partner.state_id.name or None + except Exception: + country_area = None + return { + "firstName": partner.name or "", + "lastName": "", + "streetAddress1": partner.street or "", + "streetAddress2": partner.street2 or "", + "city": partner.city or "", + "postalCode": partner.zip or "", + "country": (partner.country_id and partner.country_id.code) or None, + "countryArea": country_area, + "phone": partner.phone or partner.mobile or "", + } + + def _saleor_prepare_order_payload(self): + self.ensure_one() + if not self.saleor_channel_id or not self.saleor_channel_id.saleor_channel_id: + raise UserError( + self.env._("Please select a Saleor Channel with remote ID.") + ) + partner = self.partner_id + billing = self._saleor_prepare_address(self.partner_invoice_id or partner) + shipping = self._saleor_prepare_address(self.partner_shipping_id or partner) + + # Validate required state/region for certain countries + def _needs_state(addr): + return (addr or {}).get("country") in {"US", "CA"} + + for label, addr in (("Billing", billing), ("Shipping", shipping)): + if _needs_state(addr) and not (addr or {}).get("countryArea"): + raise UserError( + self.env._( + "%s address requires a State/Province for country %s.", + label, + (addr or {}).get("country") or "", + ) + ) + user_id = getattr(partner, "saleor_customer_id", None) + payload = { + "channelId": self.saleor_channel_id.saleor_channel_id, + "billingAddress": billing, + "shippingAddress": shipping, + "lines": [ + { + "variantId": line.product_id.saleor_variant_id, + "quantity": int(line.product_uom_qty), + } + for line in self.order_line + if line.product_id + and line.product_id.saleor_variant_id + and line.display_type is False + and not getattr(line, "is_delivery", False) + ], + } + # Optional fields: include only if present + if user_id: + payload["user"] = user_id + elif partner.email: + payload["userEmail"] = partner.email + missing = [ + line.product_id.display_name + for line in self.order_line + if line.product_id + and not line.product_id.saleor_variant_id + and line.display_type is False + and not getattr(line, "is_delivery", False) + ] + if missing: + raise UserError( + self.env._( + "The following products are not synced" + " to Saleor (no variant ID): %s", + ", ".join(sorted(set(missing))), + ) + ) + return payload + + def action_sync_to_saleor(self): + account = get_active_saleor_account(self.env, raise_if_missing=True) + if len(self) == 1: + payload = self._saleor_prepare_order_payload() + account.job_order_sync(self.id, payload) + return True + # Multiple: batch + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for order in self: + payload = order._saleor_prepare_order_payload() + items.append({"id": order.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_order_sync_batch(chunk) + else: + account.job_order_sync_batch(chunk) + return True + + def action_mark_paid_in_saleor(self): + account = get_active_saleor_account(self.env, raise_if_missing=True) + for order in self: + if not order.saleor_order_id: + raise UserError( + self.env._("This order is not linked to a Saleor order.") + ) + client = account._get_client() + account._refresh_token(client) + tx_ref = f"Odoo-{order.name}" if order.name else None + res = client.order_mark_as_paid( + order.saleor_order_id, transaction_reference=tx_ref + ) + try: + order.message_post( + body=self.env._( + "Marked as paid in Saleor (order: %s)", + (res or {}).get("id") or order.saleor_order_id, + ) + ) + order.write({"saleor_mark_as_paid": True}) + except Exception as e: + _logger.warning( + "Failed to post 'Marked as paid' message on sale.order %s: %s", + order.id, + e, + ) + return True diff --git a/sale_saleor/models/saleor_account.py b/sale_saleor/models/saleor_account.py new file mode 100644 index 0000000000..5985ed7900 --- /dev/null +++ b/sale_saleor/models/saleor_account.py @@ -0,0 +1,5153 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import secrets +import time +from datetime import timedelta +from functools import partial + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from ..helpers import ( + decode_image_field, + format_batch_errors_message, + format_kv_list, + format_note, + generate_unique_slug, + make_link, + post_to_current_job_committed, + saleor_dashboard_links, + upsert_tax_class, +) +from ..utils import SaleorClient, _logger + + +# --- Module-level helpers (reusable) --- +def saleor_collection_do_update(client, _id, payload, filename, file_bytes, ctype): + """Update a collection with optional background image.""" + return client.collection_update( + _id, + payload, + filename=filename, + file_bytes=file_bytes, + content_type=ctype, + ) + + +def saleor_product_do_update(client, _id, payload, filename, file_bytes, ctype): + """Update a product (image upload handled separately).""" + del filename, file_bytes, ctype # not used for product update + return client.product_update(_id, payload) + + +def saleor_attribute_do_update(client, _id, payload, filename, file_bytes, ctype): + """Update an attribute; media params unused.""" + del filename, file_bytes, ctype + return client.attribute_update(_id, payload) + + +class SaleorAccount(models.Model): + _name = "saleor.account" + _description = "Saleor Account" + _rec_name = "name" + + TOKEN_EXPIRY_MINUTES = 4 + DEFAULT_APP_PERMISSIONS = ["MANAGE_USERS", "MANAGE_TAXES", "MANAGE_ORDERS"] + + name = fields.Char(required=True) + base_url = fields.Char( + required=True, help="Base URL of the Saleor site", string="Saleor Base URL" + ) + email = fields.Char(required=True, help="Saleor staff user email for API auth") + password = fields.Char( + required=True, + groups="sale_saleor.group_saleor_manager", + ) + odoo_base_url = fields.Char( + string="Odoo Base URL", help="Enter the base URL of your Odoo instance" + ) + + customer_webhook_url = fields.Char( + string="Webhook Target URL", + compute="_compute_webhook_url", + help="This URL is automatically generated", + store=True, + ) + payment_webhook_url = fields.Char( + string="Payment Webhook URL", + compute="_compute_webhook_url", + help="Auto-generated URL for order payment updates", + store=True, + ) + order_webhook_url = fields.Char( + string="Order Webhook URL", + compute="_compute_webhook_url", + help="Auto-generated URL for order created/updated", + store=True, + ) + draft_order_webhook_url = fields.Char( + string="Draft Order Webhook URL", + compute="_compute_webhook_url", + help="Auto-generated URL for draft order created/updated", + store=True, + ) + + verify_ssl = fields.Boolean(default=True) + + token = fields.Char( + readonly=True, + store=False, + groups="sale_saleor.group_saleor_manager", + ) + token_expiry = fields.Datetime( + readonly=True, + store=False, + groups="sale_saleor.group_saleor_manager", + ) + active = fields.Boolean(default=False) + + # Batch size for queue jobs when syncing multiple records + job_batch_size = fields.Integer(default=10, string="Batch Size") + + # App/Webhook automation fields + saleor_app_id = fields.Char(copy=False, readonly=True, string="Saleor App ID") + saleor_app_token = fields.Char( + copy=False, + readonly=True, + groups="sale_saleor.group_saleor_manager", + ) + saleor_customer_webhook_id = fields.Char( + copy=False, readonly=True, string="Saleor Webhook ID" + ) + saleor_payment_webhook_id = fields.Char( + copy=False, readonly=True, string="Saleor Payment Webhook ID" + ) + saleor_webhook_secret = fields.Char( + copy=False, + help="HMAC secret used to verify incoming webhook payloads from Saleor", + groups="sale_saleor.group_saleor_manager", + ) + saleor_order_webhook_id = fields.Char( + copy=False, readonly=True, string="Saleor Order Webhook ID" + ) + saleor_draft_order_webhook_id = fields.Char( + copy=False, readonly=True, string="Saleor Draft Order Webhook ID" + ) + + def _get_client(self): + self.ensure_one() + # Prefer App token if available (long-lived). Otherwise use staff JWT. + client = SaleorClient(self.base_url, verify_ssl=self.verify_ssl) + if self.saleor_app_token: + client.set_token(self.saleor_app_token) + else: + self._refresh_token(client) + return client + + @api.depends("odoo_base_url") + def _compute_webhook_url(self): + """Compute webhook target URLs based on the Odoo base URL.""" + for rec in self: + if rec.odoo_base_url: + base = rec.odoo_base_url.rstrip("/") + rec.customer_webhook_url = base + "/saleor/webhook/customer" + rec.payment_webhook_url = base + "/saleor/webhook/order_payment" + rec.order_webhook_url = base + "/saleor/webhook/order_created_updated" + rec.draft_order_webhook_url = base + "/saleor/webhook/draft_order" + else: + rec.customer_webhook_url = False + rec.payment_webhook_url = False + rec.order_webhook_url = False + rec.draft_order_webhook_url = False + + @api.constrains("active") + def _check_single_active_account(self): + for rec in self: + if rec.active: + count_active = self.search_count([("active", "=", True)]) + if count_active > 1: + raise UserError( + self.env._("Only one Saleor account can be active at a time.") + ) + + def _refresh_token(self, client=None): + self.ensure_one() + client = client or SaleorClient(self.base_url, verify_ssl=self.verify_ssl) + + # Fast-path: in-memory cache on the current record + now = fields.Datetime.now() + if self.token and self.token_expiry and now < self.token_expiry: + client.set_token(self.token) + return self.token + + # Acquire a row-level lock to prevent concurrent refreshes. + self.env.cr.execute( + "SELECT id FROM saleor_account WHERE id = %s FOR UPDATE NOWAIT", + (self.id,), + ) + + # Re-read after lock acquisition to see if another worker already refreshed + fresh = self.sudo().browse(self.id) + now = fields.Datetime.now() + if fresh.token and fresh.token_expiry and now < fresh.token_expiry: + client.set_token(fresh.token) + # Update in-memory cache on current record + self.token = fresh.token + self.token_expiry = fresh.token_expiry + return fresh.token + + # Token actually expired or missing; perform a real refresh + token = client.token_create(self.email, self.password) + expiry = now + timedelta(minutes=self.TOKEN_EXPIRY_MINUTES) + fresh.write({"token": token, "token_expiry": expiry}) + client.set_token(token) + # Update in-memory cache for the current record + self.token = token + self.token_expiry = expiry + return token + + # --- App/Webhook automation --- + def _ensure_app_and_webhook(self): + """Ensure a Saleor App exists with proper permissions and a webhook + pointing to our computed webhook_url. Store the app token for future API calls. + """ + self.ensure_one() + if not self.active or not self.customer_webhook_url: + return + + # Use staff JWT to create/ensure the App; then store app token for future use + staff_client = SaleorClient(self.base_url, verify_ssl=self.verify_ssl) + # Authenticate staff client to allow app create/update + try: + self._refresh_token(staff_client) + except Exception as e: + _logger.warning("Failed to authenticate staff client for app ensure: %s", e) + app_id, app_token = self._ensure_app(staff_client) + vals = {} + if app_id and self.saleor_app_id != app_id: + vals["saleor_app_id"] = app_id + if app_token and self.saleor_app_token != app_token: + vals["saleor_app_token"] = app_token + if vals: + self.write(vals) + + # Ensure we have a secret + if not self.saleor_webhook_secret: + self.saleor_webhook_secret = secrets.token_hex(32) + + # Ensure customer and payment webhooks + client = SaleorClient( + self.base_url, + verify_ssl=self.verify_ssl, + token=self.saleor_app_token or None, + ) + self._ensure_webhook( + client, + self.customer_webhook_url, + "saleor_customer_webhook_id", + ["CUSTOMER_UPDATED"], + "Customer", + ) + self._ensure_webhook( + client, + self.payment_webhook_url, + "saleor_payment_webhook_id", + ["ORDER_PAID", "ORDER_FULLY_PAID"], + "Payment", + ) + self._ensure_webhook( + client, + self.order_webhook_url, + "saleor_order_webhook_id", + ["ORDER_CREATED", "ORDER_UPDATED"], + "Order", + ) + self._ensure_webhook( + client, + self.draft_order_webhook_url, + "saleor_draft_order_webhook_id", + ["DRAFT_ORDER_CREATED", "DRAFT_ORDER_UPDATED"], + "Draft Order", + ) + + def _ensure_app(self, staff_client): + """Ensure a Saleor App exists and has required permissions.""" + # Basic idempotency: try find existing App by stored ID + app = None + if self.saleor_app_id: + try: + app = staff_client.app_get_by_id(self.saleor_app_id) + except Exception as e: + _logger.warning( + "Failed to fetch Saleor App %s by id: %s", + self.saleor_app_id, + e, + ) + app = None + if not app: + permissions = self.DEFAULT_APP_PERMISSIONS + app_name = f"Odoo Integration ({self.name})" + res = staff_client.app_create( + name=app_name, permissions=permissions, is_active=True + ) + return (res and res.get("id"), res and res.get("authToken")) + # Update permissions best-effort + try: + staff_client.app_update( + app.get("id"), permissions=self.DEFAULT_APP_PERMISSIONS + ) + except Exception as e: + _logger.warning("Failed to update Saleor App %s: %s", app.get("id"), e) + return (app.get("id"), None) + + def _ensure_webhook(self, client, url, id_field, events, name_suffix): + """Generic helper to ensure a Saleor webhook exists and is up to date.""" + + target_url = url + if not target_url: + _logger.debug( + "Saleor %s webhook ensure skipped" + " because target URL is empty on account %s", + name_suffix, + self.name, + ) + return + + webhook_id = getattr(self, id_field, None) + webhook = None + try: + if webhook_id: + webhook = client.webhook_get_by_id(webhook_id) + except Exception as e: + _logger.warning( + "Failed to fetch existing Saleor %s webhook %s for account %s: %s", + name_suffix, + webhook_id, + self.name, + e, + ) + webhook = None + + if webhook: + need_update = webhook.get("targetUrl") != target_url or not webhook.get( + "isActive", True + ) + if need_update: + upd = client.webhook_update( + webhook_id=webhook.get("id"), + target_url=target_url, + events=events, + secret_key=self.saleor_webhook_secret, + is_active=True, + ) + if upd and upd.get("id") and upd.get("id") != webhook_id: + self.write({id_field: upd.get("id")}) + else: + created = client.webhook_create( + app_id=self.saleor_app_id, + target_url=target_url, + events=events, + secret_key=self.saleor_webhook_secret, + is_active=True, + name=f"Odoo {name_suffix} Webhook ({self.name})", + ) + if created and created.get("id"): + self.write({id_field: created.get("id")}) + + # --- Saleor → Odoo Order upsert --- + def _import_saleor_order(self, order): + """Idempotently create/update a sale.order from a Saleor order payload.""" + self.ensure_one() + if not order: + return False + + unit_uom = self._get_default_unit_uom() + saleor_order_id = order.get("id") + number = order.get("number") + status = order.get("status") + + partner, inv_partner, ship_partner = self._resolve_partner_and_addresses(order) + channel_rec = self._resolve_channel(order) + + so = self._find_or_create_sale_order( + saleor_order_id, + number, + status, + partner, + inv_partner, + ship_partner, + channel_rec, + ) + + self._compute_discount_total(order) + # Only modify order lines and shipping while the order is in draft/sent + if so.state in ("draft", "sent"): + # Rebuild lines safely in draft + so.order_line.unlink() + self._add_order_lines(so, order, unit_uom) + self._add_shipping_line(so, order, saleor_order_id) + else: + pass + # If the Saleor order is no longer in DRAFT status, ensure the quotation + # is not considered abandoned anymore. + if status and status != "DRAFT" and hasattr(so, "is_abandoned"): + so.write({"is_abandoned": False}) + self._store_payment_info(so, order) + self._post_sync_message(so, number, saleor_order_id) + + return so.id + + def _get_default_unit_uom(self): + try: + return self.env.ref("uom.product_uom_unit", raise_if_not_found=False) + except Exception: + return False + + def _resolve_partner_and_addresses(self, order): + Partner = self.env["res.partner"].sudo() + user = order.get("user") or {} + customer_id = user.get("id") or None + email = user.get("email") or None + first = user.get("firstName") or "" + last = user.get("lastName") or "" + cust_name = (first + " " + last).strip() or email or "Saleor Customer" + + # Prefer matching by Saleor IDs when provided + partner = None + if customer_id: + partner = Partner.search( + [ + ("saleor_customer_id", "=", customer_id), + ("saleor_account_id", "=", self.id), + ], + limit=1, + ) + # If not found by IDs, try by email to adopt identifiers + if not partner and email: + partner = Partner.search([("email", "=ilike", email)], limit=1) + if partner: + partner.write( + { + "saleor_customer_id": customer_id, + "saleor_account_id": self.id, + } + ) + + # If still not found, or no customer_id provided, fall back to email-only + if not partner and email: + partner = Partner.search([("email", "=ilike", email)], limit=1) + + # Create partner if needed + if not partner: + vals = {"name": cust_name, "email": email or False} + if customer_id: + vals.update( + { + "saleor_customer_id": customer_id, + "saleor_account_id": self.id, + } + ) + partner = Partner.create(vals) + + # Ensure basic fields (name/email) are up to date + to_write = {} + if cust_name and partner.name != cust_name: + to_write["name"] = cust_name + if email and (partner.email or "").lower() != (email or "").lower(): + to_write["email"] = email + if customer_id: + # Persist identifiers if missing + if not getattr(partner, "saleor_customer_id", None): + to_write["saleor_customer_id"] = customer_id + if not getattr(partner, "saleor_account_id", None): + to_write["saleor_account_id"] = self.id + if to_write: + partner.write(to_write) + + # Child addresses + billing = order.get("billingAddress") or {} + shipping = order.get("shippingAddress") or {} + inv_partner = self._ensure_child_partner(partner, billing, "invoice") + ship_partner = self._ensure_child_partner(partner, shipping, "delivery") + return partner, inv_partner, ship_partner + + def _ensure_child_partner(self, parent, addr, atype): + Partner = self.env["res.partner"].sudo() + if not addr: + return parent + vals = { + "parent_id": parent.id, + "type": atype, + "name": (addr.get("firstName") or "") + " " + (addr.get("lastName") or ""), + "street": addr.get("streetAddress1") or "", + "street2": addr.get("streetAddress2") or "", + "city": addr.get("city") or "", + "zip": addr.get("postalCode") or "", + "phone": addr.get("phone") or "", + } + country_code = ( + (addr.get("country") or {}).get("code") if addr.get("country") else None + ) + if country_code: + country = ( + self.env["res.country"] + .sudo() + .search([("code", "=", country_code)], limit=1) + ) + if country: + vals["country_id"] = country.id + state_code = addr.get("countryArea") or None + if state_code: + state = ( + self.env["res.country.state"] + .sudo() + .search( + [ + ("code", "=", state_code), + ("country_id", "=", country.id), + ], + limit=1, + ) + ) + if state: + vals["state_id"] = state.id + child = Partner.search( + [ + ("parent_id", "=", parent.id), + ("type", "=", atype), + ("street", "=", vals["street"]), + ("zip", "=", vals["zip"]), + ], + limit=1, + ) + if child: + child.write(vals) + else: + child = Partner.create(vals) + return child + + def _resolve_channel(self, order): + ch = order.get("channel") or {} + ch_id = ch.get("id") + ch_slug = ch.get("slug") + Channel = self.env["saleor.channel"].sudo() + channel_rec = None + if ch_id: + channel_rec = Channel.search([("saleor_channel_id", "=", ch_id)], limit=1) + if not channel_rec and ch_slug: + channel_rec = Channel.search([("slug", "=", ch_slug)], limit=1) + return channel_rec + + def _find_or_create_sale_order( + self, + saleor_order_id, + number, + status, + partner, + inv_partner, + ship_partner, + channel_rec, + ): + SaleOrder = self.env["sale.order"].sudo() + so = SaleOrder.search([("saleor_order_id", "=", saleor_order_id)], limit=1) + # Base values always safe to set + base_vals = { + "saleor_order_id": saleor_order_id, + "saleor_number": number, + "saleor_status": status, + } + if channel_rec: + base_vals["saleor_channel_id"] = channel_rec.id + if so: + if so.state in ("draft", "sent"): + partner_vals = { + "partner_id": partner.id, + "partner_invoice_id": inv_partner.id if inv_partner else partner.id, + "partner_shipping_id": ship_partner.id + if ship_partner + else partner.id, + } + so.write({**partner_vals, **base_vals}) + else: + so.write(base_vals) + else: + create_vals = { + "partner_id": partner.id, + "partner_invoice_id": inv_partner.id if inv_partner else partner.id, + "partner_shipping_id": ship_partner.id if ship_partner else partner.id, + **base_vals, + } + so = SaleOrder.create(create_vals) + return so + + def _compute_discount_total(self, order): + total = 0.0 + for d in order.get("discounts") or []: + amt = ((d or {}).get("amount") or {}).get("amount") + try: + total += float(amt or 0) + except Exception as e: + _logger.debug("Failed to parse discount amount '%s': %s", amt, e) + return total + + def _add_order_lines(self, so, order, unit_uom): + ProductProduct = self.env["product.product"].sudo() + ProductTemplate = self.env["product.template"].sudo() + for line in order.get("lines") or []: + qty = int((line or {}).get("quantity") or 0) + if qty <= 0: + continue + var = (line or {}).get("variant") or {} + var_id = var.get("id") + prod = None + if var_id: + prod = ProductProduct.search( + [("saleor_variant_id", "=", var_id)], limit=1 + ) + if not prod: + pname = (line or {}).get( + "productName" + ) or f"Saleor Variant {var_id or ''}" + tvals2 = {"name": pname, "type": "product"} + if unit_uom: + tvals2.update({"uom_id": unit_uom.id, "uom_po_id": unit_uom.id}) + tmpl = ProductTemplate.create(tvals2) + prod = ProductProduct.create( + {"product_tmpl_id": tmpl.id, "saleor_variant_id": var_id or False} + ) + up = (line or {}).get("unitPrice") or {} + net_amt = (up.get("net") or {}).get("amount") + try: + unit_price = float(net_amt or 0.0) + except Exception: + unit_price = 0.0 + self.env["sale.order.line"].sudo().create( + { + "order_id": so.id, + "product_id": prod.id, + "product_uom_qty": qty, + "price_unit": unit_price, + } + ) + + def _add_shipping_line(self, so, order, saleor_order_id): + sp = order.get("shippingPrice") or {} + net_sp = (sp.get("net") or {}).get("amount") + try: + ship_total = float(net_sp or 0.0) + except Exception: + ship_total = 0.0 + if not ship_total: + return + ship_prod = None + sm = None + sm_name = None + try: + sm_node = order.get("shippingMethod") or {} + sm = sm_node.get("id") + sm_name = sm_node.get("name") + carrier = None + if sm_name: + carrier = ( + self.env["delivery.carrier"] + .sudo() + .search( + [("delivery_type", "=", "saleor"), ("name", "=", sm_name)], + limit=1, + ) + ) + if not carrier: + carrier = ( + self.env["delivery.carrier"] + .sudo() + .search( + [ + ("delivery_type", "=", "saleor"), + ("name", "ilike", sm_name), + ], + limit=1, + ) + ) + if not carrier and sm: + carrier = ( + self.env["delivery.carrier"] + .sudo() + .search( + [("saleor_shipping_method_id", "=", sm)], + limit=1, + ) + ) + if carrier: + try: + so.sudo().write({"carrier_id": carrier.id}) + except Exception: + _logger.debug("Could not set carrier_id on sale.order %s", so.id) + if carrier and carrier.product_id: + ship_prod = carrier.product_id + except Exception: + ship_prod = None + if ship_prod: + self.env["sale.order.line"].sudo().create( + { + "order_id": so.id, + "product_id": ship_prod.id, + "product_uom_qty": 1, + "price_unit": ship_total, + } + ) + else: + _logger.warning( + "Saleor order %s: shipping method %s not mapped" + " to a carrier product; skipping shipping line", + saleor_order_id, + sm or "(none)", + ) + + def _store_payment_info(self, so, order): + pays = order.get("payments") or [] + if not pays: + return + p = pays[0] or {} + total_money = p.get("total") or {} + cap_money = p.get("capturedAmount") or {} + try: + total_amt = float(total_money.get("amount") or 0.0) + except Exception: + total_amt = 0.0 + try: + cap_amt = float(cap_money.get("amount") or 0.0) + except Exception: + cap_amt = 0.0 + curr = total_money.get("currency") or cap_money.get("currency") + vals = { + "saleor_payment_id": p.get("id"), + "saleor_payment_gateway": p.get("gateway"), + "saleor_payment_charge_status": p.get("chargeStatus"), + "saleor_payment_total": total_amt, + "saleor_payment_captured_amount": cap_amt, + "saleor_payment_currency": curr, + "saleor_payment_psp_reference": p.get("pspReference"), + } + so.write(vals) + + def _post_sync_message(self, so, number, saleor_order_id): + try: + dash_url, obj_url = saleor_dashboard_links( + self.base_url, "order", id=saleor_order_id, number=number + ) + body = format_kv_list( + "Synced from Saleor:", + [ + ("Account", self.email or self.name), + ("Order", number or saleor_order_id), + ("Saleor", make_link("View in Saleor", obj_url)), + ], + ) + so.message_post(body=body) + except Exception as e: + _logger.debug( + "Failed to post Saleor sync message on order %s: %s", so.id, e + ) + + # Hooks to auto-ensure app/webhook + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + # Run after creation if active set + for rec in res: + if rec.active and (rec.customer_webhook_url or rec.payment_webhook_url): + rec._ensure_app_and_webhook() + return res + + def write(self, vals): + res = super().write(vals) + for rec in self: + # If active turned on or webhook URL(s) changed, ensure webhook/app + if rec.active and ( + "active" in vals + or "odoo_base_url" in vals + or "webhook_url" in vals + or "payment_webhook_url" in vals + ): + rec._ensure_app_and_webhook() + return res + + def _sync_parent_and_set_payload_parent(self, odoo_cat, payload): + parent = odoo_cat.parent_id + if not parent: + return + if not payload.get("parent"): + if not parent.saleor_category_id: + parent_payload = parent._saleor_prepare_payload() + # Sync parent directly in same account context + self.job_category_sync(parent.id, parent_payload) + if parent.saleor_category_id: + payload["parent"] = parent.saleor_category_id + + def _ensure_slug_and_get_existing(self, client, odoo_cat, payload): + slug = payload.get("slug") + if not slug: + name = payload.get("name") or odoo_cat.name + if name: + slug = generate_unique_slug( + odoo_cat, + name, + slug_field_name="saleor_slug", + ) + if slug: + payload["slug"] = slug + if slug: + self._refresh_token(client) + existing = client.category_get_by_slug(slug) + else: + existing = None + return slug, existing + + def _prepare_image(self, rec, extra_images=False): + """Prepare image bytes for supported records. + + - Category: field saleor_background_image + - Collection: field saleor_background_image + - Product: field saleor_image_ids + """ + img_bytes = None + filename = None + content_type = "application/octet-stream" + + # Category/Collection background image + if getattr(rec, "saleor_background_image", False): + img_bytes, filename, content_type = decode_image_field( + rec.saleor_background_image, base_filename="collection" + ) + return (img_bytes, filename, content_type) if not extra_images else [] + + # Product images + if rec._name == "product.template" and hasattr(rec, "saleor_image_ids"): + if not extra_images: + return None, None, None + + images = [] + for img in rec.saleor_image_ids.sorted("sequence"): + # Skip if image already has a Saleor ID + if img.saleor_image_id: + continue + + if img.image_1920: + img_data = decode_image_field( + img.image_1920, base_filename=f"product-{img.id}" + ) + if img_data[0]: # If image data is valid + images.append( + ( + img_data[0], + img_data[1], + img_data[2], + img.name or "", + img.sequence or 0, + img, + ) + ) + + if extra_images: + return images + + return None, None, None + + def _raise_if_updating_parent(self, odoo_cat, payload): + if payload.get("parent") and odoo_cat.saleor_category_id: + raise UserError( + self.env._( + "Saleor does not support updating a category's parent. " + "Please change the parent directly in Saleor" + " or create a new category." + ) + ) + + def _post_image_success(self, odoo_cat, slug, payload, saleor_id, img_bytes): + # Optionally post image upload note if image present + if saleor_id and img_bytes: + try: + odoo_cat.message_post( + body=format_note( + self.env, + "Uploaded category background image to Saleor (account %s)", + self.email, + ) + ) + except Exception as e: + _logger.warning( + "Failed to post background image upload message on category %s: %s", + odoo_cat.id, + e, + ) + try: + dash_url, obj_url = saleor_dashboard_links( + self.base_url, + "category", + id=saleor_id, + slug=slug or payload.get("slug") or payload.get("name"), + ) + body = format_kv_list( + "Synced to Saleor:", + [ + ("Account", self.email), + ("Slug", slug or payload.get("slug") or payload.get("name")), + ("Saleor ID", saleor_id), + ("Saleor", make_link("View in Saleor", obj_url)), + ], + ) + odoo_cat.message_post(body=body) + except Exception as e: + _logger.warning("Failed to post sync message on category: %s", e) + + def _sync_promotion_rules(self, client, program, saleor_promotion_id): + """Upsert rules for a promotion based on Odoo Discount Rules. + + - Create missing rules (by stored ID or matching name). + - Update existing rules' basic data (name, description). + Note: Conditions/rewards mapping can be added later when schema is finalized. + """ + rules = program.discount_rule_ids + if not rules: + _logger.debug( + "Saleor promotion sync: no discount rules found for program %s [%s]", + getattr(program, "name", ""), + getattr(program, "id", ""), + ) + return True + + # Fetch existing rules from Saleor to resolve names when ID missing + remote_rules = client.promotion_rules_list(saleor_promotion_id) or [] + remote_by_id = {r.get("id"): r for r in remote_rules if r.get("id")} + remote_by_name = {r.get("name"): r for r in remote_rules if r.get("name")} + + for rule in rules: + input_data = rule._saleor_prepare_rule_input() + saleor_rule_id = rule.saleor_promotion_rule_id + # Collect desired channels from rule + rule_channels = [] + try: + ch = getattr(rule, "channel_id", False) + if ch and getattr(ch, "saleor_channel_id", None): + rule_channels = [ch.saleor_channel_id] + except Exception: + rule_channels = [] + # Try by stored ID + if saleor_rule_id and saleor_rule_id in remote_by_id: + if rule_channels: + client.promotion_rule_update( + saleor_rule_id, input_data, add_channels=rule_channels + ) + else: + client.promotion_rule_update(saleor_rule_id, input_data) + continue + # Else try matching by name + if input_data.get("name") and input_data["name"] in remote_by_name: + found = remote_by_name[input_data["name"]] + fr_id = found.get("id") + if fr_id: + if rule_channels: + client.promotion_rule_update( + fr_id, input_data, add_channels=rule_channels + ) + else: + client.promotion_rule_update(fr_id, input_data) + if not saleor_rule_id: + rule.write({"saleor_promotion_rule_id": fr_id}) + continue + # No match -> create + created = client.promotion_rule_create( + saleor_promotion_id, input_data, channels=rule_channels or None + ) + cr_id = (created or {}).get("id") + if cr_id and not saleor_rule_id: + rule.write({"saleor_promotion_rule_id": cr_id}) + return True + + # Job: sync a product.category to this Saleor + # account (create if missing by slug else update) + def job_category_sync(self, category_id, payload): + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor category sync skipped for category %s on inactive account %s", + category_id, + self.name, + ) + return True + client = self._get_client() + odoo_cat = self.env["product.category"].browse(category_id) + # Ensure parent synced and inject parent id + self._sync_parent_and_set_payload_parent(odoo_cat, payload) + + # Ensure slug and check existing + slug, existing = self._ensure_slug_and_get_existing(client, odoo_cat, payload) + + # Prepare optional image payload + img_bytes, filename, content_type = self._prepare_image(odoo_cat) + + saleor_id = None + if existing and existing.get("id"): + self._raise_if_updating_parent(odoo_cat, payload) + if "parent" in payload: + payload = dict(payload) + payload.pop("parent", None) + self._refresh_token(client) + cat = client.category_update( + existing["id"], + payload, + filename=filename, + file_bytes=img_bytes, + content_type=content_type, + ) + saleor_id = (cat or {}).get("id") or existing.get("id") + else: + self._refresh_token(client) + cat = client.category_create( + payload, + filename=filename, + file_bytes=img_bytes, + content_type=content_type, + ) + saleor_id = (cat or {}).get("id") + # Persist and post messages + self._persist_saleor_id(odoo_cat, "category", saleor_id) + self._post_image_success(odoo_cat, slug, payload, saleor_id, img_bytes) + return True + + # --- Batch jobs --- + def job_category_sync_batch(self, items): + """Batch sync categories.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor category batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + cat_id = it.get("id") + payload = it.get("payload") + try: + self.job_category_sync(cat_id, payload) + except Exception as e: + rec = self.env["product.category"].browse(cat_id) + rec_name = rec.display_name if rec else f"category[{cat_id}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch category sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + raise UserError(header) + return True + + def _resync_template_variants(self, tmpl): + """Resync all variants of a product.template to Saleor.""" + self.ensure_one() + if not tmpl or not tmpl.exists() or not tmpl.saleor_product_id: + return True + + variants = tmpl.product_variant_ids + if not variants: + return True + + by_sku, by_saleor_id = self._variant_build_local_indexes(variants) + + client = self._get_client() + remote_variants = self._variant_fetch_remote(client, tmpl) + if remote_variants is False: + # Remote fetch failed; abort quietly + return False + + remote_ids, to_delete_ids = self._variant_compute_deletions( + remote_variants, by_sku, by_saleor_id + ) + + self._variant_remove_obsolete(client, tmpl, to_delete_ids) + self._variant_clear_stale_ids(variants, remote_ids, to_delete_ids) + + items = self._variant_build_sync_items(variants, tmpl.saleor_product_id) + if not items: + return True + + self._variant_enqueue_sync_batches(items, tmpl) + + # After variants resync, ensure product channel listings still match + # Odoo template channels. + self._variant_resync_product_channels(tmpl) + + return True + + def _variant_build_local_indexes(self, variants): + by_sku = { + v.default_code: v for v in variants if getattr(v, "default_code", False) + } + by_saleor_id = { + v.saleor_variant_id: v + for v in variants + if getattr(v, "saleor_variant_id", False) + } + return by_sku, by_saleor_id + + def _variant_fetch_remote(self, client, tmpl): + try: + self._refresh_token(client) + return ( + client.product_variants_list_by_product_id(tmpl.saleor_product_id) or [] + ) + except Exception as e: + _logger.warning( + "Failed to fetch remote variants for product %s: %s", + tmpl.display_name, + e, + ) + return False + + def _variant_compute_deletions(self, remote_variants, by_sku, by_saleor_id): + remote_ids = set() + to_delete_ids = [] + for rv in remote_variants: + rv = rv or {} + rid = rv.get("id") + sku = rv.get("sku") + if rid: + remote_ids.add(rid) + if rid and (rid not in by_saleor_id) and (not sku or sku not in by_sku): + to_delete_ids.append(rid) + return remote_ids, to_delete_ids + + def _variant_remove_obsolete(self, client, tmpl, to_delete_ids): + if not to_delete_ids: + return + try: + self._refresh_token(client) + client.product_variant_bulk_delete(to_delete_ids) + except Exception as e: + _logger.warning( + "Failed to bulk delete obsolete variants for product %s: %s", + tmpl.display_name, + e, + ) + + def _variant_clear_stale_ids(self, variants, remote_ids, to_delete_ids): + valid_remote_ids = remote_ids.difference(to_delete_ids) + for v in variants: + sid = getattr(v, "saleor_variant_id", False) + if sid and sid not in valid_remote_ids: + try: + v.write({"saleor_variant_id": False}) + except Exception as e: + _logger.warning( + "Failed to clear stale Saleor Variant ID on %s: %s", + v.display_name, + e, + ) + + def _variant_build_sync_items(self, variants, saleor_product_id): + items = [] + for v in variants: + try: + payload = v._saleor_prepare_variant_payload(saleor_product_id) + except Exception as e: + _logger.warning( + "Failed to build payload for variant %s: %s", + v.display_name, + e, + ) + continue + items.append( + { + "variant_id": v.id, + "product_saleor_id": saleor_product_id, + "payload": payload, + } + ) + return items + + def _variant_enqueue_sync_batches(self, items, tmpl): + batch_size = getattr(self, "job_batch_size", 10) or 10 + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + try: + if hasattr(self, "with_delay"): + self.with_delay().job_product_variant_sync_batch(chunk) + else: + self.job_product_variant_sync_batch(chunk) + except Exception as e: + _logger.warning( + "Failed to enqueue variant batch sync for product %s: %s", + tmpl.display_name, + e, + ) + + def _variant_resync_product_channels(self, tmpl): + # After variants resync, ensure product channel listings still match + # Odoo template channels. + try: + client = self._get_client() + self._refresh_token(client) + self._sync_product_channel_listings(client, tmpl, tmpl.saleor_product_id) + except Exception as e: + _logger.warning( + "Failed to resync product channel listings for %s: %s", + tmpl.display_name, + e, + ) + + def job_product_variants_resync(self, product_tmpl_id): + """Public job to resync all variants for a given product.template. + + This is typically invoked when attribute_line_ids change, to ensure + Saleor variants match the current Odoo matrix. + """ + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor variants resync skipped for template %s on inactive account %s", + product_tmpl_id, + self.name, + ) + return True + + tmpl = self.env["product.template"].browse(product_tmpl_id) + if not tmpl or not tmpl.exists() or not tmpl.saleor_product_id: + return True + + return self._resync_template_variants(tmpl) + + def job_warehouse_sync(self, warehouse_id, payload): + """Sync a single stock.warehouse to Saleor Warehouse. + Name rule is prepared by warehouse: name + (short_name). + """ + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor warehouse sync skipped for warehouse %s on inactive account %s", + warehouse_id, + self.name, + ) + return True + + wh = self.env["stock.warehouse"].browse(warehouse_id) + if not wh: + _logger.debug( + "Saleor warehouse sync skipped: warehouse %s not found on account %s", + warehouse_id, + self.name, + ) + return True + + client = self._get_client() + try: + saleor_id = wh.saleor_warehouse_id + # Map Odoo selection to Saleor boolean + is_private_val = getattr(wh, "is_private", False) + is_private_bool = True if is_private_val in ("private", True) else False + if saleor_id: + # Verify the remote warehouse exists; if not, re-create + self._refresh_token(client) + existing = client.warehouse_get_by_id(saleor_id) + if not existing: + saleor_id = None + if saleor_id: + self._refresh_token(client) + update_payload = dict(payload or {}) + update_payload["isPrivate"] = is_private_bool + res = client.warehouse_update(saleor_id, update_payload) + saleor_id = (res or {}).get("id") or saleor_id + else: + self._refresh_token(client) + res = client.warehouse_create(payload) + saleor_id = (res or {}).get("id") + + # After create, immediately update isPrivate (not accepted on create) + if saleor_id is not None: + try: + self._refresh_token(client) + client.warehouse_update( + saleor_id, + {"isPrivate": is_private_bool}, + ) + except Exception as e2: + _logger.warning( + "Failed to set isPrivate on newly" + " created Saleor warehouse %s: %s", + saleor_id, + e2, + ) + + if saleor_id and wh.saleor_warehouse_id != saleor_id: + try: + wh.write({"saleor_warehouse_id": saleor_id}) + except Exception as e: + _logger.warning( + "Failed to persist Saleor Warehouse ID on warehouse %s: %s", + wh.id, + e, + ) + return bool(saleor_id) + except Exception as e: + _logger.exception( + "Error syncing warehouse '%s' to Saleor via account %s: %s", + wh.display_name, + self.name, + e, + ) + emsg = str(e) + if "postalCode" in emsg and "INVALID" in emsg: + raise UserError( + self.env._( + "Address validation failed: " + "The postal code is invalid for the selected country.\n" + "Please verify the ZIP/Postal Code format for your country" + " and update the partner's address." + ) + ) from e + if "countryArea" in emsg and "INVALID" in emsg: + raise UserError( + self.env._( + "Address validation failed: " + "The state/region (country area) is invalid" + " for the selected country.\n" + "Please ensure it matches a valid subdivision name/code" + " for your country and update the partner's address." + ) + ) from e + raise UserError(self.env._("Saleor warehouse sync failed: %s", emsg)) from e + + def job_warehouse_sync_batch(self, items): + """Batch sync warehouses.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor warehouse batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + wid = it.get("id") + payload = it.get("payload") + try: + self.job_warehouse_sync(wid, payload) + except Exception as e: + rec = self.env["stock.warehouse"].browse(wid) + rec_name = rec.display_name if rec else f"warehouse[{wid}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch warehouse sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + def job_location_sync(self, location_id, payload): + """Sync a single stock.location to Saleor Warehouse. + Name rule is complete_name of location. + """ + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor location sync skipped for location %s" + " on inactive account %s", + location_id, + self.name, + ) + return True + + loc = self.env["stock.location"].browse(location_id) + if not loc: + return True + + client = self._get_client() + try: + saleor_id = loc.saleor_warehouse_id + if saleor_id: + # Verify the remote warehouse exists; if not, re-create + self._refresh_token(client) + existing = client.warehouse_get_by_id(saleor_id) + if not existing: + saleor_id = None + if saleor_id: + self._refresh_token(client) + res = client.warehouse_update(saleor_id, payload) + saleor_id = (res or {}).get("id") or saleor_id + else: + self._refresh_token(client) + res = client.warehouse_create(payload) + saleor_id = (res or {}).get("id") + + if saleor_id and loc.saleor_warehouse_id != saleor_id: + try: + loc.write({"saleor_warehouse_id": saleor_id}) + except Exception as e: + _logger.warning( + "Failed to persist Saleor Warehouse ID on location %s: %s", + loc.id, + e, + ) + return bool(saleor_id) + except Exception as e: + _logger.exception( + "Error syncing location '%s' to Saleor via account %s: %s", + loc.display_name, + self.name, + e, + ) + # Raise a friendly error for the user instead of posting to chatter + emsg = str(e) + if "postalCode" in emsg and "INVALID" in emsg: + raise UserError( + self.env._( + "Address validation failed:" + " The postal code is invalid for the selected country.\n" + "Please verify the ZIP/Postal Code format for your country" + " and update the partner's address." + ) + ) from e + if "countryArea" in emsg and "INVALID" in emsg: + raise UserError( + self.env._( + "Address validation failed:" + " The state/region (country area) is invalid for" + " the selected country.\n" + "Please ensure it matches a valid subdivision name/code" + " for your country and update the partner's address." + ) + ) from e + raise UserError(self.env._("Saleor location sync failed: %s", emsg)) from e + + def job_location_sync_batch(self, items): + """Batch sync locations.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor location batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + lid = it.get("id") + payload = it.get("payload") + try: + self.job_location_sync(lid, payload) + except Exception as e: + rec = self.env["stock.location"].browse(lid) + rec_name = rec.display_name if rec else f"location[{lid}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch location sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + raise UserError(header) + return True + + def job_product_variant_sync(self, variant_id, product_saleor_id, payload): + """Sync a single product.product variant to Saleor.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor variant sync skipped for variant %s" + " (product %s) on inactive account %s", + variant_id, + product_saleor_id, + self.name, + ) + return True + + variant = self.env["product.product"].browse(variant_id) + if not variant: + return True + + client = self._get_client() + self._refresh_token(client) + + try: + # Upsert variant (update or create) + saleor_id = self._variant_upsert( + client, variant, product_saleor_id, payload + ) + + # Build and push channel listings if available + if saleor_id: + try: + update_channels = self._variant_build_channel_listings( + variant, payload + ) + self._variant_push_channel_listings( + client, saleor_id, update_channels, variant + ) + except Exception as e: + _logger.warning( + "Failed to update variant channel listings for %s: %s", + variant.default_code or variant.id, + e, + ) + + # Ensure initial stock rows exist in Saleor for enabled sources + try: + # Collect Saleor-enabled warehouses and locations + Warehouses = ( + self.env["stock.warehouse"] + .sudo() + .search( + [ + ("is_saleor_warehouse", "=", True), + ("include_in_saleor_inventory", "=", True), + ("saleor_warehouse_id", "!=", False), + ] + ) + ) + Locations = ( + self.env["stock.location"] + .sudo() + .search( + [ + ("is_saleor_warehouse", "=", True), + ("include_in_saleor_inventory", "=", True), + ("saleor_warehouse_id", "!=", False), + ] + ) + ) + # De-duplicate by remote warehouse id + saleor_wh_ids = set() + for wh in Warehouses: + if wh.saleor_warehouse_id: + saleor_wh_ids.add(wh.saleor_warehouse_id) + for loc in Locations: + if loc.saleor_warehouse_id: + saleor_wh_ids.add(loc.saleor_warehouse_id) + + if saleor_wh_ids: + for remote_wh_id in saleor_wh_ids: + try: + # Create stock entry with zero quantity idempotently + client.product_variant_stocks_create( + variant_id=saleor_id, + warehouse_id=remote_wh_id, + quantity=0, + ) + except Exception as se: + _logger.warning( + "Failed to ensure initial stock for variant %s" + " in Saleor warehouse %s: %s", + saleor_id, + remote_wh_id, + se, + ) + except Exception as e: + _logger.debug( + "Skipping initial stock creation for variant %s due to: %s", + saleor_id, + e, + ) + + # Post chatter success + try: + slug = ( + (payload or {}).get("sku") + or variant.default_code + or str(variant.id) + ) + self._post_success( + rec=variant, + object_type="product_variant", + slug=slug, + payload=payload or {}, + saleor_id=saleor_id, + ) + except Exception as e: + _logger.warning("Failed to post variant sync message: %s", e) + + return bool(saleor_id) + + except Exception as e: + _logger.exception( + "Error syncing variant '%s' to Saleor via account %s: %s", + variant.display_name, + self.name, + e, + ) + raise UserError(self.env._("Saleor variant sync failed: %s", str(e))) from e + + def job_product_variant_sync_batch(self, items): + """Batch sync product variants.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor variant batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + vid = it.get("variant_id") + pid = it.get("product_saleor_id") + payload = it.get("payload") + try: + self.job_product_variant_sync(vid, pid, payload) + except Exception as e: + rec = self.env["product.product"].browse(vid) + rec_name = rec.display_name if rec else f"variant[{vid}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch product variant sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + raise UserError(header) + return True + + def _variant_allowed_update_payload(self, payload): + allowed = {"name", "sku", "trackInventory", "weight"} + return {k: v for k, v in (payload or {}).items() if k in allowed} + + def _variant_upsert(self, client, variant, product_saleor_id, payload): + """Update existing variant or create a new one, return Saleor ID.""" + saleor_id = None + if variant.saleor_variant_id: + existing = client.product_variant_get_by_id(variant.saleor_variant_id) + if existing and existing.get("id"): + input_payload = self._variant_allowed_update_payload(payload) + res = client.product_variant_update( + variant.saleor_variant_id, input_payload + ) + saleor_id = (res or {}).get("id") or variant.saleor_variant_id + + if not saleor_id: + # Ensure SKU is never None/False; allow empty string if not set. + sku_val = (payload or {}).get("sku") + if not sku_val: + sku_val = getattr(variant, "default_code", "") or "" + res = client.product_variant_create( + product_id=product_saleor_id, + sku=sku_val, + name=(payload or {}).get("name") or variant.name, + attributes=(payload or {}).get("attributes") or [], + weight=(payload or {}).get("weight"), + ) + saleor_id = (res or {}).get("id") + if saleor_id and variant.saleor_variant_id != saleor_id: + try: + variant.write({"saleor_variant_id": saleor_id}) + except Exception as e: + _logger.warning( + "Failed to persist Saleor Variant ID on variant %s: %s", + variant.id, + e, + ) + return saleor_id + + def _variant_build_channel_listings(self, variant, payload): + """Build channel listing payloads using provided channelListings""" + listings = (payload or {}).get("channelListings") or [] + update_channels = [] + if listings: + for it in listings: + ch_id = (it or {}).get("channelId") + entry = {"channelId": ch_id} + price_val = (it or {}).get("price") + if price_val is not None: + try: + if isinstance(price_val, dict): + price_val = price_val.get("amount") + entry["price"] = str(float(price_val)) + except Exception as e: + _logger.debug( + "Saleor: skip price conversion for variant %s: %s", + variant.display_name, + e, + ) + try: + if variant.standard_price is not None: + entry["costPrice"] = str(float(variant.standard_price)) + except Exception as e: + _logger.debug( + "Saleor: skip costPrice conversion for variant %s: %s", + variant.display_name, + e, + ) + if ch_id: + update_channels.append(entry) + return update_channels + + # Fallback to addChannels + add_channels = (payload or {}).get("addChannels") or [] + if add_channels: + channels = self.env["saleor.channel"].search( + [("saleor_channel_id", "in", add_channels)] + ) + by_id = {c.saleor_channel_id: c for c in channels} + for ch_id in add_channels: + ch = by_id.get(ch_id) + if not ch: + continue + entry = { + "channelId": ch_id, + "price": str(float(variant.lst_price or 0.0)), + } + try: + if variant.standard_price is not None: + entry["costPrice"] = str(float(variant.standard_price)) + except Exception as e: + _logger.debug( + "Saleor: skip costPrice conversion for variant %s: %s", + variant.display_name, + e, + ) + update_channels.append(entry) + return update_channels + + def _variant_push_channel_listings( + self, client, saleor_id, update_channels, variant + ): + if update_channels: + self._refresh_token(client) + client.product_variant_channel_listing_update(saleor_id, update_channels) + + def job_variant_stock_update(self, variant_id, warehouse_id, quantity): + """Update stock for a product variant to Saleor.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor stock update skipped for variant %s," + " warehouse %s on inactive account %s", + variant_id, + warehouse_id, + self.name, + ) + return True + + client = self._get_client() + self._refresh_token(client) + try: + res = client.product_variant_stocks_update( + variant_id=variant_id, + warehouse_id=warehouse_id, + quantity=quantity, + ) + _logger.info( + "Updated Saleor stock: variant=%s, warehouse=%s, qty=%s", + variant_id, + warehouse_id, + quantity, + ) + return res + except Exception as e: + _logger.exception( + "Error updating Saleor stock for variant %s via account %s: %s", + variant_id, + self.name, + e, + ) + raise UserError(self.env._("Saleor stock update failed: %s", str(e))) from e + + def job_order_sync(self, order_id, payload): + """Create a draft order in Saleor and add lines. + + payload is expected to contain keys: channelId, userId (optional), userEmail, + billingAddress, shippingAddress, lines (list of {variantId, quantity}). + """ + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor order sync skipped for order %s on inactive account %s", + order_id, + self.name, + ) + return True + + order = self.env["sale.order"].browse(order_id) + if not order: + return True + + client = self._get_client() + try: + base_input = { + "channelId": payload.get("channelId"), + "user": payload.get("user"), + "userEmail": payload.get("userEmail"), + "billingAddress": payload.get("billingAddress"), + "shippingAddress": payload.get("shippingAddress"), + } + base_input = {k: v for k, v in base_input.items() if v} + # Mark order as originating from Odoo to avoid webhook loop + marker = [{"key": "odoo_origin", "value": "true"}] + base_input["privateMetadata"] = marker + base_input["metadata"] = marker + lines = payload.get("lines") or [] + + self._refresh_token(client) + channel_id = base_input.get("channelId") or payload.get("channelId") + if channel_id and lines: + unavail = self._preflight_check_variants(client, channel_id, lines) + if unavail: + msg = ", ".join(sorted(set(unavail))) + raise UserError( + self.env._( + "Cannot sync order: variants unavailable in channel: %s", + msg, + ) + ) + + shipping_addr = base_input.get("shippingAddress") or payload.get( + "shippingAddress" + ) + self._preflight_check_country(client, channel_id, shipping_addr) + + saleor_order_id = self._draft_order_create_or_update( + client, order, base_input, lines + ) + + self._apply_shipping_method_to_order(client, order, saleor_order_id) + + if order.state == "sale": + self._complete_draft_order(client, saleor_order_id) + + if saleor_order_id and order.saleor_order_id != saleor_order_id: + try: + order.write({"saleor_order_id": saleor_order_id}) + except Exception as e: + _logger.warning( + "Failed to persist Saleor Order ID" " on sale.order %s: %s", + order.id, + e, + ) + + try: + dash_url, obj_url = saleor_dashboard_links( + self.base_url, "order", id=saleor_order_id, number=None + ) + order.message_post( + body=format_kv_list( + "Synced to Saleor:", + [ + ("Account", self.email or self.name), + ("Saleor Order ID", saleor_order_id), + ("Saleor", make_link("View in Saleor", obj_url)), + ], + ) + ) + except Exception as e: + _logger.warning("Failed to post order sync message: %s", e) + + return bool(saleor_order_id) + except Exception as e: + _logger.exception( + "Error syncing sale.order '%s' to Saleor via account %s: %s", + order.display_name, + self.name, + e, + ) + raise UserError(self.env._("Saleor order sync failed: %s", str(e))) from e + + def job_order_sync_batch(self, items): + """Batch sync sale orders.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor order batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + oid = it.get("id") + payload = it.get("payload") + try: + self.job_order_sync(oid, payload) + except Exception as e: + rec = self.env["sale.order"].browse(oid) + rec_name = rec.display_name if rec else f"order[{oid}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._("Batch order sync failed for %s item(s):", len(errors)) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + def _preflight_check_variants(self, client, channel_id, lines): + """ + Return list of display names or IDs of variants not available in the channel. + """ + missing = [] + + # Collect unique variant IDs from lines + variant_ids = self._collect_variant_ids_from_lines(lines) + + if not variant_ids: + return missing + + try: + variant_map = self._fetch_variant_map(client, variant_ids) + except Exception: + # In case of a total failure, fall back to marking all as missing + missing.extend(variant_ids) + return missing + + # Evaluate availability per line using the batched data + for ln in lines or []: + vid = ln.get("variantId") + if not vid: + continue + v = variant_map.get(vid) or {} + if not v: + # If variant not returned by API, treat as missing + missing.append(vid) + continue + + label = self._compute_variant_missing_label(v, channel_id, vid) + if label: + missing.append(label) + return missing + + def _collect_variant_ids_from_lines(self, lines): + variant_ids = [] + seen = set() + for ln in lines or []: + vid = ln.get("variantId") + if not vid: + continue + if vid not in seen: + seen.add(vid) + variant_ids.append(vid) + return variant_ids + + def _fetch_variant_map(self, client, variant_ids): + query = """ + query ProductVariants($ids: [ID!], $first: Int!) { + productVariants(first: $first, ids: $ids) { + edges { + node { + id + name + product { + id + name + channelListings { + channel { id } + isPublished + } + } + stocks { + warehouse { id name } + quantity + } + } + } + } + } + """ + + variant_map = {} + self._refresh_token(client) + data = ( + client.graphql( + query, + {"ids": variant_ids, "first": len(variant_ids)}, + ) + or {} + ) + edges = ((data.get("productVariants") or {}).get("edges")) or [] + for edge in edges: + node = (edge or {}).get("node") or {} + vid = node.get("id") + if vid: + variant_map[vid] = node + return variant_map + + def _compute_variant_missing_label(self, variant_node, channel_id, default_label): + prod = variant_node.get("product") or {} + pid = prod.get("id") + + # Channel listing check taken from product.channelListings + listed = False + published = False + if pid: + cls = prod.get("channelListings") or [] + for cl in cls: + ch = cl.get("channel") or {} + if ch.get("id") == channel_id: + listed = True + published = bool(cl.get("isPublished")) + break + + # Basic stock check + stocks = variant_node.get("stocks") or [] + try: + total_qty = sum(int(s.get("quantity") or 0) for s in stocks) + except Exception: + total_qty = 0 + + if not listed and not published and total_qty <= 0: + # No signal of availability; fall back to default label without reasons + return default_label + + if not listed or not published or total_qty <= 0: + name = ( + variant_node.get("name") + or (prod.get("name") if prod else None) + or default_label + ) + reasons = [] + if not listed: + reasons.append("not listed") + if listed and not published: + reasons.append("unpublished") + if total_qty <= 0: + reasons.append("no stock") + return f"{name} ({', '.join(reasons)})" + + return None + + def _preflight_check_country(self, client, channel_id, shipping_addr): + country_code = (shipping_addr or {}).get("country") + if channel_id and country_code: + try: + query = """ + query ShippingZones($first: Int!) { + shippingZones(first: $first) { + edges { + node { + countries { code } + channels { id } + } + } + } + } + """ + zones_data = client.graphql(query, {"first": 100}) or {} + edges = ((zones_data.get("shippingZones") or {}).get("edges")) or [] + allowed = set() + for edge in edges: + node = edge.get("node") or {} + chans = node.get("channels") or [] + if any((c or {}).get("id") == channel_id for c in chans): + for c in node.get("countries") or []: + code = (c or {}).get("code") + if code: + allowed.add(code) + _logger.debug( + "Saleor preflight: channel %s" + " allows countries: %s (checking %s)", + channel_id, + ",".join(sorted(allowed)) or "", + country_code, + ) + if allowed and country_code not in allowed: + raise UserError( + self.env._( + "Shipping country %s is not available for channel." + " Please check Saleor Shipping Zones" + " assigned to the channel.", + country_code, + ) + ) + except UserError: + raise + except Exception as e: + _logger.debug( + "Preflight shipping country check skipped due to error: %s", e + ) + + def _draft_order_create_or_update(self, client, order, base_input, lines): + saleor_order_id = order.saleor_order_id + existing = None + if saleor_order_id: + self._refresh_token(client) + try: + existing = client.order_get_by_id(saleor_order_id) + except Exception: + existing = None + if not existing: + saleor_order_id = None + + if saleor_order_id and existing and (existing.get("status") == "DRAFT"): + self._refresh_token(client) + update_input = dict(base_input) + if "channelId" in update_input: + update_input.pop("channelId") + client.draft_order_update(saleor_order_id, update_input) + try: + for ln in existing.get("lines") or []: + lid = (ln or {}).get("id") + if lid: + self._refresh_token(client) + client.order_line_delete(lid) + except Exception as e: + _logger.debug("Saleor: failed deleting some lines before re-add: %s", e) + if lines: + self._refresh_token(client) + client.draft_order_lines_create(saleor_order_id, lines) + return saleor_order_id + + if saleor_order_id and existing: + return saleor_order_id + + self._refresh_token(client) + created = client.draft_order_create(base_input) + saleor_order_id = (created or {}).get("id") + if saleor_order_id and lines: + self._refresh_token(client) + client.draft_order_lines_create(saleor_order_id, lines) + return saleor_order_id + + def _apply_shipping_method_to_order(self, client, order, saleor_order_id): + try: + carrier = getattr(order, "saleor_delivery_carrier_id", False) + if saleor_order_id and carrier: + self._refresh_token(client) + available = ( + client.order_available_shipping_methods(saleor_order_id) or [] + ) + target_id = None + carrier_name = getattr(carrier, "name", None) + for m in available: + if (m or {}).get("name") == carrier_name: + target_id = m.get("id") + break + if not target_id and len(available) == 1: + target_id = (available[0] or {}).get("id") + if target_id: + client.order_update_shipping(saleor_order_id, target_id) + else: + avail_names = ", ".join( + [(m or {}).get("name") or "" for m in available] + ) + raise Exception( + "No matching ShippingMethod found for carrier" + " '{carrier_name}'. Available: {avail_names}" + ) + except Exception as e: + _logger.warning( + "Failed to set delivery method for Saleor order" + " %s from carrier %s: %s", + saleor_order_id, + getattr(carrier, "name", carrier) if "carrier" in locals() else None, + e, + ) + + def _complete_draft_order(self, client, saleor_order_id): + try: + if saleor_order_id: + self._refresh_token(client) + client.draft_order_complete(saleor_order_id) + except Exception as e: + _logger.warning( + "Failed to complete Saleor draft order %s: %s", + saleor_order_id, + e, + ) + + # --- Collections --- + # Job: sync a product.collection to this Saleor account + # (create if missing by slug else update) + def job_collection_sync(self, collection_id, payload): + return self.job_saleor_sync("collection", collection_id, payload) + + def job_collection_sync_batch(self, items): + """Batch sync product collections.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor collection batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + col_id = it.get("id") + payload = it.get("payload") + try: + self.job_collection_sync(col_id, payload) + except Exception as e: + rec = self.env["product.collection"].browse(col_id) + rec_name = rec.display_name if rec else f"collection[{col_id}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch collection sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + # --- Unified post-success --- + def _post_success( + self, + rec, + object_type, + slug, + payload, + saleor_id, + img_uploaded=False, + img_bytes=None, + ): + """Post a unified success message and optional image upload note. + + object_type: 'collection' | 'product' | 'product_variant' + For collections, pass img_bytes; for products, pass img_uploaded. + """ + try: + if object_type == "product_variant": + try: + parent_pid = getattr( + getattr(rec, "product_tmpl_id", None), "saleor_product_id", None + ) + except Exception: + parent_pid = None + dash_url, obj_url = saleor_dashboard_links( + self.base_url, + "product_variant", + id=saleor_id, + product_id=parent_pid, + ) + body = format_kv_list( + "Synced variant to Saleor:", + [ + ("Account", self.email), + ( + "SKU", + slug + or payload.get("sku") + or payload.get("name") + or str(rec.id), + ), + ("Variant ID", saleor_id), + ("Saleor", make_link("Open in Saleor", obj_url)), + ], + ) + rec.message_post(body=body) + else: + # Determine kind for deep link + kind_map = { + "collection": "collection", + "product": "product", + "product_variant": "product", + } + kind = kind_map.get(object_type) + dash_url, obj_url = saleor_dashboard_links( + self.base_url, + kind or object_type, + id=saleor_id, + slug=slug or payload.get("slug") or payload.get("name"), + ) + slug_val = slug or payload.get("slug") or payload.get("name") + storefront_url = None + if kind == "product" and slug_val: + base = "https://storefront-gcr.staging-kencove.com".rstrip("/") + storefront_url = f"{base}/products/detail/{slug_val}" + # For products, show explicit Storefront/Saleor links. + if kind == "product": + body = format_kv_list( + "Synced to Saleor:", + [ + ("Account", self.email), + ("Slug", slug_val), + ("Saleor ID", saleor_id), + ( + "Storefront", + make_link( + "View in Storefront", + storefront_url or dash_url, + ), + ), + ( + "Saleor", + make_link("View in Saleor", obj_url), + ), + ], + ) + else: + body = format_kv_list( + "Synced to Saleor:", + [ + ("Account", self.email), + ("Slug", slug_val), + ("Saleor ID", saleor_id), + ("Saleor", make_link("Open in Saleor", obj_url)), + ], + ) + rec.message_post(body=body) + except Exception as e: + _logger.warning("Failed to post sync message on %s: %s", object_type, e) + # Image notes + try: + if object_type == "collection" and saleor_id and img_bytes: + rec.message_post( + body=format_note( + self.env, + "Uploaded collection background image to Saleor (account %s)", + self.email, + ) + ) + if object_type == "product" and img_uploaded: + rec.message_post( + body=format_note( + self.env, + "Uploaded product image to Saleor (account %s)", + self.email, + ) + ) + except Exception as e: + _logger.warning("Failed to post image note on %s: %s", object_type, e) + + def job_product_sync(self, product_tmpl_id, payload): + return self.job_saleor_sync("product", product_tmpl_id, payload) + + def job_product_sync_batch(self, items): + """Batch sync product templates.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor product batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + pt_id = it.get("id") + payload = it.get("payload") + try: + self.job_product_sync(pt_id, payload) + except Exception as e: + rec = self.env["product.template"].browse(pt_id) + rec_name = rec.display_name if rec else f"product[{pt_id}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch product sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + def job_voucher_sync(self, voucher_id, payload): + """Sync a single saleor.voucher to Saleor (create/update), + update channel listings, and add codes. + + Resolution: + - If Odoo has saleor_voucher_id, try by ID. If missing, fall back to name. + - If found by name, persist ID; otherwise create new. + Posts a chatter message and returns True on success. + """ + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor voucher sync skipped for voucher %s" " on inactive account %s", + voucher_id, + self.name, + ) + return True + + rec = self.env["saleor.voucher"].browse(voucher_id) + if not rec: + return True + + client = self._get_client() + + base_input = dict(payload or {}) + channel_listings = base_input.pop("channelListings", []) + codes = base_input.pop("codes", []) + metadata = base_input.pop("metadata", []) + private_metadata = base_input.pop("privateMetadata", []) + + saleor_id = rec.saleor_voucher_id + try: + saleor_id, existing = self._voucher_resolve_and_upsert( + client, rec, base_input, codes + ) + # Metadata + self._voucher_sync_metadata(client, saleor_id, metadata, private_metadata) + + # Channels + self._voucher_update_channels(client, saleor_id, channel_listings) + + # Catalogues + self._voucher_attach_catalogues(client, saleor_id, rec) + + # Codes + self._voucher_add_codes(client, saleor_id, codes, existing) + + try: + self._post_success( + rec=rec, + object_type="voucher", + slug=rec.name, + payload=payload or {}, + saleor_id=saleor_id, + ) + except Exception as e: + _logger.warning("Failed to post voucher sync message: %s", e) + + return bool(saleor_id) + + except Exception as e: + _logger.exception( + "Error syncing voucher '%s' to Saleor via account %s: %s", + rec.display_name, + self.name, + e, + ) + raise UserError(self.env._("Saleor voucher sync failed: %s", str(e))) from e + + def job_voucher_sync_batch(self, items): + """Batch sync vouchers.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor voucher batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + vid = it.get("id") + payload = it.get("payload") + try: + self.job_voucher_sync(vid, payload) + except Exception as e: + rec = self.env["saleor.voucher"].browse(vid) + rec_name = rec.display_name if rec else f"voucher[{vid}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch voucher sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + def job_giftcard_activate(self, giftcard_id, input_payload): + """Upsert (create/update) a gift card in Saleor, then sync metadata. + + Expects input_payload per GiftCardCreateInput/GiftCardUpdateInput. + On success writes: + - status = active + - saleor_giftcard_id, saleor_giftcard_code, code, name + - posts chatter message + And updates public/private metadata from line models if any. + """ + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor gift card activate job skipped for giftcard %s" + " on inactive account %s", + giftcard_id, + self.name, + ) + return True + + rec = self.env["saleor.giftcard"].browse(giftcard_id) + if not rec: + return True + + client = self._get_client() + try: + self._refresh_token(client) + # Create or Update depending on existing Saleor ID + if rec.saleor_giftcard_id: + # GiftCardUpdateInput does not accept some create-only fields + blocked = {"balance", "channel", "isActive", "note"} + update_payload = { + k: v for k, v in (input_payload or {}).items() if k not in blocked + } + remote = ( + client.gift_card_update(rec.saleor_giftcard_id, update_payload) + or {} + ) + else: + remote = client.gift_card_create(input_payload or {}) or {} + remote_id = remote.get("id") + saleor_code = remote.get("code") + saleor_display = remote.get("displayCode") + + vals = { + "status": "active", + "saleor_giftcard_id": remote_id, + } + if saleor_code or saleor_display: + if saleor_code: + vals["code"] = saleor_code + if saleor_display: + vals["name"] = saleor_display + if vals: + try: + rec.write(vals) + except Exception as e: + _logger.warning( + "Failed to persist Saleor GiftCard fields on %s: %s", rec.id, e + ) + + # Sync metadata and private metadata if provided via lines + try: + meta_lines = rec.saleor_metadata_line_ids + if meta_lines and remote_id: + metadata = [ + {"key": line.key, "value": line.value} for line in meta_lines + ] + self._refresh_token(client) + client.gift_card_metadata_update(remote_id, metadata) + except Exception as e: + _logger.warning("Failed to update gift card metadata: %s", e) + try: + pmeta_lines = rec.saleor_private_metadata_line_ids + if pmeta_lines and remote_id: + pmetadata = [ + {"key": line.key, "value": line.value} for line in pmeta_lines + ] + self._refresh_token(client) + client.gift_card_private_metadata_update(remote_id, pmetadata) + except Exception as e: + _logger.warning("Failed to update gift card private metadata: %s", e) + + try: + # Use the latest values after write for accuracy + name_val = rec.name or saleor_display or "-" + code_val = rec.code or saleor_code or "-" + dash_url, obj_url = saleor_dashboard_links( + self.base_url, + "gift_card", + id=remote_id, + ) + rec.message_post( + body=format_kv_list( + "Synced Gift Card:", + [ + ("Account", self.email or self.name), + ("Name", name_val), + ("Code", code_val), + ("Saleor ID", remote_id or "-"), + ("Saleor", make_link("View in Saleor", obj_url)), + ], + ) + ) + except Exception as e: + _logger.warning("Failed to post gift card sync message: %s", e) + + return bool(remote_id) + except Exception as e: + _logger.exception( + "Error activating gift card '%s' via account %s: %s", + rec.display_name, + self.name, + e, + ) + raise UserError( + self.env._("Saleor gift card activation failed: %s", str(e)) + ) from e + + def _voucher_sync_metadata(self, client, saleor_id, metadata, private_metadata): + """Update public and private metadata if provided.""" + try: + if metadata: + self._refresh_token(client) + client.voucher_metadata_update(saleor_id, metadata) + except Exception as e: + _logger.warning("Failed to update voucher metadata: %s", e) + try: + if private_metadata: + self._refresh_token(client) + client.voucher_private_metadata_update(saleor_id, private_metadata) + except Exception as e: + _logger.warning("Failed to update voucher private metadata: %s", e) + + def _voucher_resolve_and_upsert(self, client, rec, base_input, codes): + """Find or create the voucher in Saleor and persist its ID on the record. + + Returns a tuple (saleor_id, existing) where existing is the remote voucher + data when found by ID, else None. + """ + saleor_id = rec.saleor_voucher_id + existing = None + if saleor_id: + self._refresh_token(client) + existing = client.voucher_get_by_id(saleor_id) + if not existing: + saleor_id = None + if not saleor_id: + # Try match by exact name + self._refresh_token(client) + found = client.vouchers_search_by_name(rec.name) + if found and found.get("id"): + saleor_id = found.get("id") + + if saleor_id: + self._refresh_token(client) + res = client.voucher_update(saleor_id, base_input) + saleor_id = (res or {}).get("id") or saleor_id + else: + self._refresh_token(client) + primary_code = codes[0] if codes else None + create_input = dict(base_input) + if primary_code: + create_input["code"] = primary_code + created = client.voucher_create(create_input) + saleor_id = (created or {}).get("id") + + if saleor_id and rec.saleor_voucher_id != saleor_id: + try: + rec.write({"saleor_voucher_id": saleor_id}) + except Exception as e: + _logger.warning( + "Failed to persist Saleor Voucher ID on %s: %s", rec.id, e + ) + + return saleor_id, existing + + def _voucher_update_channels(self, client, saleor_id, channel_listings): + """Add and remove channel listings to match Odoo selection.""" + try: + add_channels = [] + desired_ids = set() + for ch in channel_listings or []: + sid = ch.get("channelId") + if sid: + desired_ids.add(sid) + item = {"channelId": sid} + if ("discountValue" in ch) and ( + ch.get("discountValue") is not None + ): + item["discountValue"] = float(ch.get("discountValue") or 0.0) + if ("amount" in ch) and (ch.get("amount") is not None): + item["minAmountSpent"] = float(ch.get("amount") or 0.0) + add_channels.append(item) + + self._refresh_token(client) + current_ids = set(client.voucher_channel_listings_get(saleor_id) or []) + remove_ids = list(current_ids - desired_ids) + + if add_channels or remove_ids: + self._refresh_token(client) + client.voucher_channel_listing_update( + saleor_id, + add_channels=add_channels or [], + remove_channels=remove_ids or [], + ) + except Exception as e: + _logger.warning( + "Failed to update voucher channel listings (add/remove): %s", e + ) + + def _voucher_attach_catalogues(self, client, saleor_id, rec): + """Attach product/category/collection/variant restrictions if any.""" + try: + products = [ + pt.saleor_product_id + for pt in rec.product_template_ids + if getattr(pt, "saleor_product_id", False) + ] + variants = [ + pv.saleor_variant_id + for pv in rec.product_variant_ids + if getattr(pv, "saleor_variant_id", False) + ] + collections = [ + pc.saleor_collection_id + for pc in rec.product_collection_ids + if getattr(pc, "saleor_collection_id", False) + ] + categories = [ + cat.saleor_category_id + for cat in rec.product_category_ids + if getattr(cat, "saleor_category_id", False) + ] + if any([products, variants, collections, categories]): + self._refresh_token(client) + client.voucher_catalogues_add( + saleor_id, + products=products, + collections=collections, + categories=categories, + variants=variants, + ) + except Exception as e: + _logger.warning("Failed to attach voucher catalogues: %s", e) + + def _voucher_add_codes(self, client, saleor_id, codes, existing): + """Add voucher codes after creation/update.""" + try: + codes_to_add = [] + if codes: + if existing is None: + codes_to_add = codes[1:] if len(codes) > 1 else [] + else: + codes_to_add = codes + if codes_to_add: + self._refresh_token(client) + client.voucher_update_add_codes(saleor_id, codes_to_add) + except Exception as e: + _logger.warning("Failed to add voucher codes: %s", e) + + # --- Promotions --- + def job_promotion_sync(self, program_id, payload): + """Sync a loyalty.program (program_type='saleor') to Saleor Promotion.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor promotion sync skipped for program %s" + " on inactive account %s", + program_id, + self.name, + ) + return True + + prog = self.env["loyalty.program"].browse(program_id) + if not prog or prog.program_type != "saleor": + return True + + client = self._get_client() + self._refresh_token(client) + + # Create or update + saleor_id = prog.saleor_promotion_id + try: + if saleor_id: + # Verify remote exists; if missing, create new + remote = client.promotion_get_by_id(saleor_id) + if remote and remote.get("id"): + # On update, Saleor may not accept 'type' in input + upd_payload = dict(payload) + upd_payload.pop("type", None) + res = client.promotion_update(saleor_id, upd_payload) + saleor_id = (res or {}).get("id") or saleor_id + else: + created = client.promotion_create(payload) + saleor_id = (created or {}).get("id") + else: + created = client.promotion_create(payload) + saleor_id = (created or {}).get("id") + + # Persist + if saleor_id and prog.saleor_promotion_id != saleor_id: + prog.write({"saleor_promotion_id": saleor_id}) + + # Skipping promotion channel sync per requirement + + # Sync promotion rules (upsert) + try: + self._sync_promotion_rules(client, prog, saleor_id) + except Exception as e: + _logger.warning( + "Failed to sync promotion rules for program %s: %s", + prog.display_name, + e, + ) + + return bool(saleor_id) + + except Exception as e: + _logger.exception( + "Error syncing promotion '%s' to Saleor via account %s: %s", + prog.display_name, + self.name, + e, + ) + raise UserError( + self.env._("Saleor promotion sync failed: %s", str(e)) + ) from e + + def job_promotion_sync_batch(self, items): + """Batch sync promotions.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor promotion batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + pid = it.get("id") + payload = it.get("payload") + try: + self.job_promotion_sync(pid, payload) + except Exception as e: + rec = self.env["loyalty.program"].browse(pid) + rec_name = rec.display_name if rec else f"promotion[{pid}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch promotion sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + def _sync_promotion_channels(self, client, program, saleor_promotion_id): + """Delta-sync promotion channels from program rules' channel_id.""" + # Collect desired channel IDs from rules + rules = program.discount_rule_ids + channels = rules.mapped("channel_id") if rules else self.env["saleor.channel"] + desired_ids = set() + if channels: + missing = channels.filtered(lambda ch: not ch.saleor_channel_id) + if missing: + names = ", ".join(missing.mapped("display_name")) + raise UserError( + self.env._( + "Some channels on this promotion" + " are not synced to Saleor yet: %s.\n" + "Please sync these channels first.", + names, + ) + ) + desired_ids = {ch.saleor_channel_id for ch in channels} + + # Fetch current promotion listings + self._refresh_token(client) + current = client.promotion_channel_listings(saleor_promotion_id) or [] + current_ids = { + item.get("channel", {}).get("id") for item in current if item.get("channel") + } + + to_add_ids = sorted(list(desired_ids - current_ids)) + to_remove_ids = sorted(list(current_ids - desired_ids)) + + add_channels = [{"channelId": ch_id} for ch_id in to_add_ids] + + if add_channels or to_remove_ids: + client.promotion_channel_listing_update( + saleor_promotion_id, + add_channels=add_channels, + remove_channels=to_remove_ids, + ) + return True + + def job_attribute_sync(self, attribute_id, payload): + return self.job_saleor_sync("attribute", attribute_id, payload) + + def job_attribute_sync_batch(self, items): + """Batch sync product attributes.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor attribute batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + att_id = it.get("id") + payload = it.get("payload") + try: + self.job_attribute_sync(att_id, payload) + except Exception as e: + rec = self.env["product.attribute"].browse(att_id) + rec_name = rec.display_name if rec else f"attribute[{att_id}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch attribute sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + def _channel_prepare_payload_with_zones(self, ch, payload): + zones = ch.shipping_zone_ids + if zones: + missing = zones.filtered(lambda z: not z.saleor_id) + if missing: + names = ", ".join(missing.mapped("name")) + raise UserError( + self.env._( + "Some shipping zones assigned to this channel" + " are not synced to Saleor yet: %s." + "\nPlease sync these shipping zones first.", + names, + ) + ) + payload = dict(payload or {}) + payload["addShippingZones"] = [z.saleor_id for z in zones] + return payload + + def _channel_align_currency(self, ch, remote_cur): + local_cur = getattr(ch.currency_id, "name", None) + if remote_cur and remote_cur != local_cur: + cur_rec = self.env["res.currency"].search( + [("name", "=", remote_cur)], limit=1 + ) + if cur_rec: + ch.with_context(bypass_currency_lock=True).write( + {"currency_id": cur_rec.id} + ) + else: + _logger.warning( + "Currency %s not found in res.currency;" " keeping local %s", + remote_cur, + local_cur, + ) + + def _strip_channel_currency_from_payload(self, payload): + if payload and payload.get("currencyCode") is not None: + payload = dict(payload) + payload.pop("currencyCode", None) + return payload + + def _post_channel_synced_message(self, ch, slug): + try: + dash_url, obj_url = saleor_dashboard_links( + self.base_url, "channel", slug=slug, id=ch.saleor_channel_id + ) + body = format_kv_list( + "Synced to Saleor:", + [ + ("Account", self.email or self.name), + ("Slug", slug), + ("Saleor ID", ch.saleor_channel_id), + ("Saleor", make_link("View in Saleor", obj_url)), + ], + ) + ch.message_post(body=body) + except Exception as e: + _logger.warning( + "Failed to post channel sync message (update) on %s: %s", + ch.id, + e, + ) + + def _update_channel_tax_configuration(self, client, ch): + """Best-effort update of the Saleor channel tax configuration. + + Isolated in a helper to keep job_channel_sync complexity low. + """ + channel_id = getattr(ch, "saleor_channel_id", False) + if not channel_id: + return + try: + entered = getattr(ch, "entered_prices", "without_tax") or "without_tax" + prices_entered_with_tax = entered == "with_tax" + client.channel_tax_configuration_update(channel_id, prices_entered_with_tax) + except Exception as e: + _logger.warning( + "Failed to update tax configuration for channel %s: %s", + channel_id, + e, + ) + + def job_channel_sync(self, channel_id, payload): + """Sync a single saleor.channel to Saleor.""" + self.ensure_one() + + ch = self.env["saleor.channel"].browse(channel_id) + if not ch: + return True + + client = self._get_client() + try: + payload = self._channel_prepare_payload_with_zones(ch, payload) + + # Update if we already have an ID + if ch.saleor_channel_id: + # Verify the stored ID exists remotely + self._refresh_token(client) + remote = None + try: + remote = client.channel_get_by_id(ch.saleor_channel_id) + except Exception: + remote = None + if not remote: + _logger.warning( + "Stored Saleor channel ID %s not found;" + " will re-create by slug %s", + ch.saleor_channel_id, + ch.slug, + ) + ch.saleor_channel_id = False + else: + _logger.info( + "Updating Saleor channel %s (%s)", ch.slug, ch.saleor_channel_id + ) + try: + remote_cur = (remote or {}).get("currencyCode") + self._channel_align_currency(ch, remote_cur) + except Exception: + _logger.exception( + "Failed to align channel currency with Saleor" + ) + payload = self._strip_channel_currency_from_payload(payload) + res = client.channel_update(ch.saleor_channel_id, payload) + # Persist and chatter + self._post_channel_synced_message(ch, ch.slug) + self._update_channel_tax_configuration(client, ch) + return bool(res) + + # Otherwise try to find by slug + slug = payload.get("slug") or getattr(ch, "slug", None) + existing = None + if slug: + self._refresh_token(client) + existing = client.channel_get_by_slug(slug) + + if existing and existing.get("id"): + ch.saleor_channel_id = existing["id"] + _logger.info("Updating existing Saleor channel %s", slug) + self._refresh_token(client) + # Ensure local currency matches remote when found by slug + try: + remote_cur = (existing or {}).get("currencyCode") + self._channel_align_currency(ch, remote_cur) + except Exception: + _logger.exception( + "Failed to align channel currency with Saleor (slug)" + ) + payload = self._strip_channel_currency_from_payload(payload) + res = client.channel_update(ch.saleor_channel_id, payload) + self._post_channel_synced_message(ch, slug) + self._update_channel_tax_configuration(client, ch) + return bool(res) + + # Create new + _logger.info("Creating Saleor channel %s", slug or ch.name) + self._refresh_token(client) + # Ensure currencyCode is present for creation + if not payload.get("currencyCode"): + cur = getattr(ch.currency_id, "name", None) + if not cur: + raise UserError( + self.env._( + "Channel %s requires a Currency before creating in Saleor", + ch.display_name or ch.slug or ch.name, + ) + ) + payload = dict(payload) + payload["currencyCode"] = cur + created = client.channel_create(payload) + ch.saleor_channel_id = (created or {}).get("id") + try: + self._post_channel_synced_message(ch, slug or ch.slug) + except Exception as e: + _logger.warning( + "Failed to post channel sync message (create) on %s: %s", + ch.id, + e, + ) + if ch.saleor_channel_id: + self._update_channel_tax_configuration(client, ch) + return bool(ch.saleor_channel_id) + + except Exception as e: + _logger.exception( + "Error syncing channel '%s' to Saleor via account %s: %s", + ch.slug, + self.name, + e, + ) + try: + ch.message_post( + body=format_note( + self.env, + "Error syncing to Saleor (account %s): %s", + self.email or self.name, + str(e), + ) + ) + except Exception as e2: + _logger.warning( + "Failed to post channel sync error message on %s: %s", + ch.id, + e2, + ) + raise UserError(self.env._("Saleor channel sync failed: %s", e)) from e + + def job_channel_sync_batch(self, items): + """Batch sync channels.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor channel batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + cid = it.get("id") + payload = it.get("payload") + try: + self.job_channel_sync(cid, payload) + except Exception as e: + rec = self.env["saleor.channel"].browse(cid) + rec_name = rec.display_name if rec else f"channel[{cid}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch channel sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + def job_tax_sync(self, tax_id, payload): + """Sync a single account.tax to Saleor TaxClass.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor tax sync skipped for tax %s" " on inactive account %s", + tax_id, + self.name, + ) + return True + + tax = self.env["account.tax"].browse(tax_id) + if not tax: + return True + + # Make sure our app exists and has required permissions before using app token + try: + self._ensure_app_and_webhook() + except Exception as e: + _logger.warning( + "Failed to ensure Saleor App and Webhook for account %s: %s", + self.name, + e, + ) + + client = self._get_client() + + saleor_tax_id = None + error = None + try: + saleor_tax_id = upsert_tax_class(client, tax, payload) + if saleor_tax_id and tax.saleor_tax_class_id != saleor_tax_id: + tax.write({"saleor_tax_class_id": saleor_tax_id}) + except Exception as e: + error = e + _logger.error( + "Error syncing tax %s to Saleor via account %s: %s", + tax_id, + self.name, + str(e), + exc_info=True, + ) + + if error: + try: + tax.message_post( + body=format_note( + self.env, + "Error syncing to Saleor (account %s): %s", + self.email, + str(error), + ) + ) + except Exception as e: + _logger.warning( + "Failed to post error message on tax %s for account %s: %s", + tax_id, + self.email, + e, + ) + return False + + try: + dash_url, obj_url = saleor_dashboard_links( + self.base_url, "tax", id=saleor_tax_id + ) + body = format_kv_list( + "Synced to Saleor:", + [ + ("Account", self.email or self.name), + ("TaxClass ID", saleor_tax_id), + ("Saleor", make_link("View in Saleor", obj_url)), + ], + ) + tax.message_post(body=body) + except Exception as e: + _logger.warning( + "Failed to post sync message on tax %s for account %s: %s", + tax_id, + self.email, + e, + ) + return True + + def job_tax_sync_batch(self, items): + """Batch sync account.tax to Saleor TaxClass.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor tax batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + tid = it.get("id") + payload = it.get("payload") + try: + self.job_tax_sync(tid, payload) + except Exception as e: + rec = self.env["account.tax"].browse(tid) + rec_name = rec.display_name if rec else f"tax[{tid}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._("Batch tax sync failed for %s item(s):", len(errors)) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + def job_shipping_method_sync(self, carrier_id, payload): + """Sync a single delivery.carrier shipping method to Saleor.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor shipping method sync skipped for carrier %s" + " on inactive account %s", + carrier_id, + self.name, + ) + return True + + carrier = self.env["delivery.carrier"].browse(carrier_id) + if not carrier or carrier.delivery_type != "saleor": + return True + + if not carrier.zone_id: + _logger.warning("Carrier %s has no shipping zone assigned", carrier.name) + return False + + if not carrier.zone_id.saleor_id: + _logger.warning( + "Shipping zone %s for carrier %s is not synced to Saleor", + carrier.zone_id.name, + carrier.name, + ) + return False + + client = self._get_client() + try: + method_id = carrier.saleor_shipping_method_id + exists = False + + if method_id: + # Verify existence in Saleor + self._refresh_token(client) + exists = bool(client.shipping_method_get_by_id(method_id)) + + if exists: + method_id = self._shipping_method_update_flow( + client, carrier, method_id, payload + ) + else: + method_id = self._shipping_method_create_flow(client, carrier, payload) + + # Sync channel listings for the shipping method + if method_id: + try: + self._sync_shipping_method_channel_listings( + client, carrier, method_id + ) + except Exception as e: + _logger.warning( + "Failed to sync channel listings for carrier %s: %s", + carrier.name, + e, + ) + + return bool(method_id) + + except Exception as e: + _logger.exception( + """Error syncing shipping method '%s' + to Saleor via account %s: %s""", + carrier.name, + self.name, + e, + ) + try: + if carrier.zone_id: + carrier.zone_id.message_post( + body=format_note( + self.env, + "Error syncing shipping method %s to Saleor " + "(account %s): %s", + carrier.name, + self.email or self.name, + str(e), + ) + ) + except Exception as e2: + _logger.warning( + "Failed to post carrier sync error message on %s: %s", + carrier.id, + e2, + ) + return False + + def _shipping_method_update_flow(self, client, carrier, method_id, payload): + """Handle update of an existing shipping method and related data.""" + _logger.info( + "Updating Saleor shipping method for carrier %s (%s)", + carrier.name, + method_id, + ) + self._refresh_token(client) + # Create update payload without postal codes and excluded products + update_payload = { + k: v + for k, v in payload.items() + if k not in ["addPostalCodeRules", "inclusionType", "excludedProducts"] + } + updated = client.shipping_method_update(method_id, update_payload) + _logger.info( + "Updated shipping method %s for carrier %s: %s", + method_id, + carrier.name, + updated, + ) + + # Metadata + try: + if "metadata" in payload and payload["metadata"]: + client.shipping_method_metadata_update(method_id, payload["metadata"]) + if "privateMetadata" in payload and payload["privateMetadata"]: + client.shipping_method_private_metadata_update( + method_id, payload["privateMetadata"] + ) + except Exception as e: + _logger.warning( + "Failed to update metadata for shipping method %s: %s", method_id, e + ) + + # Postal codes + if "addPostalCodeRules" in payload: + try: + self._refresh_token(client) + inclusion_type = payload.get("inclusionType", "INCLUDE") + client.shipping_method_sync_postal_codes( + method_id, payload["addPostalCodeRules"], inclusion_type + ) + except Exception as e: + _logger.warning( + "Failed to sync postal codes for method %s: %s", method_id, e + ) + + # Excluded products + if "excludedProducts" in payload: + try: + self._refresh_token(client) + client.shipping_method_sync_excluded_products( + method_id, payload["excludedProducts"] + ) + except Exception as e: + _logger.warning( + "Failed to sync excluded products for method %s: %s", method_id, e + ) + + # Message + try: + if carrier.zone_id: + dash_url, obj_url = saleor_dashboard_links( + self.base_url, + "shipping_method", + id=method_id, + zone_id=getattr(carrier.zone_id, "saleor_id", None), + ) + carrier.zone_id.message_post( + body=format_kv_list( + "Update Shipping Method:", + [ + ("Account", self.email or self.name), + ("Shipping Method", carrier.name), + ("Saleor ID", method_id), + ("Saleor", make_link("View in Saleor", obj_url)), + ], + ) + ) + except Exception as e: + _logger.warning( + "Failed to post carrier sync message (update) on %s: %s", carrier.id, e + ) + + return method_id + + def _shipping_method_create_flow(self, client, carrier, payload): + """Handle creation of a shipping method and related data. + Returns method_id or False.""" + _logger.info( + "Creating Saleor shipping method for carrier %s in zone %s", + carrier.name, + carrier.zone_id.name, + ) + self._refresh_token(client) + created_method = client.shipping_method_create( + carrier.zone_id.saleor_id, payload + ) + method_id = (created_method or {}).get("id") + carrier.saleor_shipping_method_id = method_id + _logger.info( + "Created shipping method for carrier %s -> id=%s", carrier.name, method_id + ) + + # Enforce min/max delivery days if provided + if method_id and ( + "minimumDeliveryDays" in payload or "maximumDeliveryDays" in payload + ): + try: + self._refresh_token(client) + _logger.debug( + "Post-create enforcing min/max delivery days" + " via update for method %s", + method_id, + ) + update_payload = { + k: v + for k, v in payload.items() + if k in ["minimumDeliveryDays", "maximumDeliveryDays"] + } + client.shipping_method_update(method_id, update_payload) + except Exception as e: + _logger.warning( + "Post-create update to enforce min/max delivery days" + " failed for method %s: %s", + method_id, + e, + ) + + # Postal codes + if method_id and "addPostalCodeRules" in payload: + try: + self._refresh_token(client) + inclusion_type = payload.get("inclusionType", "INCLUDE") + client.shipping_method_sync_postal_codes( + method_id, payload["addPostalCodeRules"], inclusion_type + ) + except Exception as e: + _logger.warning( + "Failed to sync postal codes for method %s: %s", method_id, e + ) + + # Excluded products + if method_id and "excludedProducts" in payload: + try: + self._refresh_token(client) + client.shipping_method_sync_excluded_products( + method_id, payload["excludedProducts"] + ) + except Exception as e: + _logger.warning( + "Failed to sync excluded products for method %s: %s", method_id, e + ) + + # Message + if not method_id: + _logger.warning( + "Saleor did not return an ID for created shipping method of carrier %s", + carrier.id, + ) + return False + else: + try: + if carrier.zone_id: + dash_url, obj_url = saleor_dashboard_links( + self.base_url, + "shipping_method", + id=method_id, + zone_id=getattr(carrier.zone_id, "saleor_id", None), + ) + carrier.zone_id.message_post( + body=format_kv_list( + "Create Shipping Method:", + [ + ("Account", self.email or self.name), + ("Shipping Method", carrier.name), + ("Saleor ID", method_id), + ("Saleor", make_link("View in Saleor", obj_url)), + ], + ) + ) + except Exception as e: + _logger.warning( + "Failed to post carrier sync message (create) on %s: %s", + carrier.id, + e, + ) + return method_id + + def _sync_shipping_method_channel_listings(self, client, carrier, method_id): + """Sync channel listings for a shipping method.""" + add_channels = [] + + # If carrier has specific pricing lines, use those + if carrier.saleor_shipping_pricing_line_ids: + for pricing_line in carrier.saleor_shipping_pricing_line_ids: + channel = pricing_line.channel_id + if not channel.saleor_channel_id: + _logger.warning( + "Channel %s for carrier %s is not synced to Saleor", + channel.name, + carrier.name, + ) + continue + + channel_config = { + "channelId": channel.saleor_channel_id, + "price": str(pricing_line.price), + } + + # Add order value constraints only when enabled on carrier + if carrier.order_value: + for ( + order_value_line + ) in carrier.saleor_order_value_line_ids.filtered( + lambda line, ch=channel: line.channel_id == ch + ): + if carrier.shipping_method_type == "price": + channel_config["minimumOrderPrice"] = str( + order_value_line.min_value + ) + channel_config["maximumOrderPrice"] = str( + order_value_line.max_value + ) + else: + if carrier.shipping_method_type == "price": + channel_config["minimumOrderPrice"] = None + channel_config["maximumOrderPrice"] = None + + add_channels.append(channel_config) + else: + if carrier.zone_id and carrier.zone_id.channel_ids: + default_price = "0.00" # Default price if no pricing configured + for channel in carrier.zone_id.channel_ids: + if not channel.saleor_channel_id: + _logger.warning( + "Channel %s for carrier %s is not synced to Saleor", + channel.name, + carrier.name, + ) + continue + + channel_config = { + "channelId": channel.saleor_channel_id, + "price": default_price, + } + + if carrier.order_value: + for ( + order_value_line + ) in carrier.saleor_order_value_line_ids.filtered( + lambda line, ch=channel: line.channel_id == ch + ): + if carrier.shipping_method_type == "price": + channel_config["minimumOrderPrice"] = str( + order_value_line.min_value + ) + channel_config["maximumOrderPrice"] = str( + order_value_line.max_value + ) + else: + if carrier.shipping_method_type == "price": + channel_config["minimumOrderPrice"] = None + channel_config["maximumOrderPrice"] = None + + add_channels.append(channel_config) + + if add_channels: + self._refresh_token(client) + client.shipping_method_channel_listing_update(method_id, add_channels) + _logger.info( + "Updated channel listings for shipping method %s with %d channels", + method_id, + len(add_channels), + ) + else: + _logger.info("No channels to add for shipping method %s", method_id) + + def job_shipping_zone(self, shipping_zone_id, payload): + """Sync a single saleor.shippingZone to Saleor.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor shipping zone sync skipped for zone %s" + " on inactive account %s", + shipping_zone_id, + self.name, + ) + return True + + sz = self.env["saleor.shipping.zone"].browse(shipping_zone_id) + if not sz: + return True + + client = self._get_client() + try: + # Prepare payload with validated channels + payload = self._prepare_shipping_zone_channels(sz, payload) + + if sz.saleor_id: + _logger.info( + "Updating Saleor shipping zone %s (%s)", sz.name, sz.saleor_id + ) + self._refresh_token(client) + try: + res = self._shipping_zone_update_only(client, sz, payload) + self._post_shipping_zone_synced(sz) + # Always sync shipping methods after a successful update + try: + self._sync_shipping_methods(client, sz) + except Exception as e: + _logger.exception( + "Error syncing shipping methods for zone %s (%s): %s", + sz.name, + sz.saleor_id, + e, + ) + return bool(res) + except Exception as e: + error_msg = str(e).lower() + if ( + "not_found" in error_msg + or "couldn't resolve to a node" in error_msg + ): + _logger.warning( + "Shipping zone %s with Saleor ID %s " + "no longer exists; will recreate", + sz.name, + sz.saleor_id, + ) + sz.saleor_id = False + else: + raise + + # Try to confirm existing by ID (if any) + existing = None + if sz.saleor_id: + self._refresh_token(client) + existing = client.shipping_zone_get_by_id(sz.saleor_id) + if existing and ( + isinstance(existing, str) or getattr(existing, "get", None) + ): + sz.saleor_id = ( + existing if isinstance(existing, str) else existing.get("id") + ) + self._refresh_token(client) + res = self._shipping_zone_update_only(client, sz, payload) + self._post_shipping_zone_synced(sz) + try: + self._sync_shipping_methods(client, sz) + except Exception as e: + _logger.exception( + "Error syncing shipping methods for zone %s (%s): %s", + sz.name, + sz.saleor_id, + e, + ) + return bool(res) + + # Create new zone + created_ok = self._shipping_zone_create_only(client, sz, payload) + self._post_shipping_zone_synced(sz) + if sz.saleor_id: + try: + self._sync_shipping_methods(client, sz) + except Exception as e: + _logger.exception( + "Error syncing shipping methods for newly" + " created zone %s (%s): %s", + sz.name, + sz.saleor_id, + e, + ) + return bool(created_ok) + + except Exception as e: + _logger.exception( + "Error syncing shipping zone '%s' to Saleor via account %s: %s", + sz.name, + self.name, + e, + ) + raise + + def job_shipping_zone_batch(self, items): + """Batch sync saleor.shipping.zone records.""" + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor shipping zone batch sync skipped for %s item(s)" + " on inactive account %s", + len(items or []), + self.name, + ) + return True + errors = [] + for it in items or []: + zone_id = it.get("id") + payload = it.get("payload") + try: + self.job_shipping_zone(zone_id, payload) + except Exception as e: + rec = self.env["saleor.shipping.zone"].browse(zone_id) + rec_name = rec.display_name if rec else f"shipping.zone[{zone_id}]" + errors.append((rec_name, str(e))) + if errors: + header = self.env._( + "Batch shipping zone sync failed for %s item(s):", len(errors) + ) + body = format_batch_errors_message(header, errors) + post_to_current_job_committed(self.env, self, body) + self.env.cr.rollback() + raise UserError(header) + return True + + def post_fetch_success(self, rec, object_type): + """Post a unified success message for fetch operations.""" + try: + if object_type == "product": + rec.message_post( + body=format_note( + self.env, + "Fetched metadata from Saleor (account: %s)", + self.name, + ) + ) + except Exception as e: + _logger.warning("Failed to post fetch message on %s: %s", object_type, e) + + # --- Internal helpers to reduce complexity --- + def _handlers_for(self, client, object_type): + if object_type == "collection": + model = "product.collection" + get_by_slug = client.collection_get_by_slug + do_update = partial(saleor_collection_do_update, client) + do_create = client.collection_create + elif object_type == "product": + model = "product.template" + get_by_slug = client.product_get_by_slug + do_update = partial(saleor_product_do_update, client) + do_create = client.product_create + elif object_type == "attribute": + model = "product.attribute" + get_by_slug = client.attribute_get_by_slug + do_update = partial(saleor_attribute_do_update, client) + do_create = client.attribute_create + else: + raise ValueError(f"Unsupported object_type: {object_type}") + return model, get_by_slug, do_update, do_create + + def _sync_shipping_methods(self, client, sz): + """Ensure delivery.carrier methods in zone are created/updated""" + self.ensure_one() + carriers = sz.shipping_method_ids.filtered( + lambda c: c.delivery_type == "saleor" + ) + if not carriers: + carriers = self.env["delivery.carrier"].search( + [ + ("zone_id", "=", sz.id), + ("delivery_type", "=", "saleor"), + ] + ) + _logger.info( + "Shipping method sync for zone %s (%s): %s carriers", + sz.name, + sz.saleor_id, + len(carriers), + ) + if not carriers: + return True + + # Use the new job-based sync pattern + if len(carriers) == 1: + carrier = carriers + payload = carrier._saleor_shipping_method_prepare_payload() + self.job_shipping_method_sync(carrier.id, payload) + else: + for carrier in carriers: + payload = carrier._saleor_shipping_method_prepare_payload() + if hasattr(self, "with_delay"): + self.with_delay().job_shipping_method_sync(carrier.id, payload) + else: + self.job_shipping_method_sync(carrier.id, payload) + return True + + # --- Shipping Zone helpers (complexity extraction) --- + def _prepare_shipping_zone_channels(self, sz, payload): + """Validate and inject channel IDs into the payload as addChannels. + Raises UserError if some channels are not synced yet. + """ + channels = sz.channel_ids + if channels: + missing_channels = channels.filtered(lambda c: not c.saleor_channel_id) + if missing_channels: + names = ", ".join(missing_channels.mapped("name")) + raise UserError( + self.env._( + "Some channels assigned to this shipping zone" + " are not synced to Saleor yet: %s." + "\nPlease sync these channels first.", + names, + ) + ) + payload = dict(payload or {}) + payload["addChannels"] = [c.saleor_channel_id for c in channels] + return payload + + def _inject_product_tax_class(self, client, rec, payload): + """Ensure product's sale tax has a Saleor TaxClass and inject its ID.""" + try: + # Prefer the first sale tax on the template + taxes = ( + getattr(rec, "taxes_id", False) + and rec.taxes_id + or self.env["account.tax"] + ) + sale_taxes = ( + taxes.filtered(lambda t: t.type_tax_use == "sale") + if taxes + else self.env["account.tax"] + ) + tax = sale_taxes[:1] + tax = tax and tax[0] or None + if not tax: + return payload + + saleor_tax_id = getattr(tax, "saleor_tax_class_id", None) + if not saleor_tax_id: + # Build tax payload and upsert + try: + tax_payload = tax._saleor_prepare_tax_payload() + except Exception as e: + _logger.warning( + "Failed to prepare Saleor tax payload for tax %s: %s", + tax.id, + e, + ) + tax_payload = None + if tax_payload: + # Ensure app has required permissions + try: + self._ensure_app_and_webhook() + except Exception as e: + _logger.warning( + "Failed to ensure Saleor App and Webhook" + " before upserting tax %s: %s", + tax.id, + e, + ) + saleor_tax_id = upsert_tax_class(client, tax, tax_payload) + if ( + saleor_tax_id + and getattr(tax, "saleor_tax_class_id", None) != saleor_tax_id + ): + try: + tax.write({"saleor_tax_class_id": saleor_tax_id}) + except Exception as e: + _logger.warning( + "Failed to store Saleor TaxClass ID on tax %s: %s", + tax.id, + e, + ) + + if saleor_tax_id: + payload = dict(payload or {}) + payload["taxClass"] = saleor_tax_id + except Exception as e: + _logger.warning( + "Failed to resolve/create tax class for product %s: %s", + getattr(rec, "id", "unknown"), + e, + ) + return payload + + def _inject_product_type_tax_class(self, client, ptype, payload): + """Ensure product type's sale tax has a Saleor TaxClass and inject its ID.""" + try: + tax = getattr(ptype, "tax_id", None) + if not tax: + return payload + + saleor_tax_id = getattr(tax, "saleor_tax_class_id", None) + if not saleor_tax_id: + try: + tax_payload = tax._saleor_prepare_tax_payload() + except Exception as e: + _logger.warning( + "Failed to prepare Saleor tax payload for tax %s: %s", + tax.id, + e, + ) + tax_payload = None + if tax_payload: + try: + self._ensure_app_and_webhook() + except Exception as e: + _logger.warning( + "Failed to ensure Saleor App and Webhook" + " before upserting tax %s: %s", + tax.id, + e, + ) + saleor_tax_id = upsert_tax_class(client, tax, tax_payload) + if ( + saleor_tax_id + and getattr(tax, "saleor_tax_class_id", None) != saleor_tax_id + ): + try: + tax.write({"saleor_tax_class_id": saleor_tax_id}) + except Exception as e: + _logger.warning( + "Failed to store Saleor TaxClass ID on tax %s: %s", + tax.id, + e, + ) + + if saleor_tax_id: + payload = dict(payload or {}) + payload["taxClass"] = saleor_tax_id + except Exception as e: + _logger.warning( + "Failed to resolve/create tax class for product type %s: %s", + getattr(ptype, "id", "unknown"), + e, + ) + return payload + + def _shipping_zone_update_only(self, client, sz, payload): + """Update an existing shipping zone, handling metadata separately.""" + # Create update payload without metadata fields + update_payload = { + k: v for k, v in payload.items() if k not in ["metadata", "privateMetadata"] + } + res = client.shipping_zone_update(sz.saleor_id, update_payload) + + # Handle metadata updates + try: + if "metadata" in payload and payload["metadata"]: + client.shipping_zone_metadata_update(sz.saleor_id, payload["metadata"]) + if "privateMetadata" in payload and payload["privateMetadata"]: + client.shipping_zone_private_metadata_update( + sz.saleor_id, payload["privateMetadata"] + ) + except Exception as e: + _logger.warning( + "Failed to update metadata for shipping zone %s: %s", sz.saleor_id, e + ) + return res + + def _shipping_zone_create_only(self, client, sz, payload): + """Create a shipping zone, then set metadata if provided. Returns True/False.""" + _logger.info("Creating Saleor shipping zone %s", sz.name) + self._refresh_token(client) + create_payload = { + k: v for k, v in payload.items() if k not in ["metadata", "privateMetadata"] + } + created = client.shipping_zone_create(create_payload) + sz.saleor_id = (created or {}).get("id") + + if sz.saleor_id: + try: + if "metadata" in payload and payload["metadata"]: + client.shipping_zone_metadata_update( + sz.saleor_id, payload["metadata"] + ) + if "privateMetadata" in payload and payload["privateMetadata"]: + client.shipping_zone_private_metadata_update( + sz.saleor_id, payload["privateMetadata"] + ) + except Exception as e: + _logger.warning( + "Failed to update metadata for new shipping zone %s: %s", + sz.saleor_id, + e, + ) + return bool(sz.saleor_id) + + def _post_shipping_zone_synced(self, sz): + """Post a standardized message indicating the zone has been synced.""" + try: + acc = self.email or self.name + dash_url, obj_url = saleor_dashboard_links( + self.base_url, "shipping_zone", id=sz.saleor_id + ) + body = format_kv_list( + "Synced to Saleor:", + [ + ("Account", acc), + ("Name", sz.name), + ("Saleor ID", sz.saleor_id), + ("Saleor", make_link("View in Saleor", obj_url)), + ], + ) + sz.message_post(body=body) + except Exception as e: + _logger.warning( + "Failed to post shipping zone sync message on %s: %s", sz.id, e + ) + + def _ensure_slug_in_payload(self, rec, payload): + slug = payload.get("slug") + if not slug: + name = payload.get("name") or getattr(rec, "name", None) + if name: + # Prefer an existing slug stored on the record, falling back + # to generating a new one using our shared helper. + field_name = None + for candidate in ("saleor_slug", "saleor_collection_slug", "slug"): + if hasattr(rec, candidate): + field_name = candidate + break + + if field_name: + slug = getattr(rec, field_name, None) + if not slug: + slug = generate_unique_slug( + rec, + name, + slug_field_name=field_name, + ) + # Persist back to the record when we own the field + try: + setattr(rec, field_name, slug) + except Exception as e: + _logger.debug( + "Failed to persist slug '%s' to field %s on %s: %s", + slug, + field_name, + getattr(rec, "id", ""), + e, + ) + + if slug: + payload = dict(payload) + payload["slug"] = slug + return slug, payload + + def _inject_product_category(self, client, rec, payload): + """Ensure product's category exists in Saleor and inject its ID.""" + try: + cat = rec.categ_id + cat_id = getattr(cat, "saleor_category_id", None) + if not cat_id: + name = getattr(cat, "name", None) + cat_slug = getattr(cat, "saleor_slug", None) + if not cat_slug and name: + cat_slug = generate_unique_slug( + cat, + name, + slug_field_name="saleor_slug", + ) + try: + cat.saleor_slug = cat_slug + except Exception as e: + _logger.debug( + "Failed to persist generated category slug '%s' on %s: %s", + cat_slug, + getattr(cat, "id", ""), + e, + ) + self._refresh_token(client) + saleor_cat = client.category_get_by_slug(cat_slug) if cat_slug else None + if not saleor_cat: + payload_cat = cat._saleor_prepare_payload() + # Ensure payload has a slug consistent with the record + if not payload_cat.get("slug") and cat_slug: + payload_cat = dict(payload_cat) + payload_cat["slug"] = cat_slug + self._refresh_token(client) + saleor_cat = client.category_create(payload_cat) + cat_id = saleor_cat and saleor_cat.get("id") + if cat_id: + try: + cat.write({"saleor_category_id": cat_id}) + except Exception as e: + _logger.warning( + "Failed to store Saleor category ID on Odoo " + "category %s: %s", + cat.id, + e, + ) + if cat_id: + payload = dict(payload) + payload["category"] = cat_id + except Exception as e: + _logger.warning( + "Failed to resolve/create category for product %s: %s", + rec.id, + e, + ) + return payload + + def _upload_product_image( + self, + client, + saleor_id, + filename, + img_bytes, + content_type, + image_record=None, + ): + """Upload a product image to Saleor.""" + if not (saleor_id and img_bytes and filename): + return False + + try: + self._refresh_token(client) + + # First, upload the image + media_res = client.product_media_create( + saleor_id, filename, img_bytes, content_type + ) + + if not media_res or not media_res.get("id"): + return False + + # Update the image record with the Saleor ID if provided + if image_record and hasattr(image_record, "saleor_image_id"): + image_record.saleor_image_id = media_res["id"] + + # Post success message to the product's chatter + try: + product_tmpl = image_record.product_tmpl_id + if product_tmpl: + product_tmpl.message_post( + body=format_note( + self.env, + "Successfully uploaded image '%s' to Saleor " + "(account %s)", + image_record.name, + self.email, + ) + ) + except Exception as e: + _logger.warning( + "Failed to post image upload message for product %s: %s", + product_tmpl.id if product_tmpl else "unknown", + str(e), + ) + + return media_res["id"] + + except Exception as e: + _logger.warning("Failed to upload product image: %s", e, exc_info=True) + return False + + def _upload_collection_image( + self, client, saleor_id, filename, img_bytes, content_type + ): + if not (saleor_id and img_bytes and filename): + return False + try: + self._refresh_token(client) + client.collection_update( + saleor_id, + {}, + filename=filename, + file_bytes=img_bytes, + content_type=content_type, + ) + return True + except Exception as e: + _logger.warning("Failed to upload collection image: %s", e) + return False + + def _delete_product_image(self, client, saleor_image_id): + """Delete a product image from Saleor""" + if not saleor_image_id: + return False + + try: + self._refresh_token(client) + client.product_media_delete(saleor_image_id) + return True + except Exception as e: + _logger.error( + "Failed to delete product image from Saleor: %s", str(e), exc_info=True + ) + return False + + def _ensure_collection_and_add_product(self, client, rec, saleor_id): + try: + col = rec.saleor_collection_id + name = getattr(col, "name", None) + col_slug = getattr(col, "saleor_collection_slug", None) + if not col_slug and name: + # Generate and persist a slug for the collection if missing + col_slug = generate_unique_slug( + col, + name, + slug_field_name="saleor_collection_slug", + ) + try: + col.saleor_collection_slug = col_slug + except Exception as e: + _logger.debug( + "Failed to persist generated collection slug '%s' on %s: %s", + col_slug, + getattr(col, "id", ""), + e, + ) + self._refresh_token(client) + saleor_col = client.collection_get_by_slug(col_slug) if col_slug else None + if not saleor_col: + payload_col = col._saleor_collection_prepare_payload() + if not payload_col.get("slug") and col_slug: + payload_col = dict(payload_col) + payload_col["slug"] = col_slug + self._refresh_token(client) + saleor_col = client.collection_create(payload_col) + col_id = saleor_col and saleor_col.get("id") + if col_id: + self._refresh_token(client) + client.collection_add_products(col_id, [saleor_id]) + except Exception as e: + _logger.warning("Failed to add product %s to collection: %s", saleor_id, e) + + def _handle_attribute_values_sync(self, client, saleor_id, payload): + """Handle syncing attribute values for an existing attribute.""" + try: + desired = set(payload.get("values") or []) + current = set(client.attribute_values_list(saleor_id)) + for name in sorted(desired - current): + self._refresh_token(client) + client.attribute_value_create(saleor_id, name) + except Exception as e: + _logger.warning("Failed to sync attribute values: %s", e) + + def _ensure_product_type(self, client, rec, payload): + """Ensure ProductType exists/updates in Saleor using rec.product_type_id.""" + # Require explicit product_type_id on template + ptype = getattr(rec, "product_type_id", None) + if not ptype: + raise UserError( + self.env._( + "Please set Product Type on the product before syncing to Saleor." + ) + ) + + # Build ProductType input payload from ptype + input_data = self._build_product_type_input(ptype) + input_data = self._inject_product_type_tax_class(client, ptype, input_data) + + # Create or update depending on whether a mapping already exists + ptype_id = getattr(ptype, "saleor_product_type_id", None) + self._refresh_token(client) + if ptype_id: + try: + updated = client.product_type_update(ptype_id, input_data) + ptype_id = (updated or {}).get("id") or ptype_id + except Exception as e: + _logger.warning( + "Saleor productTypeUpdate failed for %s: %s", ptype_id, e + ) + else: + existing = None + try: + if ptype.name: + existing = client.product_type_search_by_name(ptype.name) + except Exception as e: + _logger.warning( + "Saleor productType search by name failed for %s: %s", ptype.name, e + ) + + if existing and existing.get("id"): + ptype_id = existing.get("id") + else: + try: + created = client.product_type_create(input_data) + ptype_id = created and created.get("id") + except Exception as e: + _logger.warning( + "Saleor productTypeCreate failed for %s: %s", ptype.name, e + ) + + if not ptype_id: + raise UserError( + self.env._( + "Failed to create/update Product Type '%s' in Saleor.", ptype.name + ) + ) + + # Persist mapping on the product type record + try: + if getattr(ptype, "saleor_product_type_id", None) != ptype_id: + ptype.write({"saleor_product_type_id": ptype_id}) + except Exception as e: + _logger.warning( + "Failed to store Product Type mapping on %s: %s", ptype.id, e + ) + + # Sync metadata and private metadata for ProductType via dedicated mutations + try: + meta_lines = getattr(ptype, "metadate_line", None) + metadata = ( + [{"key": line.key, "value": line.value} for line in meta_lines] + if meta_lines + else [] + ) + if metadata: + self._refresh_token(client) + client.product_type_metadata_update(ptype_id, metadata) + except Exception as e: + _logger.warning( + "Failed to sync public metadata for product type %s: %s", ptype.id, e + ) + try: + priv_lines = getattr(ptype, "private_metadata_line", None) + private_metadata = ( + [{"key": line.key, "value": line.value} for line in priv_lines] + if priv_lines + else [] + ) + if private_metadata: + self._refresh_token(client) + client.product_type_private_metadata_update(ptype_id, private_metadata) + except Exception as e: + _logger.warning( + "Failed to sync private metadata for product type %s: %s", ptype.id, e + ) + + # Update payload with product type + new_payload = dict(payload) + new_payload["productType"] = ptype_id + return new_payload + + def sync_product_type_from_ptype(self, ptype): + self.ensure_one() + client = self._get_client() + input_data = self._build_product_type_input(ptype) + input_data = self._inject_product_type_tax_class(client, ptype, input_data) + + ptype_id = getattr(ptype, "saleor_product_type_id", None) + self._refresh_token(client) + if ptype_id: + try: + updated = client.product_type_update(ptype_id, input_data) + ptype_id = (updated or {}).get("id") or ptype_id + except Exception as e: + _logger.warning( + "Saleor productTypeUpdate failed for %s: %s", ptype_id, e + ) + else: + existing = None + try: + if ptype.name: + existing = client.product_type_search_by_name(ptype.name) + except Exception as e: + _logger.warning( + "Saleor productType search by name failed for %s: %s", + ptype.name, + e, + ) + + if existing and existing.get("id"): + ptype_id = existing.get("id") + else: + try: + created = client.product_type_create(input_data) + ptype_id = created and created.get("id") + except Exception as e: + _logger.warning( + "Saleor productTypeCreate failed for %s: %s", ptype.name, e + ) + + if not ptype_id: + raise UserError( + self.env._( + "Failed to create/update Product Type '%s' in Saleor.", + ptype.name, + ) + ) + + try: + if getattr(ptype, "saleor_product_type_id", None) != ptype_id: + ptype.write({"saleor_product_type_id": ptype_id}) + except Exception as e: + _logger.warning( + "Failed to store Product Type mapping on %s: %s", ptype.id, e + ) + + try: + meta_lines = getattr(ptype, "metadate_line", None) + metadata = ( + [{"key": line.key, "value": line.value} for line in meta_lines] + if meta_lines + else [] + ) + if metadata: + self._refresh_token(client) + client.product_type_metadata_update(ptype_id, metadata) + except Exception as e: + _logger.warning( + "Failed to sync public metadata for product type %s: %s", ptype.id, e + ) + try: + priv_lines = getattr(ptype, "private_metadata_line", None) + private_metadata = ( + [{"key": line.key, "value": line.value} for line in priv_lines] + if priv_lines + else [] + ) + if private_metadata: + self._refresh_token(client) + client.product_type_private_metadata_update(ptype_id, private_metadata) + except Exception as e: + _logger.warning( + "Failed to sync private metadata for product type %s: %s", ptype.id, e + ) + + def _build_product_type_input(self, ptype): + """Map saleor.product.type fields to Saleor ProductTypeInput.""" + # kind mapping + kind_map = { + "normal": "NORMAL", + "gift_card": "GIFT_CARD", + } + kind_val = kind_map.get(getattr(ptype, "kind", None) or "normal", "NORMAL") + + # attributes mapping (require Saleor IDs on attributes) + prod_attrs = [] + if getattr(ptype, "product_attribute_ids", None): + prod_attrs = [ + a.saleor_attribute_id + for a in ptype.product_attribute_ids + if getattr(a, "saleor_attribute_id", None) + ] + var_attrs = [] + if getattr(ptype, "variant_attribute_ids", None): + var_attrs = [ + a.saleor_attribute_id + for a in ptype.variant_attribute_ids + if getattr(a, "saleor_attribute_id", None) + ] + + # flags + has_variants = bool( + getattr(ptype, "use_variant_attributes", False) and var_attrs + ) + is_shipping_required = bool(getattr(ptype, "is_shipping", False)) + + # optional tax fields: keep None if not mapped in Odoo + tax_class = None + tax_code = None + + # weight: include only if positive + weight_val = getattr(ptype, "weight", 0) or 0 + weight = weight_val if weight_val > 0 else None + + return { + "name": ptype.name, + "slug": getattr(ptype, "slug", None) or None, + "kind": kind_val, + "hasVariants": has_variants, + "isDigital": False, + "isShippingRequired": is_shipping_required, + "productAttributes": prod_attrs, + "variantAttributes": var_attrs, + "taxClass": tax_class, + "taxCode": tax_code, + "weight": weight, + } + + def _persist_saleor_id(self, rec, object_type, saleor_id): + """Persist Saleor ID on the Odoo record.""" + if not saleor_id: + return + + field_map = { + "product": ("saleor_product_id", "saleor_product_id"), + "attribute": ("saleor_attribute_id", "saleor_attribute_id"), + "collection": ("saleor_collection_id", "saleor_collection_id"), + "category": ("saleor_category_id", "saleor_category_id"), + } + + field_name, current_value = field_map.get(object_type, (None, None)) + if not field_name: + return + + try: + if getattr(rec, current_value, None) != saleor_id: + rec.write({field_name: saleor_id}) + except Exception as e: + _logger.warning("Failed to store Saleor %s ID on Odoo: %s", object_type, e) + + def _handle_image_upload( + self, + client, + object_type, + saleor_id, + filename, + img_bytes, + content_type, + image_record=None, + ): + """Handle image upload based on object type.""" + if not all([saleor_id, filename, img_bytes, content_type]): + _logger.warning("Missing required parameters for image upload") + return False + + try: + upload_methods = { + "product": self._upload_product_image, + "collection": self._upload_collection_image, + } + + if object_type in upload_methods: + if object_type == "product": + return upload_methods[object_type]( + client=client, + saleor_id=saleor_id, + filename=filename, + img_bytes=img_bytes, + content_type=content_type, + image_record=image_record, + ) + else: + return upload_methods[object_type]( + client, saleor_id, filename, img_bytes, content_type + ) + return False + except Exception as e: + _logger.error("Error uploading image to Saleor: %s", str(e), exc_info=True) + return False + + def _process_image_upload( + self, + client, + object_type, + saleor_id, + img_bytes, + filename, + content_type, + image_record=None, + ): + """Handle the upload of a single image to Saleor.""" + if not all([saleor_id, img_bytes, filename, content_type]): + return False + + return self._handle_image_upload( + client=client, + object_type=object_type, + saleor_id=saleor_id, + filename=filename, + img_bytes=img_bytes, + content_type=content_type, + image_record=image_record, + ) + + def _process_extra_images(self, client, object_type, saleor_id, rec): + """Process and upload extra product images.""" + extra_images = self._prepare_image(rec, extra_images=True) or [] + if not isinstance(extra_images, list): + extra_images = [] + + for img_data in extra_images: + if not isinstance(img_data, list | tuple) or len(img_data) < 6: + continue + + try: + img_bytes = img_data[0] + filename = ( + str(img_data[1]) if img_data[1] else f"image_{int(time.time())}.jpg" + ) + content_type = str(img_data[2]) if img_data[2] else "image/jpeg" + image_record = img_data[5] if len(img_data) > 5 else None + + if not all([img_bytes, filename, content_type]): + continue + + if not (image_record and image_record.saleor_image_id): + self._process_image_upload( + client=client, + object_type=object_type, + saleor_id=saleor_id, + img_bytes=img_bytes, + filename=filename, + content_type=content_type, + image_record=image_record, + ) + except (IndexError, ValueError, TypeError) as e: + _logger.warning("Skipping invalid image data: %s", e) + continue + + def _handle_deleted_images(self, client, rec, payload): + """Handle deletion of images that were removed from the product.""" + current_image_ids = set( + rec.saleor_image_ids.filtered("saleor_image_id").mapped("saleor_image_id") + ) + previous_image_ids = set(payload.get("_saleor_current_image_ids", [])) + deleted_image_ids = previous_image_ids - current_image_ids + + for image_id in deleted_image_ids: + try: + if self._delete_product_image(client, image_id): + _logger.info("Successfully deleted image %s from Saleor", image_id) + rec.message_post( + body=format_note( + self.env, + "Deleted image from Saleor (ID: %s, account: %s)", + image_id, + self.email, + ) + ) + except Exception as e: + _logger.error( + "Failed to delete image %s from Saleor: %s", image_id, str(e) + ) + + def _update_existing_record( + self, + client, + object_type, + rec, + payload, + existing, + img_bytes, + filename, + content_type, + ): + """Handle update of an existing record in Saleor.""" + if object_type == "product": + payload = self._ensure_product_type(client, rec, payload) + self._refresh_token(client) + res = self._handlers_for(client, object_type)[2]( + existing["id"], payload, filename, img_bytes, content_type + ) + saleor_id = (res or {}).get("id") or existing.get("id") + + if object_type == "attribute": + self._handle_attribute_values_sync(client, saleor_id, payload) + + return saleor_id + + def _create_new_record( + self, client, object_type, rec, payload, img_bytes, filename, content_type + ): + """Handle creation of a new record in Saleor.""" + if object_type == "product": + payload = self._ensure_product_type(client, rec, payload) + + self._refresh_token(client) + res = self._handlers_for(client, object_type)[3](payload) + saleor_id = (res or {}).get("id") + + if object_type == "attribute" and saleor_id: + self._persist_saleor_id(rec, object_type, saleor_id) + + return saleor_id + + def job_saleor_fetch(self, record_ids, model_name): + """Dispatcher job to fetch metadata based on model_name. + + Accepts a single ID or a list of IDs and iterates accordingly. + """ + self.ensure_one() + + ids = record_ids if isinstance(record_ids, list | tuple) else [record_ids] + overall = True + for record_id in ids: + _logger.info( + "Starting Saleor metadata fetch for %s ID %s", model_name, record_id + ) + if model_name == "product.template": + ok = self.job_product_metadata_fetch(record_id) + overall = overall and bool(ok) + else: + _logger.error("Unsupported model for fetch: %s", model_name) + overall = False + return overall + + def job_product_metadata_fetch(self, product_tmpl_id): + """Fetch metadata for a product.template record.""" + self.ensure_one() + model_name = "product.template" + product = self.env[model_name].browse(product_tmpl_id) + if not product.exists(): + _logger.error("Record %s with ID %s not found", model_name, product_tmpl_id) + return False + + # Validate inputs + if not getattr(product, "saleor_product_id", False): + msg = f"Product {product.id} is not synced with Saleor yet" + _logger.error(msg) + product.message_post( + body=msg, message_type="comment", subtype_xmlid="mail.mt_note" + ) + return False + + try: + client = self._get_client() + self._refresh_token(client) + + # GraphQL query + query = """ + query Product($id: ID!) { + product(id: $id) { + metadata { key value } + privateMetadata { key value } + } + } + """ + variables = {"id": product.saleor_product_id} + result = client.graphql(query, variables) + + if not result or not result.get("product"): + raise Exception("Invalid response from Saleor") + + product_data = result["product"] + metadata = product_data.get("metadata", []) + private_metadata = product_data.get("privateMetadata", []) + + # Clear existing lines + product.saleor_product_metadata_line_ids.unlink() + if hasattr(product, "saleor_product_private_metadata_line_ids"): + product.saleor_product_private_metadata_line_ids.unlink() + + # Build new lines + metadata_lines = [ + (0, 0, {"key": it.get("key", ""), "value": it.get("value", "")}) + for it in metadata + ] + update_vals = {"saleor_product_metadata_line_ids": metadata_lines} + if hasattr(product, "saleor_product_private_metadata_line_ids"): + private_lines = [ + (0, 0, {"key": it.get("key", ""), "value": it.get("value", "")}) + for it in private_metadata + ] + update_vals["saleor_product_private_metadata_line_ids"] = private_lines + + # Write and mark status + product.write({**update_vals, "is_metadata_fetched": True}) + + # Notify + self.post_fetch_success(product, "product") + _logger.info( + "Fetched metadata from Saleor for product_tmpl %s via account %s", + product.id, + self.name, + ) + return True + + except Exception as e: + _logger.error( + "Error fetching metadata from Saleor for %s ID %s: %s", + model_name, + product_tmpl_id, + str(e), + exc_info=True, + ) + product.message_post( + body=f"Error fetching metadata from Saleor: {str(e)}", + message_type="comment", + subtype_xmlid="mail.mt_note", + ) + return False + + def job_saleor_sync(self, object_type, record_id, payload): + """Generic Saleor sync job for supported object types. + + object_type: 'collection' | 'product' + record_id: record id of the corresponding Odoo model + payload: dict prepared from the record + """ + self.ensure_one() + if not self.active: + _logger.debug( + "Saleor %s job skipped for record %s on inactive account %s", + object_type, + record_id, + self.name, + ) + return True + + client = self._get_client() + ( + rec, + slug, + payload, + existing, + img_bytes, + filename, + content_type, + ) = self._job_saleor_prepare_sync(client, object_type, record_id, payload) + + saleor_id = self._job_saleor_upsert_record( + client, + object_type, + rec, + payload, + existing, + img_bytes, + filename, + content_type, + ) + + img_uploaded = False + img_uploaded = self._job_saleor_post_upsert( + client, + object_type, + rec, + payload, + saleor_id, + slug, + img_bytes, + filename, + content_type, + ) + + self._post_success( + rec, + object_type, + slug, + payload, + saleor_id, + img_uploaded=img_uploaded, + img_bytes=img_bytes, + ) + return True + + def _job_saleor_prepare_sync(self, client, object_type, record_id, payload): + model, get_by_slug, _, _ = self._handlers_for(client, object_type) + rec = self.env[model].browse(record_id) + + slug, payload = self._ensure_slug_in_payload(rec, payload) + self._refresh_token(client) + existing = get_by_slug(slug) if slug else None + + img_bytes, filename, content_type = self._prepare_image(rec) + if object_type == "product" and getattr(rec, "categ_id", False): + payload = self._inject_product_category(client, rec, payload) + if object_type == "product": + payload = self._inject_product_tax_class(client, rec, payload) + + return rec, slug, payload, existing, img_bytes, filename, content_type + + def _job_saleor_upsert_record( + self, + client, + object_type, + rec, + payload, + existing, + img_bytes, + filename, + content_type, + ): + if existing and existing.get("id"): + saleor_id = self._update_existing_record( + client, + object_type, + rec, + payload, + existing, + img_bytes, + filename, + content_type, + ) + else: + saleor_id = self._create_new_record( + client, + object_type, + rec, + payload, + img_bytes, + filename, + content_type, + ) + self._persist_saleor_id(rec, object_type, saleor_id) + return saleor_id + + def _job_saleor_post_upsert( + self, + client, + object_type, + rec, + payload, + saleor_id, + slug, + img_bytes, + filename, + content_type, + ): + img_uploaded = False + + if object_type == "collection" and saleor_id: + self._job_saleor_sync_collection_channels(client, rec, saleor_id) + + if object_type == "product" and saleor_id: + self._job_saleor_sync_product_channels(client, rec, saleor_id) + img_uploaded = self._job_saleor_process_product_images( + client, + rec, + payload, + saleor_id, + img_bytes, + filename, + content_type, + ) + self._job_saleor_auto_sync_single_variant(rec) + + if "_saleor_current_image_ids" in payload: + del payload["_saleor_current_image_ids"] + + self._job_saleor_persist_slug(object_type, rec, slug) + return img_uploaded + + def _job_saleor_sync_collection_channels(self, client, rec, saleor_id): + try: + self._sync_collection_channel_listings(client, rec, saleor_id) + except Exception as e: + _logger.warning( + "Failed to sync collection channel listings for %s: %s", + rec.display_name, + e, + ) + + def _job_saleor_sync_product_channels(self, client, rec, saleor_id): + try: + self._sync_product_channel_listings(client, rec, saleor_id) + except Exception as e: + _logger.warning( + "Failed to sync product channel listings for %s: %s", + rec.display_name, + e, + ) + + def _job_saleor_process_product_images( + self, + client, + rec, + payload, + saleor_id, + img_bytes, + filename, + content_type, + ): + img_uploaded = False + if img_bytes and filename and content_type: + img_uploaded = self._process_image_upload( + client=client, + object_type="product", + saleor_id=saleor_id, + img_bytes=img_bytes, + filename=filename, + content_type=content_type, + ) + + self._process_extra_images(client, "product", saleor_id, rec) + + if getattr(rec, "saleor_collection_id", False): + self._ensure_collection_and_add_product(client, rec, saleor_id) + + self._handle_deleted_images(client, rec, payload) + return img_uploaded + + def _job_saleor_auto_sync_single_variant(self, rec): + try: + tmpl = rec + variants = getattr(tmpl, "product_variant_ids", False) + if variants and len(variants) == 1 and tmpl.saleor_product_id: + variant = variants[0] + if variant.default_code: + variant_payload = variant._saleor_prepare_variant_payload( + tmpl.saleor_product_id + ) + if hasattr(self, "with_delay"): + self.with_delay().job_product_variant_sync( + variant.id, + tmpl.saleor_product_id, + variant_payload, + ) + else: + self.job_product_variant_sync( + variant.id, + tmpl.saleor_product_id, + variant_payload, + ) + except Exception as e: + _logger.warning( + "Failed to auto-sync single variant for product %s: %s", + getattr(rec, "display_name", getattr(rec, "id", "-")), + e, + ) + + def _job_saleor_persist_slug(self, object_type, rec, slug): + try: + if ( + object_type == "product" + and slug + and hasattr(rec, "saleor_slug") + and rec.saleor_slug != slug + ): + rec.write({"saleor_slug": slug}) + except Exception as e: + _logger.warning( + "Failed to persist slug '%s' on %s[%s]: %s", + slug, + object_type, + getattr(rec, "id", "-"), + e, + ) + + def _sync_collection_channel_listings( + self, client, collection_rec, saleor_collection_id + ): + """Ensure the collection has channel listings matching Odoo channels. + + - addChannels: channels present in Odoo but missing in Saleor + - removeChannels: channels present in Saleor but not in Odoo + Keeps unchanged listings intact. + Requires channels to be synced to Saleor first (have saleor_channel_id). + """ + channels = getattr(collection_rec, "channel_ids", False) + if not channels: + # If no channels in Odoo, remove all current listings + self._refresh_token(client) + current = client.collection_channel_listings(saleor_collection_id) or [] + current_ids = [ + item.get("channel", {}).get("id") + for item in current + if item.get("channel") + ] + if current_ids: + client.collection_channel_listing_update( + saleor_collection_id, + add_channels=[], + remove_channels=current_ids, + ) + return True + missing = channels.filtered(lambda ch: not ch.saleor_channel_id) + if missing: + names = ", ".join(missing.mapped("display_name")) + raise UserError( + self.env._( + "Some channels on this collection" + " are not synced to Saleor yet: %s.\n" + "Please sync these channels first.", + names, + ) + ) + # Build desired Saleor channel IDs from Odoo + desired_ids = {ch.saleor_channel_id for ch in channels} + + # Fetch current listings from Saleor + self._refresh_token(client) + current = client.collection_channel_listings(saleor_collection_id) or [] + current_ids = { + item.get("channel", {}).get("id") for item in current if item.get("channel") + } + + # Compute deltas + to_add_ids = sorted(list(desired_ids - current_ids)) + to_remove_ids = sorted(list(current_ids - desired_ids)) + + add_channels = [ + { + "channelId": ch_id, + # Default behavior: publish + "isPublished": True, + } + for ch_id in to_add_ids + ] + + if add_channels or to_remove_ids: + client.collection_channel_listing_update( + saleor_collection_id, + add_channels=add_channels, + remove_channels=to_remove_ids, + ) + return True + + def _sync_product_channel_listings(self, client, product_rec, saleor_product_id): + """Delta-sync product channel listings to match Odoo template channels. + + - updateChannels: channels in Odoo but missing in Saleor + - removeChannels: channels present in Saleor but not in Odoo + Keeps unchanged listings intact. Requires channels to have saleor_channel_id. + """ + channels = getattr(product_rec, "channel_ids", False) + # Build desired IDs set + desired_ids = set() + if channels: + missing = channels.filtered(lambda ch: not ch.saleor_channel_id) + if missing: + names = ", ".join(missing.mapped("display_name")) + raise UserError( + self.env._( + "Some channels on this product" + " are not synced to Saleor yet: %s.\n" + "Please sync these channels first.", + names, + ) + ) + desired_ids = {ch.saleor_channel_id for ch in channels} + + # Fetch current listings from Saleor + self._refresh_token(client) + current = client.product_channel_listings(saleor_product_id) or [] + current_ids = { + item.get("channel", {}).get("id") for item in current if item.get("channel") + } + + to_add_ids = sorted(list(desired_ids - current_ids)) + to_remove_ids = sorted(list(current_ids - desired_ids)) + + update_channels = [ + { + "channelId": ch_id, + "isPublished": True, + "isAvailableForPurchase": True, + "visibleInListings": True, + } + for ch_id in to_add_ids + ] + + if update_channels or to_remove_ids: + client.product_channel_listing_update( + saleor_product_id, + update_channels=update_channels, + remove_channels=to_remove_ids, + ) + return True diff --git a/sale_saleor/models/saleor_attribute_meta_line.py b/sale_saleor/models/saleor_attribute_meta_line.py new file mode 100644 index 0000000000..bfec75a6b7 --- /dev/null +++ b/sale_saleor/models/saleor_attribute_meta_line.py @@ -0,0 +1,32 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleorAttributeMetaLine(models.Model): + _name = "saleor.attribute.meta.line" + _description = "Saleor Attribute Metadata Line" + _order = "id" + + attribute_id = fields.Many2one( + "product.attribute", + required=True, + ondelete="cascade", + ) + key = fields.Char(required=True) + value = fields.Char(required=True) + + +class SaleorAttributePrivateMetaLine(models.Model): + _name = "saleor.attribute.private.meta.line" + _description = "Saleor Attribute Private Metadata Line" + _order = "id" + + attribute_id = fields.Many2one( + "product.attribute", + required=True, + ondelete="cascade", + ) + key = fields.Char(required=True) + value = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_category_meta_line.py b/sale_saleor/models/saleor_category_meta_line.py new file mode 100644 index 0000000000..b3ec7f8f11 --- /dev/null +++ b/sale_saleor/models/saleor_category_meta_line.py @@ -0,0 +1,24 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleorCategoryMetaLine(models.Model): + _name = "saleor.category.meta.line" + _description = "Saleor Category Metadata Line" + _order = "id" + + category_id = fields.Many2one("product.category", required=True, ondelete="cascade") + key = fields.Char(required=True) + value = fields.Char(required=True) + + +class SaleorCategoryPrivateMetaLine(models.Model): + _name = "saleor.category.private.meta.line" + _description = "Saleor Category Private Metadata Line" + _order = "id" + + category_id = fields.Many2one("product.category", required=True, ondelete="cascade") + key = fields.Char(required=True) + value = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_channel.py b/sale_saleor/models/saleor_channel.py new file mode 100644 index 0000000000..f16f750cdf --- /dev/null +++ b/sale_saleor/models/saleor_channel.py @@ -0,0 +1,244 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from ..helpers import get_active_saleor_account + +_logger = logging.getLogger(__name__) + + +class SaleorChannel(models.Model): + _name = "saleor.channel" + _description = "Saleor Channel" + _inherit = ["mail.thread", "mail.activity.mixin"] + + name = fields.Char(required=True, tracking=True) + slug = fields.Char( + required=True, tracking=True, help="Unique channel slug in Saleor" + ) + status = fields.Selection( + selection=[("inactive", "Inactive"), ("active", "Active")], + default="inactive", + required=True, + tracking=True, + ) + currency_id = fields.Many2one("res.currency", string="Currency", required=True) + default_country_id = fields.Many2one( + "res.country", string="Default Country", required=True + ) + shipping_zone_ids = fields.Many2many( + "saleor.shipping.zone", + "saleor_channel_shipping_zone_rel", + "channel_id", + "shipping_zone_id", + string="Shipping Zone", + help="Assign one or more Saleor shipping zones to this channel.", + ) + + warehouse_ids = fields.Many2many( + "stock.warehouse", + "saleor_channel_warehouse_rel", + "channel_id", + "warehouse_id", + string="Warehouses", + domain=[("is_saleor_warehouse", "=", True)], + help="Only warehouses marked as Saleor warehouses are allowed.", + ) + location_ids = fields.Many2many( + "stock.location", + "saleor_channel_location_rel", + "channel_id", + "location_id", + string="Locations", + domain=[("is_saleor_warehouse", "=", True), ("usage", "=", "internal")], + help="Only internal locations marked as Saleor warehouses are allowed.", + ) + + saleor_channel_id = fields.Char(readonly=True, copy=False, index=True) + entered_prices = fields.Selection( + selection=[ + ("with_tax", "Product prices are entered with tax"), + ("without_tax", "Product prices are entered without tax"), + ], + default="without_tax", + ) + abandoned_cart_delay_hours = fields.Float( + string="Abandoned Cart Delay (Hours)", + help=( + "For orders imported from this Saleor channel, quotations that stay in " + "draft/sent state longer than this delay will be marked as abandoned." + ), + default=24.0, + ) + + _sql_constraints = [ + ("slug_unique", "unique(slug)", "Channel slug must be unique."), + ] + + @api.model + def cron_mark_abandoned_saleor_orders(self): + """Mark old Saleor quotations as abandoned based on channel delay.""" + SaleOrder = self.env["sale.order"].sudo() + now = fields.Datetime.now() + # Candidate orders: Saleor-origin quotations that are still open. + orders = SaleOrder.search( + [ + ("saleor_order_id", "!=", False), + ("state", "in", ("draft")), + ("is_abandoned", "=", False), + ] + ) + if not orders: + return + + to_abandon = self.env["sale.order"].sudo() + for order in orders: + channel = getattr(order, "saleor_channel_id", False) + delay = getattr(channel, "abandoned_cart_delay_hours", 0.0) or 0.0 + # Skip if no delay configured on the channel + if not delay: + continue + cutoff = now - relativedelta(hours=delay) + # Use date_order if available, otherwise fall back to create_date + order_date = order.date_order or order.create_date + if order_date and order_date <= cutoff: + to_abandon |= order + + if to_abandon: + _logger.info( + "Marking %s Saleor quotations as abandoned (per-channel delay)", + len(to_abandon), + ) + to_abandon.write({"is_abandoned": True}) + for order in to_abandon: + channel = getattr(order, "saleor_channel_id", False) + delay = getattr(channel, "abandoned_cart_delay_hours", 0.0) or 0.0 + try: + order.message_post( + body=self.env._( + "This quotation has been marked as abandoned by the " + "Saleor connector cron (delay=%s hours).", + delay, + ) + ) + except Exception: + # Never block the cron on chatter issues + _logger.debug( + "Failed to post abandoned-cart message on sale.order %s", + order.id, + ) + + def _prepare_saleor_payload(self, include_currency=True): + self.ensure_one() + payload = { + "name": self.name, + "slug": self.slug, + "isActive": self.status == "active", + "defaultCountry": (self.default_country_id and self.default_country_id.code) + or None, + } + if include_currency: + payload["currencyCode"] = self.currency_id.name + + # Warehouses/Locations: add addWarehouses with Saleor IDs + selected_wh = self.warehouse_ids or self.env["stock.warehouse"] + selected_loc = self.location_ids or self.env["stock.location"] + wh_ids = [ + wh.saleor_warehouse_id for wh in selected_wh if wh.saleor_warehouse_id + ] + loc_ids = [ + loc.saleor_warehouse_id for loc in selected_loc if loc.saleor_warehouse_id + ] + # Validate that every selected has a Saleor ID + missing = [] + missing += [wh.display_name for wh in selected_wh if not wh.saleor_warehouse_id] + missing += [ + loc.display_name for loc in selected_loc if not loc.saleor_warehouse_id + ] + if missing: + raise UserError( + self.env._( + "Please sync the following warehouses/locations" + " to Saleor first: %s", + ", ".join(missing), + ) + ) + add_warehouses = [*wh_ids, *loc_ids] + if add_warehouses: + payload["addWarehouses"] = add_warehouses + return payload + + def action_sync_to_saleor(self): + """Sync channels to Saleor.""" + account = get_active_saleor_account(self.env, raise_if_missing=True) + if len(self) == 1: + payload = self._prepare_saleor_payload( + include_currency=not bool(self.saleor_channel_id) + ) + account.job_channel_sync(self.id, payload) + return True + + # Multiple: batch + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for rec in self: + payload = rec._prepare_saleor_payload( + include_currency=not bool(rec.saleor_channel_id) + ) + items.append({"id": rec.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_channel_sync_batch(chunk) + else: + account.job_channel_sync_batch(chunk) + return True + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + # Auto-sync all records created as active + active_records = records.filtered(lambda r: r.status == "active") + if active_records: + active_records.action_sync_to_saleor() + return records + + def write(self, vals): + # Disallow changing currency after the channel has been synced to Saleor + if "currency_id" in vals: + for rec in self: + if rec.saleor_channel_id and not self.env.context.get( + "bypass_currency_lock" + ): + raise UserError( + self.env._( + "Currency cannot be changed after the channel" + " has been synced to Saleor." + ) + ) + res = super().write(vals) + # If status toggled to active, sync now; + # or if already active and key fields changed + fields_affecting = { + "name", + "slug", + "status", + "currency_id", + "default_country_id", + } + for rec in self: + status_target = vals.get("status") + should_sync = False + if status_target == "active": + should_sync = True + elif rec.status == "active" and fields_affecting.intersection(vals.keys()): + should_sync = True + if should_sync: + rec.action_sync_to_saleor() + return res diff --git a/sale_saleor/models/saleor_collection_meta_line.py b/sale_saleor/models/saleor_collection_meta_line.py new file mode 100644 index 0000000000..c5093ece17 --- /dev/null +++ b/sale_saleor/models/saleor_collection_meta_line.py @@ -0,0 +1,28 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleorCollectionMetaLine(models.Model): + _name = "saleor.collection.meta.line" + _description = "Saleor Collection Metadata Line" + _order = "id" + + collection_id = fields.Many2one( + "product.collection", required=True, ondelete="cascade" + ) + key = fields.Char(required=True) + value = fields.Char(required=True) + + +class SaleorCollectionPrivateMetaLine(models.Model): + _name = "saleor.collection.private.meta.line" + _description = "Saleor Collection Private Metadata Line" + _order = "id" + + collection_id = fields.Many2one( + "product.collection", required=True, ondelete="cascade" + ) + key = fields.Char(required=True) + value = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_gift_card.py b/sale_saleor/models/saleor_gift_card.py new file mode 100644 index 0000000000..388a69e9c5 --- /dev/null +++ b/sale_saleor/models/saleor_gift_card.py @@ -0,0 +1,208 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from datetime import timedelta + +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from ..helpers import get_active_saleor_account + + +class SaleorGiftCard(models.Model): + _name = "saleor.giftcard" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Saleor Gift Card" + + name = fields.Char(readonly=True, copy=False) + amount = fields.Float( + required=True, + default=1, + tracking=True, + ) + currency_id = fields.Many2one( + "res.currency", + string="Currency", + help="Currency allowed must belong to any Saleor channel.", + ) + available_currency_ids = fields.Many2many( + "res.currency", + compute="_compute_available_currencies", + ) + tag_ids = fields.Many2many("saleor.giftcard.tag", string="Tags") + send_to_customer = fields.Boolean(string="Send gift card to customer") + partner_id = fields.Many2one( + "res.partner", + string="Customer", + help="Selected customer will be sent the generated gift card code." + " Someone else can redeem the gift card code." + "Gift card will be assigned to account which redeemed the code.", + ) + channel_id = fields.Many2one( + "saleor.channel", + string="Channel", + help="Customer will be sent the gift card code via this channels email address", + ) + set_expiry_date = fields.Boolean(string="Set gift card expiry date") + expiry_type = fields.Selection( + selection=[ + ("duration", "Expires in"), + ("exact", "Exact date"), + ], + default="duration", + ) + expiry_duration = fields.Integer( + string="Duration", + help="Number of months until expiry", + default=12, + ) + expiry_unit = fields.Selection( + selection=[ + ("years", "Years After Issue"), + ("months", "Months After Issue"), + ("weeks", "Weeks After Issue"), + ("days", "Days After Issue"), + ], + default="months", + required=True, + ) + expiry_date = fields.Date( + string="Exact expiry date", + default=fields.Date.context_today, + ) + note = fields.Text( + help="Why was this gift card issued." + " This note will not be shown to the customer." + " Note will be stored in gift card history.", + ) + requires_activation = fields.Boolean( + string="Requires activation", + default=True, + help="Gift card must be activated by staff before use", + ) + status = fields.Selection( + selection=[ + ("disabled", "Disabled"), + ("active", "Active"), + ("expired", "Expired"), + ], + required=True, + tracking=True, + default="disabled", + ) + code = fields.Char() + saleor_giftcard_id = fields.Char(readonly=True, copy=False, index=True) + saleor_metadata_line_ids = fields.One2many( + "saleor.giftcard.meta.line", + "giftcard_id", + string="Meta Lines", + ) + saleor_private_metadata_line_ids = fields.One2many( + "saleor.giftcard.private.meta.line", + "giftcard_id", + string="Private Meta Lines", + ) + + @api.depends() + def _compute_available_currencies(self): + channels = self.env["saleor.channel"].search([]) + currencies = channels.mapped("currency_id").ids + for rec in self: + rec.available_currency_ids = [(6, 0, currencies)] + + @api.onchange("channel_id") + def _onchange_channel_set_currency(self): + for rec in self: + if rec.channel_id and rec.channel_id.currency_id: + rec.currency_id = rec.channel_id.currency_id + + def action_activate_giftcard(self): + """Activate and sync gift cards to Saleor via account job.""" + account = get_active_saleor_account(self.env, raise_if_missing=True) + use_delay = len(self) > 1 and hasattr(account, "with_delay") + for giftcard in self: + giftcard.status = "active" + if not giftcard.currency_id: + raise UserError(self.env._("Please set a currency before activation.")) + payload = giftcard._saleor_prepare_payload() + if use_delay: + account.with_delay().job_giftcard_activate(giftcard.id, payload) + else: + account.job_giftcard_activate(giftcard.id, payload) + + def action_deactivate_giftcard(self): + for giftcard in self: + giftcard.status = "disabled" + + @api.onchange("expiry_duration", "expiry_unit") + def _onchange_compute_expiration_date(self): + for rec in self: + if rec.expiry_date and rec.expiry_type == "duration": + today = fields.Date.context_today(self) + duration = rec.expiry_duration or 0 + unit = rec.expiry_unit + exp = None + if duration > 0: + if unit == "days": + exp = today + timedelta(days=duration) + elif unit == "weeks": + exp = today + timedelta(weeks=duration) + elif unit == "months": + exp = today + relativedelta(months=+duration) + elif unit == "years": + exp = today + relativedelta(years=+duration) + rec.expiry_date = exp + + def _saleor_prepare_payload(self): + """Build GiftCardCreateInput payload for Saleor from this record.""" + self.ensure_one() + balance = { + "amount": float(self.amount or 0.0), + "currency": self.currency_id.name, + } + user_email = ( + self.partner_id.email if self.send_to_customer and self.partner_id else None + ) + channel_id = ( + getattr(self.channel_id, "saleor_channel_id", None) + if self.send_to_customer + else None + ) + + expiry_date = None + if self.set_expiry_date: + # Only use expiry_date field (no exact_date support) + expiry_date = self.expiry_date + + payload = { + "isActive": True, + "balance": balance, + "note": self.note or "", + } + # Saleor expects addTags in GiftCardCreateInput (not `tags`) + tag_names = [t.name for t in self.tag_ids] if self.tag_ids else [] + if tag_names: + payload["addTags"] = tag_names + # Public metadata + if self.saleor_metadata_line_ids: + payload["metadata"] = [ + {"key": line.key, "value": line.value} + for line in self.saleor_metadata_line_ids + if line.key + ] + # Private metadata + if self.saleor_private_metadata_line_ids: + payload["privateMetadata"] = [ + {"key": line.key, "value": line.value} + for line in self.saleor_private_metadata_line_ids + if line.key + ] + if user_email: + payload["userEmail"] = user_email + if channel_id: + payload["channel"] = channel_id + if expiry_date: + payload["expiryDate"] = fields.Date.to_string(expiry_date) + return payload diff --git a/sale_saleor/models/saleor_gift_card_tag.py b/sale_saleor/models/saleor_gift_card_tag.py new file mode 100644 index 0000000000..437a5c2b4f --- /dev/null +++ b/sale_saleor/models/saleor_gift_card_tag.py @@ -0,0 +1,11 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleorGiftCardTag(models.Model): + _name = "saleor.giftcard.tag" + _description = "Saleor Gift Card Tag" + + name = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_giftcard_meta_line.py b/sale_saleor/models/saleor_giftcard_meta_line.py new file mode 100644 index 0000000000..c38da37a6f --- /dev/null +++ b/sale_saleor/models/saleor_giftcard_meta_line.py @@ -0,0 +1,32 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleorGiftCardMetaLine(models.Model): + _name = "saleor.giftcard.meta.line" + _description = "Saleor GiftCard Metadata Line" + _order = "id" + + giftcard_id = fields.Many2one( + "saleor.giftcard", + required=True, + ondelete="cascade", + ) + key = fields.Char(required=True) + value = fields.Char(required=True) + + +class SaleorGiftCardPrivateMetaLine(models.Model): + _name = "saleor.giftcard.private.meta.line" + _description = "Saleor GiftCard Private Metadata Line" + _order = "id" + + giftcard_id = fields.Many2one( + "saleor.giftcard", + required=True, + ondelete="cascade", + ) + key = fields.Char(required=True) + value = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_product_meta_line.py b/sale_saleor/models/saleor_product_meta_line.py new file mode 100644 index 0000000000..db91e3f782 --- /dev/null +++ b/sale_saleor/models/saleor_product_meta_line.py @@ -0,0 +1,28 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleorProductMetaLine(models.Model): + _name = "saleor.product.meta.line" + _description = "Saleor Product Metadata Line" + _order = "id" + + product_tmpl_id = fields.Many2one( + "product.template", required=True, ondelete="cascade" + ) + key = fields.Char(required=True) + value = fields.Char(required=True) + + +class SaleorProductPrivateMetaLine(models.Model): + _name = "saleor.product.private.meta.line" + _description = "Saleor Product Private Metadata Line" + _order = "id" + + product_tmpl_id = fields.Many2one( + "product.template", required=True, ondelete="cascade" + ) + key = fields.Char(required=True) + value = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_product_type.py b/sale_saleor/models/saleor_product_type.py new file mode 100644 index 0000000000..50579be5e7 --- /dev/null +++ b/sale_saleor/models/saleor_product_type.py @@ -0,0 +1,80 @@ +from odoo import fields, models + +from ..helpers import get_active_saleor_account + + +class SaleorProductType(models.Model): + _name = "saleor.product.type" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Saleor Product Type" + + name = fields.Char(required=True) + slug = fields.Char() + kind = fields.Selection( + selection=[ + ("normal", "Regular product type"), + ("gift_card", "Gift card product type"), + ], + required=True, + default="normal", + ) + tax_id = fields.Many2one( + "account.tax", + domain=[("type_tax_use", "=", "sale")], + ) + product_attribute_ids = fields.Many2many( + "product.attribute", + "saleor_product_type_product_attribute_rel", + "product_type_id", + "attribute_id", + string="Product Attributes", + domain=[("saleor_attribute_id", "!=", False)], + ) + use_variant_attributes = fields.Boolean( + string="Product type uses Variant Attributes", + default=False, + help="Product type uses Variant Attributes", + ) + variant_attribute_ids = fields.Many2many( + "product.attribute", + "saleor_product_type_variant_attribute_rel", + "product_type_id", + "variant_attribute_id", + string="Variant Attributes", + domain=[("saleor_attribute_id", "!=", False)], + ) + is_shipping = fields.Boolean( + string="Is This Product Shippable?", + default=False, + ) + weight = fields.Float( + default=0, + ) + metadata_line = fields.One2many( + "product.type.meta.line", + "product_type_id", + ) + private_metadata_line = fields.One2many( + "product.type.private.meta.line", + "product_type_id", + ) + saleor_product_type_id = fields.Char(copy=False) + + _sql_constraints = [ + ( + "saleor_product_type_slug_unique", + "unique(slug)", + "Saleor slug must be unique on product types.", + ) + ] + + def write(self, vals): + res = super().write(vals) + account = get_active_saleor_account(self.env, raise_if_missing=False) + if account: + for rec in self: + try: + account.sync_product_type_from_ptype(rec) + except Exception: + continue + return res diff --git a/sale_saleor/models/saleor_shipping_meta_line.py b/sale_saleor/models/saleor_shipping_meta_line.py new file mode 100644 index 0000000000..41ee33f175 --- /dev/null +++ b/sale_saleor/models/saleor_shipping_meta_line.py @@ -0,0 +1,24 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ShippingMethodMetaLine(models.Model): + _name = "shipping.method.meta.line" + _description = "Shipping Method Metadata Line" + _order = "id" + + carrier_id = fields.Many2one("delivery.carrier", required=True, ondelete="cascade") + key = fields.Char(required=True) + value = fields.Char(required=True) + + +class ShippingMethodPrivateMetaLine(models.Model): + _name = "shipping.method.private.meta.line" + _description = "Shipping Method Private Metadata Line" + _order = "id" + + carrier_id = fields.Many2one("delivery.carrier", required=True, ondelete="cascade") + key = fields.Char(required=True) + value = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_shipping_order_value_line.py b/sale_saleor/models/saleor_shipping_order_value_line.py new file mode 100644 index 0000000000..d75e56d52d --- /dev/null +++ b/sale_saleor/models/saleor_shipping_order_value_line.py @@ -0,0 +1,34 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class OrderValueLine(models.Model): + _name = "order.value.line" + _description = "Order Value Line" + _order = "id" + + channel_id = fields.Many2one( + "saleor.channel", + required=True, + ondelete="restrict", + help="Channel must be one of the channels assigned" + " to the related shipping zones of this carrier.", + ) + carrier_id = fields.Many2one("delivery.carrier", required=True, ondelete="cascade") + display_unit = fields.Char( + compute="_compute_display_unit", string="Unit", readonly=True + ) + min_value = fields.Float() + max_value = fields.Float() + + @api.depends("carrier_id") + def _compute_display_unit(self): + for line in self: + if line.carrier_id.shipping_method_type == "price": + line.display_unit = line.carrier_id.currency_id.name or "" + elif line.carrier_id.shipping_method_type == "weight": + line.display_unit = "KG" + else: + line.display_unit = "" diff --git a/sale_saleor/models/saleor_shipping_postal_code_range.py b/sale_saleor/models/saleor_shipping_postal_code_range.py new file mode 100644 index 0000000000..794031d6a2 --- /dev/null +++ b/sale_saleor/models/saleor_shipping_postal_code_range.py @@ -0,0 +1,14 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class PostalCodeRange(models.Model): + _name = "postal.code.range" + _description = "Postal Code Range" + _order = "id" + + carrier_id = fields.Many2one("delivery.carrier", required=True, ondelete="cascade") + start_zip = fields.Char(string="Postal Code Start", required=True) + end_zip = fields.Char(string="Postal Code End", required=True) diff --git a/sale_saleor/models/saleor_shipping_pricing_line.py b/sale_saleor/models/saleor_shipping_pricing_line.py new file mode 100644 index 0000000000..b0f6c0e7a9 --- /dev/null +++ b/sale_saleor/models/saleor_shipping_pricing_line.py @@ -0,0 +1,21 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ShippingPricingLine(models.Model): + _name = "shipping.pricing.line" + _description = "Shipping Pricing Line" + _order = "id" + + carrier_id = fields.Many2one("delivery.carrier", required=True, ondelete="cascade") + price = fields.Float(required=True) + channel_id = fields.Many2one("saleor.channel", required=True, ondelete="cascade") + currency_id = fields.Many2one( + "res.currency", + string="Currency", + related="channel_id.currency_id", + store=True, + readonly=True, + ) diff --git a/sale_saleor/models/saleor_shipping_zone.py b/sale_saleor/models/saleor_shipping_zone.py new file mode 100644 index 0000000000..e9f15751c4 --- /dev/null +++ b/sale_saleor/models/saleor_shipping_zone.py @@ -0,0 +1,180 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from ..helpers import get_active_saleor_account + +_logger = logging.getLogger(__name__) + + +class SaleorShippingZone(models.Model): + _name = "saleor.shipping.zone" + _description = "Saleor Shipping Zone" + _inherit = ["mail.thread", "mail.activity.mixin"] + + saleor_id = fields.Char(string="Saleor ID", copy=False, index=True) + name = fields.Char(required=True, tracking=True) + description = fields.Text() + + country_ids = fields.Many2many( + "res.country", + "saleor_shipping_zone_country_rel", + "zone_id", + "country_id", + string="Assigned Countries", + ) + + channel_ids = fields.Many2many( + "saleor.channel", + "saleor_channel_shipping_zone_rel", + "shipping_zone_id", + "channel_id", + string="Channels", + ) + + warehouse_ids = fields.Many2many( + "stock.warehouse", + "saleor_shipping_zone_warehouse_rel", + "zone_id", + "warehouse_id", + string="Warehouses", + domain=[("is_saleor_warehouse", "=", True)], + help="Only warehouses marked as Saleor warehouses are allowed.", + ) + location_ids = fields.Many2many( + "stock.location", + "saleor_shipping_zone_location_rel", + "zone_id", + "location_id", + string="Locations", + domain=[("is_saleor_warehouse", "=", True), ("usage", "=", "internal")], + help="Only internal locations marked as Saleor warehouses are allowed.", + ) + + shipping_method_ids = fields.Many2many( + "delivery.carrier", + "saleor_shipping_zone_shipping_method_rel", + "shipping_zone_id", + "shipping_method_id", + string="Shipping Methods", + ) + + # Metadata for Shipping Zone + shipping_zone_metadata_line_ids = fields.One2many( + "shipping.zone.meta.line", "zone_id", string="Metadata" + ) + shipping_zone_private_metadata_line_ids = fields.One2many( + "shipping.zone.private.meta.line", "zone_id", string="Private Metadata" + ) + + def _update_carriers_zone_link(self): + """Force-link carriers' zone_id to this zone when related here.""" + for zone in self: + carriers = zone.shipping_method_ids.filtered( + lambda c: c.delivery_type == "saleor" + ) + if carriers: + carriers_to_update = carriers.filtered( + lambda c, zid=zone.id: c.zone_id.id != zid + ) + if carriers_to_update: + carriers_to_update.write({"zone_id": zone.id}) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + zones_with_methods = records.filtered( + lambda zone: bool(zone.shipping_method_ids) + ) + if zones_with_methods: + zones_with_methods._update_carriers_zone_link() + return records + + def write(self, vals): + res = super().write(vals) + # If shipping methods changed, align carriers' zone_id + if vals.get("shipping_method_ids"): + self._update_carriers_zone_link() + return res + + def _saleor_shipping_zone_prepare_payload(self): + """Build payload for Saleor shipping zone create/update.""" + self.ensure_one() + payload = { + "name": self.name, + "description": self.description or "", + "countries": [c.code for c in self.country_ids], + "addChannels": [ + c.saleor_channel_id for c in self.channel_ids if c.saleor_channel_id + ], + } + + # Warehouses/Locations: add addWarehouses with Saleor IDs + selected_wh = self.warehouse_ids or self.env["stock.warehouse"] + selected_loc = self.location_ids or self.env["stock.location"] + wh_ids = [ + wh.saleor_warehouse_id for wh in selected_wh if wh.saleor_warehouse_id + ] + loc_ids = [ + loc.saleor_warehouse_id for loc in selected_loc if loc.saleor_warehouse_id + ] + # Validate that every selected has a Saleor ID + missing = [] + missing += [wh.display_name for wh in selected_wh if not wh.saleor_warehouse_id] + missing += [ + loc.display_name for loc in selected_loc if not loc.saleor_warehouse_id + ] + if missing: + raise UserError( + self.env._( + "Please sync the following warehouses/locations" + " to Saleor first: %s", + ", ".join(missing), + ) + ) + add_warehouses = [*wh_ids, *loc_ids] + if add_warehouses: + payload["addWarehouses"] = add_warehouses + + # Metadata and private metadata + meta = [ + {"key": line.key, "value": line.value} + for line in (self.shipping_zone_metadata_line_ids or []) + ] + priv = [ + {"key": line.key, "value": line.value} + for line in (self.shipping_zone_private_metadata_line_ids or []) + ] + if meta: + payload["metadata"] = meta + if priv: + payload["privateMetadata"] = priv + + return payload + + def action_saleor_shipping_zone_sync(self): + """Sync shipping zones to Saleor (direct if single, queue if multi).""" + account = get_active_saleor_account(self.env, raise_if_missing=True) + + if len(self) == 1: + zone = self + payload = zone._saleor_shipping_zone_prepare_payload() + account.job_shipping_zone(zone.id, payload) + else: + # Multiple: batch + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for zone in self: + payload = zone._saleor_shipping_zone_prepare_payload() + items.append({"id": zone.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_shipping_zone_batch(chunk) + else: + account.job_shipping_zone_batch(chunk) + return True diff --git a/sale_saleor/models/saleor_shipping_zone_meta_line.py b/sale_saleor/models/saleor_shipping_zone_meta_line.py new file mode 100644 index 0000000000..bbafa3ade4 --- /dev/null +++ b/sale_saleor/models/saleor_shipping_zone_meta_line.py @@ -0,0 +1,24 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ShippingZoneMetaLine(models.Model): + _name = "shipping.zone.meta.line" + _description = "Shipping Zone Metadata Line" + _order = "id" + + zone_id = fields.Many2one("saleor.shipping.zone", required=True, ondelete="cascade") + key = fields.Char(required=True) + value = fields.Char(required=True) + + +class ShippingZonePrivateMetaLine(models.Model): + _name = "shipping.zone.private.meta.line" + _description = "Shipping Zone Private Metadata Line" + _order = "id" + + zone_id = fields.Many2one("saleor.shipping.zone", required=True, ondelete="cascade") + key = fields.Char(required=True) + value = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_tax_meta_line.py b/sale_saleor/models/saleor_tax_meta_line.py new file mode 100644 index 0000000000..6727f93c59 --- /dev/null +++ b/sale_saleor/models/saleor_tax_meta_line.py @@ -0,0 +1,32 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleorTaxMetaLine(models.Model): + _name = "saleor.tax.meta.line" + _description = "Saleor Tax Metadata Line" + _order = "id" + + tax_id = fields.Many2one( + "account.tax", + required=True, + ondelete="cascade", + ) + key = fields.Char(required=True) + value = fields.Char(required=True) + + +class SaleorTaxPrivateMetaLine(models.Model): + _name = "saleor.tax.private.meta.line" + _description = "Saleor Tax Private Metadata Line" + _order = "id" + + tax_id = fields.Many2one( + "account.tax", + required=True, + ondelete="cascade", + ) + key = fields.Char(required=True) + value = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_voucher.py b/sale_saleor/models/saleor_voucher.py new file mode 100644 index 0000000000..9636f219d9 --- /dev/null +++ b/sale_saleor/models/saleor_voucher.py @@ -0,0 +1,322 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + +from ..helpers import get_active_saleor_account, to_saleor_datetime + + +class SaleorVoucher(models.Model): + _name = "saleor.voucher" + _inherit = ["mail.thread", "mail.activity.mixin"] + _description = "Saleor Voucher" + + name = fields.Char(required=True) + channel_ids = fields.Many2many( + "saleor.channel", + "saleor_channel_voucher_rel", + "channel_id", + "voucher_id", + string="Channels", + ) + voucher_code_ids = fields.One2many( + "saleor.voucher.code", "voucher_id", string="Voucher Codes" + ) + type = fields.Selection( + selection=[ + ("fixed", "Fixed Amount"), + ("percent", "Percentage"), + ("shipping", "Free Shipping"), + ], + required=True, + default="fixed", + ) + country_ids = fields.Many2many( + "res.country", + "saleor_voucher_country_rel", + "voucher_id", + "country_id", + string="Assigned Countries", + ) + apply_to = fields.Selection( + selection=[ + ("all", "All products"), + ("specific", "Specific products and variants"), + ], + default="all", + required=True, + ) + apply_cheapest = fields.Boolean( + "Apply only to a single cheapest eligible product", + help="If this option is disabled," + " discount will be counted for every eligible product", + default=False, + ) + # Minimum requirements + min_requirement = fields.Selection( + selection=[ + ("none", "None"), + ("order_value", "Minimal order value"), + ("item_qty", "Minimum quantity of items"), + ], + required=True, + default="none", + ) + min_item_qty = fields.Float("Minimum quantity of items") + minimal_order_value_ids = fields.One2many( + "saleor.voucher.minimal.order.value", + "voucher_id", + ) + # Usage Limit + limit_total = fields.Boolean( + "Limit number of times this discount can be used in total" + ) + limit_uses = fields.Integer("Limit of Uses") + uses_left = fields.Integer(string="Uses Left", related="limit_uses", readonly=True) + limit_one_per_customer = fields.Boolean("Limit to one use per customer") + limit_staff_only = fields.Boolean("Limit to staff only") + limit_once_per_code = fields.Boolean("Limit voucher code use once") + # Active Dates + set_end_date = fields.Boolean() + active_date_from = fields.Datetime(string="Start Date") + active_date_to = fields.Datetime(string="End Date") + saleor_voucher_metadata_line_ids = fields.One2many( + "saleor.voucher.meta.line", + "voucher_id", + ) + saleor_voucher_private_metadata_line_ids = fields.One2many( + "saleor.voucher.private.meta.line", "voucher_id" + ) + discount_line_ids = fields.One2many( + "saleor.voucher.discount.line", + "voucher_id", + string="Discount per Channel", + ) + saleor_voucher_id = fields.Char( + string="Saleor Voucher ID", + copy=False, + index=True, + help="ID of this voucher in Saleor", + ) + product_template_ids = fields.Many2many( + "product.template", + "saleor_voucher_product_template_rel", + "voucher_id", + "product_tmpl_id", + string="Products", + ) + product_variant_ids = fields.Many2many( + "product.product", + "saleor_voucher_product_variant_rel", + "voucher_id", + "product_id", + string="Variants", + ) + product_collection_ids = fields.Many2many( + "product.collection", + "saleor_voucher_product_collection_rel", + "voucher_id", + "collection_id", + string="Collections", + ) + product_category_ids = fields.Many2many( + "product.category", + "saleor_voucher_product_category_rel", + "voucher_id", + "category_id", + string="Categories", + ) + + _sql_constraints = [ + ( + "check_min_item_qty_nonnegative", + "CHECK(min_item_qty >= 0)", + "Minimum quantity of items must be greater than or equal to 0.", + ), + ] + + @api.onchange("type") + def _onchange_type_clear_discount_values(self): + for rec in self: + rec.discount_line_ids = [(5, 0, 0)] + + # --- Sync helpers --- + def _map_saleor_types(self): + """Return (saleor_type, saleor_value_type) based on voucher config.""" + self.ensure_one() + # Value type + if self.type == "fixed": + saleor_value_type = "FIXED" + elif self.type == "percent": + saleor_value_type = "PERCENTAGE" + else: + saleor_value_type = None + + # Voucher type + if self.apply_to == "specific": + saleor_type = "SPECIFIC_PRODUCT" + elif self.type == "shipping": + saleor_type = "SHIPPING" + else: + saleor_type = "ENTIRE_ORDER" + + return saleor_type, saleor_value_type + + def _build_dates_payload(self): + vals = {} + if self.active_date_from: + vals["startDate"] = to_saleor_datetime(self.active_date_from) + if self.set_end_date and self.active_date_to: + vals["endDate"] = to_saleor_datetime(self.active_date_to) + return vals + + def _build_limits_payload(self): + vals = {} + if self.limit_total and self.limit_uses: + vals["usageLimit"] = int(self.limit_uses) + if self.limit_one_per_customer: + vals["applyOncePerCustomer"] = True + if self.apply_cheapest: + vals["applyOncePerOrder"] = True + if self.limit_once_per_code: + vals["singleUse"] = True + if self.limit_staff_only: + vals["onlyForStaff"] = True + return vals + + def _build_metadata_payload(self): + vals = {} + meta_lines = self.saleor_voucher_metadata_line_ids + if meta_lines: + vals["metadata"] = [ + {"key": line.key, "value": line.value} + for line in meta_lines + if line.key + ] + priv_lines = self.saleor_voucher_private_metadata_line_ids + if priv_lines: + vals["privateMetadata"] = [ + {"key": line.key, "value": line.value} + for line in priv_lines + if line.key + ] + return vals + + def _build_min_spent_map(self): + min_spent_map = {} + if self.min_requirement == "order_value" and self.minimal_order_value_ids: + for mov in self.minimal_order_value_ids: + ch = mov.channel_id + if ch and ch.saleor_channel_id: + min_spent_map[ch.saleor_channel_id] = mov.minimal_order_value or 0.0 + return min_spent_map + + def _build_channel_listings(self, saleor_type, min_spent_map): + channel_lines = [] + for dline in self.discount_line_ids: + ch = dline.channel_id + if ch and ch.saleor_channel_id: + item = {"channelId": ch.saleor_channel_id} + if saleor_type != "SHIPPING": + item["discountValue"] = dline.discount_value or 0.0 + if ch.saleor_channel_id in min_spent_map: + item["amount"] = min_spent_map[ch.saleor_channel_id] + channel_lines.append(item) + for channel_id, amount in min_spent_map.items(): + if not any(row.get("channelId") == channel_id for row in channel_lines): + channel_lines.append({"channelId": channel_id, "amount": amount}) + return channel_lines + + def _build_requirements_payload(self): + vals = {} + if self.min_requirement == "item_qty" and self.min_item_qty: + vals["minCheckoutItemsQuantity"] = int(self.min_item_qty) + return vals + + def _collect_codes(self): + return [code.code for code in self.voucher_code_ids if code.code] + + def _saleor_prepare_payload(self): + self.ensure_one() + # Types + saleor_type, saleor_value_type = self._map_saleor_types() + + # Base + payload = {"name": self.name, "type": saleor_type} + if saleor_value_type: + payload["discountValueType"] = saleor_value_type + + # Dates, limits, countries, metadata + payload.update(self._build_dates_payload()) + payload.update(self._build_limits_payload()) + if self.country_ids: + payload["countries"] = [country.code for country in self.country_ids] + payload.update(self._build_metadata_payload()) + + # Channels and requirements + min_spent_map = self._build_min_spent_map() + channel_lines = self._build_channel_listings(saleor_type, min_spent_map) + if channel_lines: + payload["channelListings"] = channel_lines + payload.update(self._build_requirements_payload()) + + # Codes + codes = self._collect_codes() + if codes: + payload["codes"] = codes + + return payload + + def action_saleor_sync(self): + account = get_active_saleor_account(self.env, raise_if_missing=True) + if not account: + return True + if len(self) == 1: + rec = self + payload = rec._saleor_prepare_payload() + account.job_voucher_sync(rec.id, payload) + else: + # Multiple: batch + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for rec in self: + payload = rec._saleor_prepare_payload() + items.append({"id": rec.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_voucher_sync_batch(chunk) + else: + account.job_voucher_sync_batch(chunk) + return True + + # Auto-activate all codes whenever the voucher is saved + def _activate_codes(self): + for rec in self: + if rec.voucher_code_ids: + rec.voucher_code_ids.write({"status": "active"}) + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records._activate_codes() + missing_start = records.filtered(lambda r: not r.active_date_from) + if missing_start: + missing_start.write({"active_date_from": fields.Datetime.now()}) + return records + + def write(self, vals): + res = super().write(vals) + self._activate_codes() + return res + + @api.constrains("min_item_qty") + def _check_min_item_qty_nonnegative(self): + for rec in self: + if rec.min_item_qty is not None and rec.min_item_qty < 0: + raise ValidationError( + self.env._( + "Minimum quantity of items must be greater than or equal to 0." + ) + ) diff --git a/sale_saleor/models/saleor_voucher_code.py b/sale_saleor/models/saleor_voucher_code.py new file mode 100644 index 0000000000..e3d8c11ced --- /dev/null +++ b/sale_saleor/models/saleor_voucher_code.py @@ -0,0 +1,51 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class SaleorVoucherCode(models.Model): + _name = "saleor.voucher.code" + _description = "Saleor Voucher Code" + + voucher_id = fields.Many2one( + "saleor.voucher", + required=True, + ondelete="cascade", + ) + code = fields.Char(required=True) + usage = fields.Integer() + status = fields.Selection( + selection=[ + ("draft", "Draft"), + ("active", "Active"), + ("inactive", "Inactive"), + ], + required=True, + default="draft", + ) + _sql_constraints = [ + ( + "saleor_voucher_code_unique", + "unique(code)", + "Voucher code already exists. Please use a unique code.", + ) + ] + + @api.constrains("code") + def _check_unique_code(self): + for rec in self: + if not rec.code: + continue + dup = self.search( + [ + ("id", "!=", rec.id), + ("code", "=", rec.code), + ], + limit=1, + ) + if dup: + raise ValidationError( + self.env._("Voucher code '%s' already exists.", rec.code) + ) diff --git a/sale_saleor/models/saleor_voucher_discount_line.py b/sale_saleor/models/saleor_voucher_discount_line.py new file mode 100644 index 0000000000..43868ffbcc --- /dev/null +++ b/sale_saleor/models/saleor_voucher_discount_line.py @@ -0,0 +1,40 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import api, fields, models + + +class SaleorVoucherDiscountLine(models.Model): + _name = "saleor.voucher.discount.line" + _description = "Saleor Voucher Discount Line" + + voucher_id = fields.Many2one("saleor.voucher", required=True, ondelete="cascade") + channel_id = fields.Many2one( + "saleor.channel", + required=True, + ) + discount_value = fields.Float() + currency_id = fields.Many2one( + "res.currency", related="channel_id.currency_id", readonly=True + ) + display_unit = fields.Char( + compute="_compute_display_unit", string="Unit", readonly=True + ) + + _sql_constraints = [ + ( + "unique_voucher_channel", + "unique(voucher_id, channel_id)", + "Channel already exists for this voucher.", + ) + ] + + @api.depends("voucher_id.type", "currency_id") + def _compute_display_unit(self): + for line in self: + if line.voucher_id.type == "fixed": + line.display_unit = line.currency_id.name or "" + elif line.voucher_id.type == "percent": + line.display_unit = "%" + else: + line.display_unit = "" diff --git a/sale_saleor/models/saleor_voucher_meta_line.py b/sale_saleor/models/saleor_voucher_meta_line.py new file mode 100644 index 0000000000..2a5877ea5a --- /dev/null +++ b/sale_saleor/models/saleor_voucher_meta_line.py @@ -0,0 +1,32 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleorVoucherMetaLine(models.Model): + _name = "saleor.voucher.meta.line" + _description = "Saleor Voucher Metadata Line" + _order = "id" + + voucher_id = fields.Many2one( + "saleor.voucher", + required=True, + ondelete="cascade", + ) + key = fields.Char(required=True) + value = fields.Char(required=True) + + +class SaleorVoucherPrivateMetaLine(models.Model): + _name = "saleor.voucher.private.meta.line" + _description = "Saleor Voucher Private Metadata Line" + _order = "id" + + voucher_id = fields.Many2one( + "saleor.voucher", + required=True, + ondelete="cascade", + ) + key = fields.Char(required=True) + value = fields.Char(required=True) diff --git a/sale_saleor/models/saleor_voucher_minimal_order_value.py b/sale_saleor/models/saleor_voucher_minimal_order_value.py new file mode 100644 index 0000000000..bb30cdacef --- /dev/null +++ b/sale_saleor/models/saleor_voucher_minimal_order_value.py @@ -0,0 +1,29 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class SaleorVoucherMinimalOrderValue(models.Model): + _name = "saleor.voucher.minimal.order.value" + _description = "Saleor Voucher Minimal Order Value" + + voucher_id = fields.Many2one("saleor.voucher", required=True, ondelete="cascade") + channel_id = fields.Many2one( + "saleor.channel", + required=True, + ) + minimal_order_value = fields.Float() + + _sql_constraints = [ + ( + "unique_voucher_channel", + "unique(voucher_id, channel_id)", + "Channel already exists for this voucher.", + ), + ( + "check_minimal_order_value_nonnegative", + "CHECK(minimal_order_value >= 0)", + "Minimal order value must be greater than or equal to 0.", + ), + ] diff --git a/sale_saleor/models/stock_location.py b/sale_saleor/models/stock_location.py new file mode 100644 index 0000000000..7b35fc81bf --- /dev/null +++ b/sale_saleor/models/stock_location.py @@ -0,0 +1,210 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from ..helpers import get_active_saleor_account + +_logger = logging.getLogger(__name__) + + +class Location(models.Model): + _inherit = "stock.location" + + warehouse_id = fields.Many2one( + "stock.warehouse", + string="Warehouse", + help="Warehouse this location belongs to (used by Saleor integration)", + ) + + is_saleor_warehouse = fields.Boolean( + default=False, + help="If checked, this location is considered a Saleor warehouse.", + ) + + include_in_saleor_inventory = fields.Boolean( + default=False, + help="If checked, stock at this location will be considered\n" + "when syncing inventory counts to Saleor.", + ) + + saleor_warehouse_id = fields.Char( + string="Saleor Warehouse ID", copy=False, index=True + ) + + is_private = fields.Selection( + [ + ("private", "Private Stock"), + ("public", "Public Stock"), + ], + default="private", + help=( + "* Private Stock: If enabled stock in this location won't be shown" + "\n*Public Stock: If enabled stock in this location will be shown" + ), + ) + + def _saleor_prepare_warehouse_payload(self): + self.ensure_one() + # Name rule for locations: use complete_name + name = self.complete_name or self.name + partner = self.company_id + address = {} + if partner: + # Sanitize phone for Saleor: keep leading '+' and digits + def _sanitize_phone(val): + if not val: + return None + s = "".join(ch for ch in str(val) if ch.isdigit() or ch == "+") + if s.count("+") > 1: + s = "+" + s.replace("+", "") + digits_len = sum(1 for ch in s if ch.isdigit()) + if digits_len < 7 or digits_len > 15: + return None + return s + + phone_val = partner.phone or partner.mobile or "" + phone_clean = _sanitize_phone(phone_val) + + address = { + "companyName": self.company_id.name, + "streetAddress1": partner.street or "", + "streetAddress2": partner.street2 or "", + "city": partner.city or "", + "postalCode": (partner.zip or "").strip(), + "country": partner.country_id.code if partner.country_id else None, + "countryArea": partner.state_id.name if partner.state_id else "", + "phone": partner.phone or partner.mobile or "", + } + if phone_clean: + address["phone"] = phone_clean + payload = {"name": name} + if address: + payload["address"] = address + return payload + + def action_sync_to_saleor_warehouse(self): + account = get_active_saleor_account(self.env, raise_if_missing=True) + + # Validate and filter + records = self.filtered(lambda loc: loc.is_saleor_warehouse) + if not records: + if len(self) == 1: + raise UserError( + self.env._( + "Enable 'Is Saleor Warehouse'" " before syncing this location." + ) + ) + return True + + # Single: run immediate + if len(records) == 1: + loc = records + payload = loc._saleor_prepare_warehouse_payload() + account.job_location_sync(loc.id, payload) + title = self.env._("Saleor Sync") + msg = self.env._("Location synced successfully: %s", loc.display_name) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": title, + "message": msg, + "sticky": False, + "type": "success", + }, + } + + # Multiple: batch + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for loc in records: + payload = loc._saleor_prepare_warehouse_payload() + items.append({"id": loc.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_location_sync_batch(chunk) + else: + account.job_location_sync_batch(chunk) + title = self.env._("Saleor Sync") + msg = self.env._("Location sync started for %s record(s).", len(records)) + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": title, + "message": msg, + "sticky": False, + "type": "success", + }, + } + + def action_sync_product_quantities(self): + """ + Update stock for a product variant in Saleor. + """ + self.ensure_one() + if not self.include_in_saleor_inventory: + raise UserError(self.env._("This location is not marked for Saleor sync.")) + + account = get_active_saleor_account(self.env, raise_if_missing=True) + + if not self.saleor_warehouse_id: + raise UserError( + self.env._("This location has no linked Saleor warehouse ID.") + ) + + quants = self.env["stock.quant"].search([("location_id", "=", self.id)]) + success_count = 0 + skip_count = 0 + + for quant in quants: + product = quant.product_id + variant_id = product.saleor_variant_id + if not variant_id: + skip_count += 1 + continue + + qty = quant.quantity + account.with_delay().job_variant_stock_update( + variant_id=variant_id, + warehouse_id=self.saleor_warehouse_id, + quantity=qty, + ) + success_count += 1 + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": self.env._("Saleor Sync Completed"), + "message": self.env._( + "Successfully updated %s product(s)." + "\nSkipped %s product(s) without Saleor Variant ID.", + success_count, + skip_count, + ), + "sticky": False, + "type": "success", + }, + } + + @api.constrains("is_saleor_warehouse", "warehouse_id") + def _check_saleor_location_vs_warehouse(self): + for loc in self: + if ( + loc.is_saleor_warehouse + and loc.warehouse_id + and loc.warehouse_id.is_saleor_warehouse + ): + raise UserError( + self.env._( + "You cannot mark this location as a Saleor warehouse because" + " its warehouse (%s) is already marked as a Saleor warehouse.", + loc.warehouse_id.display_name, + ) + ) diff --git a/sale_saleor/models/stock_quant.py b/sale_saleor/models/stock_quant.py new file mode 100644 index 0000000000..6ab78280cd --- /dev/null +++ b/sale_saleor/models/stock_quant.py @@ -0,0 +1,124 @@ +import logging + +from odoo import api, models, tools + +from ..helpers import get_active_saleor_account + +_logger = logging.getLogger(__name__) + + +class StockQuant(models.Model): + _inherit = "stock.quant" + + @api.model_create_multi + def create(self, vals_list): + quants = super().create(vals_list) + # Recursion guard: allow callers to skip Saleor sync + if not tools.config["test_enable"]: + quants._trigger_saleor_sync() + return quants + + def write(self, vals): + res = super().write(vals) + + if ("quantity" in vals or "inventory_quantity" in vals) and not tools.config[ + "test_enable" + ]: + self._trigger_saleor_sync() + return res + + def _trigger_saleor_sync(self): + # No-op if called in a protected context + if tools.config["test_enable"]: + return + + account = get_active_saleor_account(self.env, raise_if_missing=True) + + # Collect affected variants + variants = self.mapped("product_id").filtered("saleor_variant_id") + if not variants: + return + + variant_by_id = {v.id: v for v in variants} + variant_ids = list(variant_by_id.keys()) + + saleor_whs = self.env["stock.warehouse"].search( + [ + ("is_saleor_warehouse", "=", True), + ("include_in_saleor_inventory", "=", True), + ] + ) + saleor_locs = self.env["stock.location"].search( + [ + ("is_saleor_warehouse", "=", True), + ("include_in_saleor_inventory", "=", True), + ] + ) + + Quant = self.env["stock.quant"].sudo() + + # Batch by warehouse (child_of view_location) using _read_group + for wh in saleor_whs: + domain = [ + ("location_id", "child_of", wh.view_location_id.id), + ("product_id", "in", variant_ids), + ] + rows = Quant._read_group( + domain, groupby=["product_id"], aggregates=["quantity:sum"] + ) + for group_val, qty in rows: + prod_id = group_val and group_val.id + if not prod_id: + continue + prod = variant_by_id.get(prod_id) + if not prod: + continue + qty = qty or 0.0 + try: + # Queue job if available + if hasattr(account, "with_delay"): + account.with_delay().job_variant_stock_update( + variant_id=prod.saleor_variant_id, + warehouse_id=wh.saleor_warehouse_id, + quantity=qty, + ) + else: + account.job_variant_stock_update( + variant_id=prod.saleor_variant_id, + warehouse_id=wh.saleor_warehouse_id, + quantity=qty, + ) + prod._notify_saleor_sync(wh, success=True) + except Exception as e: + prod._notify_saleor_sync(wh, success=False, error_msg=str(e)) + + # Batch by exact locations using _read_group + for loc in saleor_locs: + domain = [("location_id", "=", loc.id), ("product_id", "in", variant_ids)] + rows = Quant._read_group( + domain, groupby=["product_id"], aggregates=["quantity:sum"] + ) + for group_val, qty in rows: + prod_id = group_val and group_val.id + if not prod_id: + continue + prod = variant_by_id.get(prod_id) + if not prod: + continue + qty = qty or 0.0 + try: + if hasattr(account, "with_delay"): + account.with_delay().job_variant_stock_update( + variant_id=prod.saleor_variant_id, + warehouse_id=loc.saleor_warehouse_id, + quantity=qty, + ) + else: + account.job_variant_stock_update( + variant_id=prod.saleor_variant_id, + warehouse_id=loc.saleor_warehouse_id, + quantity=qty, + ) + prod._notify_saleor_sync(loc, success=True) + except Exception as e: + prod._notify_saleor_sync(loc, success=False, error_msg=str(e)) diff --git a/sale_saleor/models/stock_warehouse.py b/sale_saleor/models/stock_warehouse.py new file mode 100644 index 0000000000..013581c3a9 --- /dev/null +++ b/sale_saleor/models/stock_warehouse.py @@ -0,0 +1,179 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import logging + +from odoo import api, fields, models +from odoo.exceptions import UserError + +from ..helpers import get_active_saleor_account + +_logger = logging.getLogger(__name__) + + +class Warehouse(models.Model): + _inherit = "stock.warehouse" + + is_saleor_warehouse = fields.Boolean( + default=False, + help="If checked, this warehouse is considered a Saleor warehouse.", + ) + + include_in_saleor_inventory = fields.Boolean( + default=False, + help="If checked, stock at this location will be considered\n" + "when syncing inventory counts to Saleor.", + ) + + saleor_warehouse_id = fields.Char( + string="Saleor Warehouse ID", copy=False, index=True + ) + + is_private = fields.Selection( + [ + ("private", "Private Stock"), + ("public", "Public Stock"), + ], + default="private", + help=( + "* Private Stock: If enabled stock in this warehouse won't be shown" + "\n* Public Stock: If enabled stock in this warehouse will be shown" + ), + ) + + def _saleor_prepare_warehouse_payload(self): + self.ensure_one() + # Name rule: name + (short_name). In Odoo, the field code is short name. + base = self.name or "" + short = self.code or "" + name = f"{base} ({short})" if short else base + partner = self.company_id + address = {} + if partner: + address = { + "companyName": self.company_id.name, + "streetAddress1": partner.street or "", + "streetAddress2": partner.street2 or "", + "city": partner.city or "", + "postalCode": (partner.zip or "").strip(), + "country": partner.country_id.code if partner.country_id else None, + "countryArea": partner.state_id.name if partner.state_id else "", + "phone": partner.phone or partner.mobile or "", + } + payload = {"name": name} + if address: + payload["address"] = address + return payload + + def action_sync_to_saleor_warehouse(self): + """ + Sync stock.warehouse to Saleor Warehouse. + - Single: run immediate + - Multiple: batch by account.job_batch_size via queue_job + """ + account = get_active_saleor_account(self.env, raise_if_missing=True) + + # Filter valid records + records = self.filtered(lambda w: w.is_saleor_warehouse) + if not records: + if len(self) == 1: + raise UserError( + self.env._( + "Enable 'Is Saleor Warehouse' before syncing this warehouse." + ) + ) + return True + + if len(records) == 1: + wh = records + payload = wh._saleor_prepare_warehouse_payload() + account.job_warehouse_sync(wh.id, payload) + return True + + # Multiple: batch + batch_size = getattr(account, "job_batch_size", 10) or 10 + items = [] + for wh in records: + payload = wh._saleor_prepare_warehouse_payload() + items.append({"id": wh.id, "payload": payload}) + for i in range(0, len(items), batch_size): + chunk = items[i : i + batch_size] + if hasattr(account, "with_delay"): + account.with_delay().job_warehouse_sync_batch(chunk) + else: + account.job_warehouse_sync_batch(chunk) + return True + + def action_sync_product_quantities(self): + self.ensure_one() + if not self.include_in_saleor_inventory: + raise UserError(self.env._("This warehouse is not marked for Saleor sync.")) + + account = self.env["saleor.account"].search([("active", "=", True)], limit=1) + if not account: + raise UserError(self.env._("No active Saleor account configured.")) + + if not self.saleor_warehouse_id: + raise UserError( + self.env._("This warehouse has no linked Saleor warehouse ID.") + ) + + quants = self.env["stock.quant"].search( + [ + ("location_id", "child_of", self.view_location_id.id), + ] + ) + + success_count = 0 + skip_count = 0 + + for quant in quants: + product = quant.product_id + variant_id = product.saleor_variant_id + if not variant_id: + skip_count += 1 + continue + + qty = quant.quantity + account.with_delay().job_variant_stock_update( + variant_id=variant_id, + warehouse_id=self.saleor_warehouse_id, + quantity=qty, + ) + success_count += 1 + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": self.env._("Saleor Sync Completed"), + "message": self.env._( + "Successfully updated %s product(s)." + "\nSkipped %s product(s) without Saleor Variant ID.", + success_count, + skip_count, + ), + "sticky": False, + "type": "success", + }, + } + + @api.constrains("is_saleor_warehouse") + def _check_saleor_warehouse_vs_locations(self): + for wh in self: + if not wh.is_saleor_warehouse: + continue + has_child_saleor_locations = self.env["stock.location"].search_count( + [ + ("warehouse_id", "=", wh.id), + ("is_saleor_warehouse", "=", True), + ] + ) + if has_child_saleor_locations: + raise UserError( + self.env._( + "You cannot mark this warehouse as a Saleor warehouse because " + "one or more locations belonging to it" + " are already marked as Saleor warehouses." + ) + ) diff --git a/sale_saleor/pyproject.toml b/sale_saleor/pyproject.toml new file mode 100644 index 0000000000..4231d0cccb --- /dev/null +++ b/sale_saleor/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/sale_saleor/readme/CONFIGURE.md b/sale_saleor/readme/CONFIGURE.md new file mode 100644 index 0000000000..dc3301bf3c --- /dev/null +++ b/sale_saleor/readme/CONFIGURE.md @@ -0,0 +1,65 @@ +Initial Configuration +--------------------- + +Configure the Saleor account +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Open the menu that manages ``saleor.account`` records. +2. Create a new record with at least: + + * **Name**: a descriptive name (for example, ``Saleor Production``). + * **Saleor Base URL**: the base URL of the Saleor API. + * **Email / Password**: credentials of a Saleor staff user to obtain JWT + tokens (or an app token if supported by your setup). + * **Odoo Base URL**: the public URL of the Odoo instance. + +3. Enable the **Active** flag on the account that should be used in + production. Only one Saleor account can be active at a time. + +Once saved, the module will compute webhook target URLs (customer, order, +draft order, payment) from the Odoo base URL. You should configure +corresponding webhooks in Saleor using these URLs and the shared secret. + +Configure Saleor channels +~~~~~~~~~~~~~~~~~~~~~~~~~ + +1. Open the ``saleor.channel`` menu. +2. Create a channel and configure: + + * **Name** and **Slug** to match the channel in Saleor. + * **Status** set to *Active* when the channel is ready to sync. + * **Currency** and **Default Country** to match Saleor settings. + * **Shipping Zones** using corresponding Saleor shipping zones. + * **Warehouses / Locations** that are marked as Saleor warehouses and have a + remote Saleor warehouse ID. + +3. Save the channel. When a channel is created in *Active* status or key + fields are changed, the connector will automatically synchronize it to + Saleor. + +.. warning:: + + Once a channel has been synchronized to Saleor, its currency cannot be + changed unless explicitly bypassed via technical context. + +Configure promotions (optional) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* For loyalty programs and promotions based on ``loyalty.program``: + + * Create programs with ``program_type = 'saleor'``. + * Configure the discount type (catalogue/order), description, date range, + rules and channels. + * Use the *Saleor Promotion Sync* action to push programs to Saleor + promotions. Batch synchronization is supported. + +Configure vouchers (optional) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +* For vouchers based on ``saleor.voucher``: + + * Define the voucher type and value, limits, minimum requirements, countries + and channel listings. + * Add one or more voucher codes; the module will automatically activate + codes and set a start date if missing. + * Use the *Saleor Sync* action on vouchers to push them to Saleor. diff --git a/sale_saleor/readme/CONTRIBUTORS.md b/sale_saleor/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..c8f09d20a8 --- /dev/null +++ b/sale_saleor/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Trobz](https://trobz.com/): + - Khoi (Kien Kim) \<\> \ No newline at end of file diff --git a/sale_saleor/readme/DESCRIPTION.md b/sale_saleor/readme/DESCRIPTION.md new file mode 100644 index 0000000000..ef248430d6 --- /dev/null +++ b/sale_saleor/readme/DESCRIPTION.md @@ -0,0 +1,95 @@ +Saleor Connector for Odoo +========================== + +The ``sale_saleor`` module provides a two-way connector between Odoo and the +Saleor e-commerce platform. It focuses on synchronizing sales channels, orders, +payments, promotions, and vouchers while keeping Odoo as the central business +backend, with explicit flows in both directions: + +* From Odoo to Saleor: push sales orders, payment status, product variant + stock levels by warehouse, loyalty programs and vouchers defined in Odoo. +* From Saleor to Odoo: receive orders, order updates and payment events via + webhooks and reflect them in ``sale.order`` records in Odoo. + +Scope +----- + +This module does not replace Odoo's standard sales and inventory flows. Instead, +it extends them so that you can: + +* Link a single Saleor account to your Odoo database. +* Synchronize Saleor channels with Odoo currencies, countries, warehouses and + locations. +* Exchange order and payment information between Saleor and Odoo. +* Push Odoo loyalty programs and vouchers to Saleor promotions and vouchers. +* Mark Saleor-origin quotations as abandoned in Odoo based on per-channel + delays. + +Key Features +------------ + +* **Saleor account management (``saleor.account``)** + + * Stores the Saleor base URL, credentials and SSL verification settings. + * Automatically generates webhook target URLs (customer, order, draft order, + payment) from the configured Odoo base URL. + * Manages the Saleor App ID, token, webhook IDs and shared secret used for + HMAC verification. + * Enforces that only one Saleor account can be active at a time. + +* **Saleor channels (``saleor.channel``)** + + * Maps Saleor channels to Odoo currencies, default countries, shipping + zones, warehouses and locations. + * Synchronizes channels to Saleor, including linked warehouses/locations that + are marked as Saleor warehouses. + * Prevents changing the currency once a channel has been synced to Saleor + (unless explicitly bypassed from context). + * Provides a cron job to mark Saleor quotations as abandoned based on a + channel-specific delay. + +* **Sales orders (``sale.order``)** + + * Extends sales orders with fields such as ``saleor_order_id``, + ``saleor_channel_id`` and detailed Saleor payment state. + * Provides an action to push orders from Odoo to Saleor by creating and + completing a draft order with addresses and order lines. + * Provides an action to mark the related Saleor order as paid from Odoo. + * Validates required data before syncing (Saleor channel, product variants, + address requirements for specific countries, etc.). + +* **Webhooks from Saleor** + + * ``/saleor/webhook/order_created_updated`` handles ``ORDER_CREATED`` and + ``ORDER_UPDATED`` events: + + * Fetches full order details from Saleor via API. + * Skips orders that are explicitly marked as originating from Odoo in + metadata (to avoid loops). + * Creates or updates the corresponding ``sale.order`` in Odoo. + + * ``/saleor/webhook/order_payment`` handles ``ORDER_PAID`` and + ``ORDER_FULLY_PAID`` events: + + * Locates the related ``sale.order`` using ``saleor_order_id``. + * Updates payment-related fields and posts messages on the order. + +* **Promotions and loyalty programs (``loyalty.program``)** + + * Supports programs of type ``saleor``. + * Builds a minimal promotion payload (type, description, validity dates) to + reduce compatibility issues across Saleor versions. + * Synchronizes programs to Saleor promotions and upserts promotion rules. + +* **Saleor vouchers (``saleor.voucher``)** + + * Prepares Saleor voucher payloads including discount type/value, date and + usage limits, countries, channel listings and requirements. + * Collects and sends voucher codes, and adds additional codes after + creation/update when needed. + * Automatically activates voucher codes and ensures a start date is set. + +* **Stock and variants** + + * Provides a job to update Saleor variant stock quantities by warehouse. + diff --git a/sale_saleor/readme/USAGE.md b/sale_saleor/readme/USAGE.md new file mode 100644 index 0000000000..c096e0fa45 --- /dev/null +++ b/sale_saleor/readme/USAGE.md @@ -0,0 +1,98 @@ +Requirements +------------ + +* A running Saleor instance reachable from the Odoo server. +* An Odoo URL that Saleor can reach in order to call webhooks. + + +Main Flows +---------- + +Pushing orders from Odoo to Saleor +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This flow is used when orders are created in Odoo but should also exist in +Saleor: + +1. Create a ``sale.order`` in Odoo as usual. +2. Set the **Saleor Channel** field to a synced ``saleor.channel``. +3. Ensure all products that must be sent to Saleor have a + ``saleor_variant_id``. +4. Use the *Sync to Saleor* action on the order. + +The connector will: + +* Build the order payload including billing/shipping addresses, order lines + (variant and quantity) and customer identity (user or email). +* Create or update a draft order in Saleor, apply the shipping method and + complete the order. +* Store the ``saleor_order_id`` on the Odoo order and post links to the Saleor + dashboard in the chatter. + +You can also use the *Mark paid in Saleor* action to notify Saleor that the +order has been paid in Odoo (for example, offline payments). + +Note: + For customers in certain countries (for example, US/CA), a state/province + may be required. The connector validates this and raises an error if needed + information is missing. + +Receiving orders and updates from Saleor (webhooks) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When Saleor is the main source of order creation: + +1. In Saleor, configure webhooks: + + * **Order created/updated** → ``/saleor/webhook/order_created_updated``. + * **Order paid / fully paid** → ``/saleor/webhook/order_payment``. + +2. Use the same App/account and secret that are stored on the + ``saleor.account`` in Odoo. + +When events occur: + +* ``ORDER_CREATED`` / ``ORDER_UPDATED``: + + * Odoo fetches the full order from Saleor. + * The connector creates or updates a ``sale.order`` in Odoo. +* ``ORDER_PAID`` / ``ORDER_FULLY_PAID``: + + * The connector locates the related ``sale.order`` using + ``saleor_order_id``. + * Payment-related fields are updated and a message is posted in the chatter. + +Orders explicitly marked as originating from Odoo (metadata ``odoo_origin``) +are ignored by the webhook flow to avoid loops. + +Abandoned cart / quotation handling +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The cron method ``cron_mark_abandoned_saleor_orders`` periodically: + +* Finds Saleor-origin quotations (with ``saleor_order_id``) still in + ``draft``/``sent`` state and not yet marked as abandoned. +* Compares their age with the ``abandoned_cart_delay_hours`` configured on the + related ``saleor.channel``. +* Marks qualifying quotations as abandoned and posts an explanatory message on + each order. + +Stock Synchronization +--------------------- + +The connector exposes a job to update Saleor product variant stock quantities +by warehouse (``job_variant_stock_update``) to push stock changes to Saleor. + +Best Practices +-------------- + +* Keep exactly one ``saleor.account`` active to avoid ambiguity when + processing webhooks. +* Ensure ``odoo_base_url`` points to the external URL that Saleor can reach + and configure SSL verification appropriately. +* Avoid changing channel currencies after initial synchronization. +* Regularly verify that product variants, warehouses and locations are synced + and have their corresponding Saleor IDs. +* Monitor logs and queue jobs for synchronization errors and fix data issues + early. + diff --git a/sale_saleor/security/ir.model.access.csv b/sale_saleor/security/ir.model.access.csv new file mode 100644 index 0000000000..41add9956f --- /dev/null +++ b/sale_saleor/security/ir.model.access.csv @@ -0,0 +1,81 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_saleor_account_user,saleor.account user,model_saleor_account,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_account_manager,saleor.account manager,model_saleor_account,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_category_meta_line_user,saleor.category.meta.line user,model_saleor_category_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_category_meta_line_manager,saleor.category.meta.line manager,model_saleor_category_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_category_private_meta_line_user,saleor.category.private.meta.line user,model_saleor_category_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_category_private_meta_line_manager,saleor.category.private.meta.line manager,model_saleor_category_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_collection_meta_line_user,saleor.collection.meta.line user,model_saleor_collection_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_collection_meta_line_manager,saleor.collection.meta.line manager,model_saleor_collection_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_collection_private_meta_line_user,saleor.collection.private.meta.line user,model_saleor_collection_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_collection_private_meta_line_manager,saleor.collection.private.meta.line manager,model_saleor_collection_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_collection_user,saleor.collection user,model_product_collection,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_collection_manager,saleor.collection manager,model_product_collection,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_product_meta_line_user,saleor.product.meta.line user,model_saleor_product_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_product_meta_line_manager,saleor.product.meta.line manager,model_saleor_product_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_product_private_meta_line_user,saleor.product.private.meta.line user,model_saleor_product_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_product_private_meta_line_manager,saleor.product.private.meta.line manager,model_saleor_product_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_attribute_meta_line_user,saleor.attribute.meta.line user,model_saleor_attribute_meta_line,sale_saleor.group_saleor_user,1,0,0,0 +access_saleor_attribute_meta_line_manager,saleor.attribute.meta.line manager,model_saleor_attribute_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_attribute_private_meta_line_user,saleor.attribute.private.meta.line user,model_saleor_attribute_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_attribute_private_meta_line_manager,saleor.attribute.private.meta.line manager,model_saleor_attribute_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_product_image_user,saleor.product.image user,model_saleor_product_image,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_product_image_manager,saleor.product.image manager,model_saleor_product_image,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_tax_meta_line_user,saleor.tax.meta.line user,model_saleor_tax_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_tax_meta_line_manager,saleor.tax.meta.line manager,model_saleor_tax_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_tax_private_meta_line_user,saleor.tax.private.meta.line user,model_saleor_tax_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_tax_private_meta_line_manager,saleor.tax.private.meta.line manager,model_saleor_tax_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_channel_user,saleor.channel user,model_saleor_channel,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_channel_manager,saleor.channel manager,model_saleor_channel,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_shipping_zone_user,saleor.shipping.zone user,model_saleor_shipping_zone,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_shipping_zone_manager,saleor.shipping.zone manager,model_saleor_shipping_zone,sale_saleor.group_saleor_manager,1,1,1,1 +access_order_value_line_user,order.value.line user,model_order_value_line,sale_saleor.group_saleor_user,1,1,1,0 +access_order_value_line_manager,order.value.line manager,model_order_value_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_shipping_pricing_line_user,shipping.pricing.line user,model_shipping_pricing_line,sale_saleor.group_saleor_user,1,1,1,0 +access_shipping_pricing_line_manager,shipping.pricing.line manager,model_shipping_pricing_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_postal_code_range_user,postal.code.range user,model_postal_code_range,sale_saleor.group_saleor_user,1,1,1,0 +access_postal_code_range_manager,postal.code.range manager,model_postal_code_range,sale_saleor.group_saleor_manager,1,1,1,1 +access_shipping_method_meta_line_user,shipping.method.meta.line user,model_shipping_method_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_shipping_method_meta_line_manager,shipping.method.meta.line manager,model_shipping_method_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_shipping_method_private_meta_line_user,shipping.method.private.meta.line user,model_shipping_method_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_shipping_method_private_meta_line_manager,shipping.method.private.meta.line manager,model_shipping_method_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_shipping_zone_meta_line_user,shipping.zone.meta.line user,model_shipping_zone_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_shipping_zone_meta_line_manager,shipping.zone.meta.line manager,model_shipping_zone_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_shipping_zone_private_meta_line_user,shipping.zone.private.meta.line user,model_shipping_zone_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_shipping_zone_private_meta_line_manager,shipping.zone.private.meta.line manager,model_shipping_zone_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_discount_rule_user,discount.rule user,model_discount_rule,sale_saleor.group_saleor_user,1,1,1,0 +access_discount_rule_manager,discount.rule manager,model_discount_rule,sale_saleor.group_saleor_manager,1,1,1,1 +access_discount_rule_condition_user,discount.rule.condition user,model_discount_rule_condition,sale_saleor.group_saleor_user,1,1,1,0 +access_discount_rule_condition_manager,discount.rule.condition manager,model_discount_rule_condition,sale_saleor.group_saleor_manager,1,1,1,1 +access_condition_operation_type_user,condition.operation.type user,model_condition_operation_type,sale_saleor.group_saleor_user,1,1,1,0 +access_condition_operation_type_manager,condition.operation.type manager,model_condition_operation_type,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_voucher_user,saleor.voucher user,model_saleor_voucher,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_voucher_manager,saleor.voucher manager,model_saleor_voucher,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_voucher_meta_line_user,saleor.voucher.meta.line user,model_saleor_voucher_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_voucher_meta_line_manager,saleor.voucher.meta.line manager,model_saleor_voucher_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_voucher_private_meta_line_user,saleor.voucher.private.meta.line user,model_saleor_voucher_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_voucher_private_meta_line_manager,saleor.voucher.private.meta.line manager,model_saleor_voucher_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_voucher_code_user,saleor.voucher.code user,model_saleor_voucher_code,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_voucher_code_manager,saleor.voucher.code manager,model_saleor_voucher_code,sale_saleor.group_saleor_manager,1,1,1,1 +access_voucher_code_auto_generate_wizard_user,voucher.code.auto.generate.wizard user,model_voucher_code_generate_wizard,sale_saleor.group_saleor_user,1,1,1,0 +access_voucher_code_auto_generate_wizard_manager,voucher.code.auto.generate.wizard manager,model_voucher_code_generate_wizard,sale_saleor.group_saleor_manager,1,1,1,1 +access_voucher_code_manual_wizard_user,voucher.code.manual.wizard user,model_voucher_code_manual_wizard,sale_saleor.group_saleor_user,1,1,1,0 +access_voucher_code_manual_wizard_manager,voucher.code.manual.wizard manager,model_voucher_code_manual_wizard,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_voucher_discount_line_user,saleor.voucher.discount.line user,model_saleor_voucher_discount_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_voucher_discount_line_manager,saleor.voucher.discount.line manager,model_saleor_voucher_discount_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_voucher_minimal_order_value_user,saleor.voucher.minimal.order.value user,model_saleor_voucher_minimal_order_value,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_voucher_minimal_order_value_manager,saleor.voucher.minimal.order.value manager,model_saleor_voucher_minimal_order_value,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_giftcard_user,saleor.giftcard user,model_saleor_giftcard,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_giftcard_manager,saleor.giftcard manager,model_saleor_giftcard,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_giftcard_tag_user,saleor.giftcard.tag user,model_saleor_giftcard_tag,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_giftcard_tag_manager,saleor.giftcard.tag manager,model_saleor_giftcard_tag,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_giftcard_meta_line_user,saleor.giftcard.meta.line user,model_saleor_giftcard_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_giftcard_meta_line_manager,saleor.giftcard.meta.line manager,model_saleor_giftcard_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_giftcard_private_meta_line_user,saleor.giftcard.private.meta.line user,model_saleor_giftcard_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_giftcard_private_meta_line_manager,saleor.giftcard.private.meta.line manager,model_saleor_giftcard_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_product_type_user,saleor.product.type user,model_saleor_product_type,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_product_type_manager,saleor.product.type manager,model_saleor_product_type,sale_saleor.group_saleor_manager,1,1,1,1 +access_product_type_meta_line_user,saleor.product.type.meta.line user,model_product_type_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_product_type_meta_line_manager,saleor.product.type.meta.line manager,model_product_type_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_product_type_private_meta_line_user,saleor.product.type.private.meta.line user,model_product_type_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 +access_product_type_private_meta_line_manager,saleor.product.type.private.meta.line manager,model_product_type_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 diff --git a/sale_saleor/security/saleor_security.xml b/sale_saleor/security/saleor_security.xml new file mode 100644 index 0000000000..cb4392a462 --- /dev/null +++ b/sale_saleor/security/saleor_security.xml @@ -0,0 +1,24 @@ + + + + + Saleor/ User + + + + + Saleor/ Manager + + + + diff --git a/sale_saleor/static/description/icon.png b/sale_saleor/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9d5d28a79e23072a8c270fe4608ca67ade4c34e8 GIT binary patch literal 600 zcmeAS@N?(olHy`uVBq!ia0vp^ZXnD7Bp5&~J>Ys2rFr$Az zr}XFZ>n8~JS4&7r@NcV6P)Rvs-!JC*C(qLMw{6glB>qSH&%Lei{dvA%*$1&rVQY^4 z+4_3I!Pg?kyj9I-NODMgzP>7$aex0hu~|ls80njh(1$ReX0q&?SFV1Km4zrDZPm48g^P5j}%Vnxys(M}zgl}a3J z;S=rP!2)8K7_u=W%yU0+$??T!V9=_TxJHzuB$lLFB^RXvDF!10BU4=i3tdCw5JLki zVN%;<8Yt`4Y&;@nYpROC5gEOn0gG&A(otP SXSN6GVeoYIb6Mw<&;$T558}Z9 literal 0 HcmV?d00001 diff --git a/sale_saleor/static/description/icon.svg b/sale_saleor/static/description/icon.svg new file mode 100644 index 0000000000..22f72f13e5 --- /dev/null +++ b/sale_saleor/static/description/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/sale_saleor/static/description/index.html b/sale_saleor/static/description/index.html new file mode 100644 index 0000000000..b26dcac720 --- /dev/null +++ b/sale_saleor/static/description/index.html @@ -0,0 +1,688 @@ + + + + + +Saleor Connector + + + +
    +

    Saleor Connector

    + + +

    Beta License: AGPL-3 OCA/e-commerce Translate me on Weblate Try me on Runboat

    +
    +

    Saleor Connector for Odoo

    +

    The sale_saleor module provides a two-way connector between Odoo and +the Saleor e-commerce platform. It focuses on synchronizing sales +channels, orders, payments, promotions, and vouchers while keeping Odoo +as the central business backend, with explicit flows in both directions:

    +
      +
    • From Odoo to Saleor: push sales orders, payment status, product +variant stock levels by warehouse, loyalty programs and vouchers +defined in Odoo.
    • +
    • From Saleor to Odoo: receive orders, order updates and payment events +via webhooks and reflect them in sale.order records in Odoo.
    • +
    +
    +

    Scope

    +

    This module does not replace Odoo’s standard sales and inventory flows. +Instead, it extends them so that you can:

    +
      +
    • Link a single Saleor account to your Odoo database.
    • +
    • Synchronize Saleor channels with Odoo currencies, countries, +warehouses and locations.
    • +
    • Exchange order and payment information between Saleor and Odoo.
    • +
    • Push Odoo loyalty programs and vouchers to Saleor promotions and +vouchers.
    • +
    • Mark Saleor-origin quotations as abandoned in Odoo based on +per-channel delays.
    • +
    +
    +
    +

    Key Features

    +
      +
    • Saleor account management (``saleor.account``)
        +
      • Stores the Saleor base URL, credentials and SSL verification +settings.
      • +
      • Automatically generates webhook target URLs (customer, order, draft +order, payment) from the configured Odoo base URL.
      • +
      • Manages the Saleor App ID, token, webhook IDs and shared secret used +for HMAC verification.
      • +
      • Enforces that only one Saleor account can be active at a time.
      • +
      +
    • +
    • Saleor channels (``saleor.channel``)
        +
      • Maps Saleor channels to Odoo currencies, default countries, shipping +zones, warehouses and locations.
      • +
      • Synchronizes channels to Saleor, including linked +warehouses/locations that are marked as Saleor warehouses.
      • +
      • Prevents changing the currency once a channel has been synced to +Saleor (unless explicitly bypassed from context).
      • +
      • Provides a cron job to mark Saleor quotations as abandoned based on +a channel-specific delay.
      • +
      +
    • +
    • Sales orders (``sale.order``)
        +
      • Extends sales orders with fields such as saleor_order_id, +saleor_channel_id and detailed Saleor payment state.
      • +
      • Provides an action to push orders from Odoo to Saleor by creating +and completing a draft order with addresses and order lines.
      • +
      • Provides an action to mark the related Saleor order as paid from +Odoo.
      • +
      • Validates required data before syncing (Saleor channel, product +variants, address requirements for specific countries, etc.).
      • +
      +
    • +
    • Webhooks from Saleor
        +
      • /saleor/webhook/order_created_updated handles ORDER_CREATED +and ORDER_UPDATED events:
          +
        • Fetches full order details from Saleor via API.
        • +
        • Skips orders that are explicitly marked as originating from Odoo +in metadata (to avoid loops).
        • +
        • Creates or updates the corresponding sale.order in Odoo.
        • +
        +
      • +
      • /saleor/webhook/order_payment handles ORDER_PAID and +ORDER_FULLY_PAID events:
          +
        • Locates the related sale.order using saleor_order_id.
        • +
        • Updates payment-related fields and posts messages on the order.
        • +
        +
      • +
      +
    • +
    • Promotions and loyalty programs (``loyalty.program``)
        +
      • Supports programs of type saleor.
      • +
      • Builds a minimal promotion payload (type, description, validity +dates) to reduce compatibility issues across Saleor versions.
      • +
      • Synchronizes programs to Saleor promotions and upserts promotion +rules.
      • +
      +
    • +
    • Saleor vouchers (``saleor.voucher``)
        +
      • Prepares Saleor voucher payloads including discount type/value, date +and usage limits, countries, channel listings and requirements.
      • +
      • Collects and sends voucher codes, and adds additional codes after +creation/update when needed.
      • +
      • Automatically activates voucher codes and ensures a start date is +set.
      • +
      +
    • +
    • Stock and variants
        +
      • Provides a job to update Saleor variant stock quantities by +warehouse.
      • +
      +
    • +
    +

    Table of contents

    +
    +
    +
    +

    Configuration

    +
    +

    Initial Configuration

    +

    Configure the Saleor account

    +
    +1. Open the menu that manages ``saleor.account`` records.
    +2. Create a new record with at least:
    +
    +   * **Name**: a descriptive name (for example, ``Saleor Production``).
    +   * **Saleor Base URL**: the base URL of the Saleor API.
    +   * **Email / Password**: credentials of a Saleor staff user to obtain JWT
    +     tokens (or an app token if supported by your setup).
    +   * **Odoo Base URL**: the public URL of the Odoo instance.
    +
    +3. Enable the **Active** flag on the account that should be used in
    +   production. Only one Saleor account can be active at a time.
    +
    +Once saved, the module will compute webhook target URLs (customer, order,
    +draft order, payment) from the Odoo base URL. You should configure
    +corresponding webhooks in Saleor using these URLs and the shared secret.
    +
    +Configure Saleor channels
    +~~~~~~~~~~~~~~~~~~~~~~~~~
    +
    +1. Open the ``saleor.channel`` menu.
    +2. Create a channel and configure:
    +
    +   * **Name** and **Slug** to match the channel in Saleor.
    +   * **Status** set to *Active* when the channel is ready to sync.
    +   * **Currency** and **Default Country** to match Saleor settings.
    +   * **Shipping Zones** using corresponding Saleor shipping zones.
    +   * **Warehouses / Locations** that are marked as Saleor warehouses and have a
    +     remote Saleor warehouse ID.
    +
    +3. Save the channel. When a channel is created in *Active* status or key
    +   fields are changed, the connector will automatically synchronize it to
    +   Saleor.
    +
    +.. warning::
    +
    +   Once a channel has been synchronized to Saleor, its currency cannot be
    +   changed unless explicitly bypassed via technical context.
    +
    +Configure promotions (optional)
    +
    +
      +
    • For loyalty programs and promotions based on loyalty.program:
        +
      • Create programs with program_type = 'saleor'.
      • +
      • Configure the discount type (catalogue/order), description, date +range, rules and channels.
      • +
      • Use the Saleor Promotion Sync action to push programs to Saleor +promotions. Batch synchronization is supported.
      • +
      +
    • +
    +

    Configure vouchers (optional)

    +
    +* For vouchers based on ``saleor.voucher``:
    +
    +  * Define the voucher type and value, limits, minimum requirements, countries
    +    and channel listings.
    +  * Add one or more voucher codes; the module will automatically activate
    +    codes and set a start date if missing.
    +  * Use the *Saleor Sync* action on vouchers to push them to Saleor.
    +
    +
    +
    +
    +

    Usage

    +
    +

    Requirements

    +
      +
    • A running Saleor instance reachable from the Odoo server.
    • +
    • An Odoo URL that Saleor can reach in order to call webhooks.
    • +
    +
    +
    +

    Main Flows

    +

    Pushing orders from Odoo to Saleor

    +
    +This flow is used when orders are created in Odoo but should also exist in
    +Saleor:
    +
    +1. Create a ``sale.order`` in Odoo as usual.
    +2. Set the **Saleor Channel** field to a synced ``saleor.channel``.
    +3. Ensure all products that must be sent to Saleor have a
    +   ``saleor_variant_id``.
    +4. Use the *Sync to Saleor* action on the order.
    +
    +The connector will:
    +
    +* Build the order payload including billing/shipping addresses, order lines
    +  (variant and quantity) and customer identity (user or email).
    +* Create or update a draft order in Saleor, apply the shipping method and
    +  complete the order.
    +* Store the ``saleor_order_id`` on the Odoo order and post links to the Saleor
    +  dashboard in the chatter.
    +
    +You can also use the *Mark paid in Saleor* action to notify Saleor that the
    +order has been paid in Odoo (for example, offline payments).
    +
    +Note:
    +   For customers in certain countries (for example, US/CA), a state/province
    +   may be required. The connector validates this and raises an error if needed
    +   information is missing.
    +
    +Receiving orders and updates from Saleor (webhooks)
    +
    +

    When Saleor is the main source of order creation:

    +
      +
    1. In Saleor, configure webhooks:
        +
      • Order created/updated → +/saleor/webhook/order_created_updated.
      • +
      • Order paid / fully paid/saleor/webhook/order_payment.
      • +
      +
    2. +
    3. Use the same App/account and secret that are stored on the +saleor.account in Odoo.
    4. +
    +

    When events occur:

    +
      +
    • ORDER_CREATED / ORDER_UPDATED:
        +
      • Odoo fetches the full order from Saleor.
      • +
      • The connector creates or updates a sale.order in Odoo.
      • +
      +
    • +
    • ORDER_PAID / ORDER_FULLY_PAID:
        +
      • The connector locates the related sale.order using +saleor_order_id.
      • +
      • Payment-related fields are updated and a message is posted in the +chatter.
      • +
      +
    • +
    +

    Orders explicitly marked as originating from Odoo (metadata +odoo_origin) are ignored by the webhook flow to avoid loops.

    +

    Abandoned cart / quotation handling

    +
    +The cron method ``cron_mark_abandoned_saleor_orders`` periodically:
    +
    +* Finds Saleor-origin quotations (with ``saleor_order_id``) still in
    +  ``draft``/``sent`` state and not yet marked as abandoned.
    +* Compares their age with the ``abandoned_cart_delay_hours`` configured on the
    +  related ``saleor.channel``.
    +* Marks qualifying quotations as abandoned and posts an explanatory message on
    +  each order.
    +
    +Stock Synchronization
    +---------------------
    +
    +The connector exposes a job to update Saleor product variant stock quantities
    +by warehouse (``job_variant_stock_update``) to push stock changes to Saleor.
    +
    +Best Practices
    +--------------
    +
    +* Keep exactly one ``saleor.account`` active to avoid ambiguity when
    +  processing webhooks.
    +* Ensure ``odoo_base_url`` points to the external URL that Saleor can reach
    +  and configure SSL verification appropriately.
    +* Avoid changing channel currencies after initial synchronization.
    +* Regularly verify that product variants, warehouses and locations are synced
    +  and have their corresponding Saleor IDs.
    +* Monitor logs and queue jobs for synchronization errors and fix data issues
    +  early.
    +
    +
    +
    +
    +

    Bug Tracker

    +

    Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

    +

    Do not contact contributors directly about support or help with technical issues.

    +
    +
    +

    Credits

    +
    +

    Authors

    +
      +
    • Kencove
    • +
    +
    +
    +

    Contributors

    + +
    +
    +

    Maintainers

    +

    This module is maintained by the OCA.

    + +Odoo Community Association + +

    OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

    +

    This module is part of the OCA/e-commerce project on GitHub.

    +

    You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

    +
    +
    +
    + + diff --git a/sale_saleor/static/src/scss/kanban_record.scss b/sale_saleor/static/src/scss/kanban_record.scss new file mode 100644 index 0000000000..355421f45f --- /dev/null +++ b/sale_saleor/static/src/scss/kanban_record.scss @@ -0,0 +1,17 @@ +.o_form_renderer { + .o_field_x2_many_media_viewer .o_kanban_renderer { + --KanbanRecord-width: 100px; + + article.o_kanban_record { + display: flex; + justify-content: center; + margin-bottom: unset !important; + + & img { + height: 128px; + width: 168.86px; + object-fit: contain; + } + } + } +} diff --git a/sale_saleor/static/src/scss/sale_saleor_backend.scss b/sale_saleor/static/src/scss/sale_saleor_backend.scss new file mode 100644 index 0000000000..250e098d0e --- /dev/null +++ b/sale_saleor/static/src/scss/sale_saleor_backend.scss @@ -0,0 +1,67 @@ +.o_sale_saleor_image_list .o_kanban_view.o_kanban_ungrouped { + width: auto; + + .o_kanban_record { + flex: 0 1 50%; + position: relative; + + @include media-breakpoint-up(md) { + flex: 0 0 percentage(1/3); + } + + @include media-breakpoint-up(lg) { + flex: 0 0 percentage(1/5); + } + + @include media-breakpoint-up(xl) { + flex: 0 0 percentage(1/6); + } + // make the image square and in the center + .o_squared_image { + position: relative; + overflow: hidden; + padding-bottom: 100%; + > img { + position: absolute; + margin: auto; + top: 0; + left: 0; + bottom: 0; + right: 0; + } + } + + .o_product_image_size { + position: absolute; + top: 0; + left: 0; + } + } +} + +.o_sale_saleor_image_modal { + .o_website_sale_image_modal_container { + border-left: 1px solid map-get($grays, "400"); + + .o_field_image { + margin-bottom: 0; + box-shadow: 0 2px 10px map-get($grays, "300"); + + > img { + border: 1px solid map-get($grays, "400"); + height: 200px; + width: auto; + } + } + } + .o_video_container { + height: 200px; + position: relative; + @include o-we-preview-box($text-muted); + .o_invalid_warning { + width: 90%; + @include o-position-absolute($top: 50%, $left: 50%); + transform: translate(-50%, -50%); + } + } +} diff --git a/sale_saleor/utils.py b/sale_saleor/utils.py new file mode 100644 index 0000000000..d05fb68792 --- /dev/null +++ b/sale_saleor/utils.py @@ -0,0 +1,2968 @@ +# Copyright 2025 Kencove (https://www.kencove.com/) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +import json as _json +import logging + +import requests + +_logger = logging.getLogger(__name__) + +DEFAULT_TIMEOUT = 30 + + +class SaleorClient: + """Lightweight GraphQL client for Saleor. + + Handles auth via tokenCreate (JWT) or pre-provided token. + """ + + def __init__(self, base_url, verify_ssl=True, token=None, timeout=DEFAULT_TIMEOUT): + self.base_url = base_url.rstrip("/") + self.endpoint = f"{self.base_url}/graphql/" + self.verify_ssl = verify_ssl + self._token = token + self.timeout = timeout + + def set_token(self, token): + self._token = token + + def graphql(self, query, variables=None): + headers = {"Content-Type": "application/json"} + if self._token: + headers["Authorization"] = f"JWT {self._token}" + payload = {"query": query, "variables": variables or {}} + resp = requests.post( + self.endpoint, + json=payload, + headers=headers, + timeout=self.timeout, + verify=self.verify_ssl, + ) + if resp.status_code != 200: + # Try to provide detailed server message to ease debugging + try: + err_body = resp.json() + except Exception: + err_body = resp.text + raise Exception(f"Saleor GraphQL HTTP {resp.status_code}: {err_body}") + data = resp.json() + if "errors" in data and data["errors"]: + raise Exception(str(data["errors"])) + return data["data"] + + def graphql_multipart(self, query, variables, files_map): + """Perform GraphQL multipart request per spec (for Upload scalar). + + files_map is a dict like {"0": (filename, bytes, content_type, paths)} + where paths is a list of variable paths e.g. ["variables.image"]. + """ + headers = {} + if self._token: + headers["Authorization"] = f"JWT {self._token}" + operations = { + "query": query, + "variables": variables or {}, + } + # Build map and files payload + files = {} + file_map = {} + for idx, (filename, content, content_type, paths) in files_map.items(): + file_map[idx] = paths + files[idx] = (filename, content, content_type) + data = { + "operations": _json.dumps(operations), + "map": _json.dumps(file_map), + } + resp = requests.post( + self.endpoint, + data=data, + files=files, + headers=headers, + timeout=self.timeout, + verify=self.verify_ssl, + ) + if resp.status_code != 200: + try: + err_body = resp.json() + except Exception: + err_body = resp.text + raise Exception(f"Saleor GraphQL HTTP {resp.status_code}: {err_body}") + data = resp.json() + if "errors" in data and data["errors"]: + raise Exception(str(data["errors"])) + return data["data"] + + def token_create(self, email, password): + query = """ + mutation TokenCreate($email: String!, $password: String!) { + tokenCreate(email: $email, password: $password) { + token + errors { field message } + } + } + """ + res = self.graphql(query, {"email": email, "password": password}) + out = res.get("tokenCreate") or {} + token = out.get("token") + if not token: + raise Exception(f"Saleor auth failed: {out.get('errors')}") + return token + + # --- App management --- + def app_get_by_id(self, app_id): + query = """ + query App($id: ID!) { + app(id: $id) { id name } + } + """ + data = self.graphql(query, {"id": app_id}) + return data.get("app") + + def app_create(self, name, permissions=None, is_active=True): + query = """ + mutation AppCreate($input: AppInput!) { + appCreate(input: $input) { + app { id name } + authToken + errors { field message } + } + } + """ + variables = { + "input": { + "name": name, + "permissions": permissions or [], + } + } + data = self.graphql(query, variables) + result = data.get("appCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor appCreate errors: {errors}") + app = result.get("app") or {} + token = result.get("authToken") + out = dict(app) + if token: + out["authToken"] = token + _logger.info("Saleor app_create done: %s", out) + return out + + def app_update(self, app_id, permissions=None, is_active=None, name=None): + """Update a Saleor App (e.g., permissions). + + Parameters: + - app_id: ID of the app to update + - permissions: list of permission codes to set + - is_active: optional boolean to activate/deactivate + - name: optional new app name + """ + query = """ + mutation AppUpdate($id: ID!, $input: AppInput!) { + appUpdate(id: $id, input: $input) { + app { id name } + errors { field message } + } + } + """ + input_payload = {} + if permissions is not None: + input_payload["permissions"] = permissions + if is_active is not None: + input_payload["isActive"] = bool(is_active) + if name is not None: + input_payload["name"] = name + + variables = {"id": app_id, "input": input_payload} + data = self.graphql(query, variables) + result = data.get("appUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor appUpdate errors: {errors}") + _logger.info("Saleor app_update done for app %s", app_id) + return result.get("app") + + # --- Webhook management --- + def webhook_get_by_id(self, webhook_id): + query = """ + query Webhook($id: ID!) { + webhook(id: $id) { + id name targetUrl isActive events { eventType } + } + } + """ + data = self.graphql(query, {"id": webhook_id}) + return data.get("webhook") + + def webhook_create( + self, app_id, target_url, events, secret_key=None, is_active=True, name=None + ): + query = """ + mutation WebhookCreate($input: WebhookCreateInput!) { + webhookCreate(input: $input) { + webhook { id name targetUrl isActive } + errors { field message } + } + } + """ + variables = { + "input": { + "app": app_id, + "name": name or "Odoo Webhook", + "targetUrl": target_url, + "isActive": bool(is_active), + "secretKey": secret_key or "", + # Use asyncEvents for standard webhooks + "asyncEvents": events or [], + }, + } + data = self.graphql(query, variables) + result = data.get("webhookCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor webhookCreate errors: {errors}") + return result.get("webhook") + + def webhook_update( + self, + webhook_id, + target_url=None, + events=None, + secret_key=None, + is_active=None, + name=None, + ): + query = """ + mutation WebhookUpdate($id: ID!, $input: WebhookUpdateInput!) { + webhookUpdate(id: $id, input: $input) { + webhook { id name targetUrl isActive } + errors { field message } + } + } + """ + input_data = {} + if target_url is not None: + input_data["targetUrl"] = target_url + if is_active is not None: + input_data["isActive"] = bool(is_active) + if name is not None: + input_data["name"] = name + if secret_key is not None: + input_data["secretKey"] = secret_key + if events is not None: + input_data["asyncEvents"] = events or [] + variables = {"id": webhook_id, "input": input_data} + data = self.graphql(query, variables) + result = data.get("webhookUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor webhookUpdate errors: {errors}") + return result.get("webhook") + + # ---Product variant quantity--- + def product_variant_stocks_update(self, variant_id, warehouse_id, quantity): + """ + Update stock quantity of a single product variant in a Saleor warehouse. + """ + query = """ + mutation ProductVariantStocksUpdate( + $variantId: ID!, $stocks: [StockInput!]! + ) { + productVariantStocksUpdate(variantId: $variantId, stocks: $stocks) { + productVariant { + id + sku + stocks { + warehouse { id name } + quantity + } + } + errors { + field + message + } + } + } + """ + variables = { + "variantId": variant_id, + "stocks": [{"warehouse": warehouse_id, "quantity": int(quantity)}], + } + data = self.graphql(query, variables) + result = data.get("productVariantStocksUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor productVariantStocksUpdate errors: {errors}") + return result.get("productVariant") + + def product_variants_list_by_product_id(self, product_id): + """Return list of variants for a given Saleor product ID. + + Each item: {id, sku, name} + """ + query = """ + query ProductVariants($id: ID!) { + product(id: $id) { + id + variants { + id + sku + name + } + } + } + """ + variables = {"id": product_id} + data = self.graphql(query, variables) or {} + product = (data or {}).get("product") or {} + return product.get("variants") or [] + + def product_variant_bulk_delete(self, variant_ids): + """Bulk delete product variants by IDs in Saleor. + + variant_ids: iterable of variant relay IDs. + """ + ids = list(variant_ids or []) + if not ids: + return True + query = """ + mutation ProductVariantBulkDelete($ids: [ID!]!) { + productVariantBulkDelete(ids: $ids) { + count + errors { field message } + } + } + """ + variables = {"ids": ids} + data = self.graphql(query, variables) or {} + result = (data or {}).get("productVariantBulkDelete") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor productVariantBulkDelete errors: {errors}") + return result.get("count") or True + + def product_variant_stocks_create(self, variant_id, warehouse_id, quantity=0): + """Create stock entries for a product variant in a Saleor warehouse.""" + query = """ + mutation ProductVariantStocksCreate( + $variantId: ID!, $stocks: [StockInput!]! + ) { + productVariantStocksCreate(variantId: $variantId, stocks: $stocks) { + productVariant { id sku stocks { warehouse { id name } quantity } } + errors { field message code } + } + } + """ + variables = { + "variantId": variant_id, + "stocks": [{"warehouse": warehouse_id, "quantity": int(quantity)}], + } + data = self.graphql(query, variables) + result = (data or {}).get("productVariantStocksCreate") or {} + errors = result.get("errors") or [] + non_dup_errors = [ + e for e in errors if (e or {}).get("code") not in {"ALREADY_EXISTS"} + ] + if non_dup_errors: + raise Exception( + f"Saleor productVariantStocksCreate errors: {non_dup_errors}" + ) + return result.get("productVariant") or True + + def product_variant_channel_listing_update(self, variant_id, listings): + """ + Update channel listings for a variant (prices/cost prices per channel). + """ + query = """ + mutation ProductVariantChannelListingUpdate( + $id: ID!, $listings: [ProductVariantChannelListingAddInput!]! + ) { + productVariantChannelListingUpdate(id: $id, input: $listings) { + variant { id sku } + errors { field message } + } + } + """ + variables = {"id": variant_id, "listings": listings or []} + data = self.graphql(query, variables) + result = (data or {}).get("productVariantChannelListingUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception( + f"Saleor productVariantChannelListingUpdate errors: {errors}" + ) + return result.get("variant") + + # --- Customers --- + def customer_get_by_id(self, customer_id): + query = """ + query User($id: ID!) { + user(id: $id) { + id + email + firstName + lastName + defaultBillingAddress { + country { code } + city + streetAddress1 + postalCode + phone + } + defaultShippingAddress { + country { code } + city + streetAddress1 + postalCode + phone + } + } + } + """ + data = self.graphql(query, {"id": customer_id}) + return data.get("user") + + # Category mutations + def category_create( + self, + input_data, + filename=None, + file_bytes=None, + content_type="application/octet-stream", + ): + query = """ + mutation CategoryCreate( + $name: String!, + $slug: String!, + $description: JSONString, + $seoTitle: String, + $seoDescription: String, + $metadata: [MetadataInput!], + $privateMetadata: [MetadataInput!], + $parent: ID, + $backgroundImage: Upload + ) { + categoryCreate( + input: { + name: $name, + slug: $slug, + description: $description, + seo: { title: $seoTitle, description: $seoDescription }, + metadata: $metadata, + privateMetadata: $privateMetadata, + backgroundImage: $backgroundImage + }, + parent: $parent + ) { + category { id slug } + errors { field message } + } + } + """ + seo = input_data.get("seo") or {} + variables = { + "name": input_data.get("name"), + "slug": input_data.get("slug"), + "description": input_data.get("description"), + "seoTitle": seo.get("title"), + "seoDescription": seo.get("description"), + "metadata": input_data.get("metadata") or [], + "privateMetadata": input_data.get("privateMetadata") or [], + "parent": input_data.get("parent"), + "backgroundImage": None, + } + if file_bytes: + files_map = { + "0": ( + filename or "image", + file_bytes, + content_type, + ["variables.backgroundImage"], + ), + } + data = self.graphql_multipart(query, variables, files_map) + else: + data = self.graphql(query, variables) + result = data.get("categoryCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor categoryCreate errors: {errors}") + category = result.get("category") + _logger.info("Saleor category_create done: %s", category) + return category + + def category_update( + self, + category_id, + input_data, + filename=None, + file_bytes=None, + content_type="application/octet-stream", + ): + query = """ + mutation CategoryUpdate( + $id: ID!, + $name: String, + $slug: String, + $description: JSONString, + $seoTitle: String, + $seoDescription: String, + $metadata: [MetadataInput!], + $privateMetadata: [MetadataInput!], + $backgroundImage: Upload + ) { + categoryUpdate( + id: $id, + input: { + name: $name, + slug: $slug, + description: $description, + seo: { title: $seoTitle, description: $seoDescription }, + metadata: $metadata, + privateMetadata: $privateMetadata, + backgroundImage: $backgroundImage + } + ) { + category { id slug } + errors { field message } + } + } + """ + seo = input_data.get("seo") or {} + variables = { + "id": category_id, + "name": input_data.get("name"), + "slug": input_data.get("slug"), + "description": input_data.get("description"), + "seoTitle": seo.get("title"), + "seoDescription": seo.get("description"), + "metadata": input_data.get("metadata") or [], + "privateMetadata": input_data.get("privateMetadata") or [], + "backgroundImage": None, + } + if file_bytes: + files_map = { + "0": ( + filename or "image", + file_bytes, + content_type, + ["variables.backgroundImage"], + ), + } + data = self.graphql_multipart(query, variables, files_map) + else: + data = self.graphql(query, variables) + result = data.get("categoryUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor categoryUpdate errors: {errors}") + + category = result.get("category") + _logger.info("Saleor category_update done: %s", category) + return category + + # Queries + def category_get_by_slug(self, slug): + query = """ + query CategoryBySlug($slug: String!) { + category(slug: $slug) { + id + slug + } + } + """ + data = self.graphql(query, {"slug": slug}) + return data.get("category") + + # Collection mutations + def collection_create( + self, + input_data, + filename=None, + file_bytes=None, + content_type="application/octet-stream", + ): + query = """ + mutation CollectionCreate($input: CollectionCreateInput!) { + collectionCreate(input: $input) { + collection { id slug } + errors { field message } + } + } + """ + variables = { + "input": { + "name": input_data.get("name"), + "slug": input_data.get("slug"), + "description": input_data.get("description"), + "seo": input_data.get("seo") or {}, + "metadata": input_data.get("metadata") or [], + "privateMetadata": input_data.get("privateMetadata") or [], + } + } + if file_bytes: + files_map = { + "0": ( + filename or "image", + file_bytes, + content_type, + ["variables.input.backgroundImage"], + ), + } + data = self.graphql_multipart(query, variables, files_map) + else: + data = self.graphql(query, variables) + result = data.get("collectionCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor collectionCreate errors: {errors}") + collection = result.get("collection") + _logger.info("Saleor collection_create done: %s", collection) + return collection + + def collection_update( + self, + collection_id, + input_data, + filename=None, + file_bytes=None, + content_type="application/octet-stream", + ): + query = """ + mutation CollectionUpdate($id: ID!, $input: CollectionInput!) { + collectionUpdate(id: $id, input: $input) { + collection { id slug } + errors { field message } + } + } + """ + variables = { + "id": collection_id, + "input": { + "name": input_data.get("name"), + "slug": input_data.get("slug"), + "description": input_data.get("description"), + "seo": input_data.get("seo") or {}, + "metadata": input_data.get("metadata") or [], + "privateMetadata": input_data.get("privateMetadata") or [], + }, + } + if file_bytes: + files_map = { + "0": ( + filename or "image", + file_bytes, + content_type, + ["variables.input.backgroundImage"], + ), + } + data = self.graphql_multipart(query, variables, files_map) + else: + data = self.graphql(query, variables) + result = data.get("collectionUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor collectionUpdate errors: {errors}") + collection = result.get("collection") + _logger.info("Saleor collection_update done: %s", collection) + return collection + + def collection_channel_listing_update( + self, collection_id, add_channels=None, remove_channels=None + ): + """Update channel listings for a collection. + + add_channels: list of dicts, each like + {"channelId": ID, "isPublished": bool, "publicationDate": str|None} + remove_channels: list of channel IDs to remove + """ + query = """ + mutation CollectionChannelListingUpdate( + $id: ID!, $input: CollectionChannelListingUpdateInput! + ) { + collectionChannelListingUpdate(id: $id, input: $input) { + collection { id slug } + errors { field message } + } + } + """ + variables = { + "id": collection_id, + "input": { + "addChannels": add_channels or [], + "removeChannels": remove_channels or [], + }, + } + data = self.graphql(query, variables) + result = data.get("collectionChannelListingUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor collectionChannelListingUpdate errors: {errors}") + return result.get("collection") + + def collection_add_products(self, collection_id, product_ids): + """Add products to a collection. + collection_id: Saleor ID of the collection + product_ids: list of Saleor Product IDs + """ + query = """ + mutation CollectionAddProducts($collectionId: ID!, $products: [ID!]!) { + collectionAddProducts(collectionId: $collectionId, products: $products) { + collection { id } + errors { field message } + } + } + """ + variables = {"collectionId": collection_id, "products": product_ids or []} + data = self.graphql(query, variables) + result = data.get("collectionAddProducts") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor collectionAddProducts errors: {errors}") + return result.get("collection") + + def collection_get_by_slug(self, slug): + query = """ + query CollectionBySlug($slug: String!) { + collection(slug: $slug) { + id + slug + } + } + """ + data = self.graphql(query, {"slug": slug}) + return data.get("collection") + + def collection_channel_listings(self, collection_id): + """Return list of current channel listings for a collection. + Each item: { channel: { id }, isPublished, publicationDate } + """ + query = """ + query CollectionChannels($id: ID!) { + collection(id: $id) { + id + channelListings { + channel { id } + isPublished + publicationDate + } + } + } + """ + data = self.graphql(query, {"id": collection_id}) + col = (data or {}).get("collection") or {} + return col.get("channelListings") or [] + + # --- Vouchers --- + def voucher_get_by_id(self, voucher_id): + query = """ + query VoucherById($id: ID!) { + voucher(id: $id) { + id + name + type + } + } + """ + data = self.graphql(query, {"id": voucher_id}) + return (data or {}).get("voucher") + + def vouchers_search_by_name(self, name, first=50): + """Search vouchers and return exact name match if present.""" + query = """ + query Vouchers($first: Int) { + vouchers(first: $first) { + edges { node { id name } } + } + } + """ + data = self.graphql(query, {"first": int(first)}) + edges = (((data or {}).get("vouchers") or {}).get("edges")) or [] + exact = None + for e in edges: + node = e.get("node") or {} + if node.get("name") == name: + exact = node + break + return exact + + def voucher_channel_listings_get(self, voucher_id): + """Return list of channel IDs currently linked to the voucher in Saleor.""" + query = """ + query VoucherChannelListings($id: ID!) { + voucher(id: $id) { + id + channelListings { channel { id } } + } + } + """ + data = self.graphql(query, {"id": voucher_id}) + voucher = (data or {}).get("voucher") or {} + listings = voucher.get("channelListings") or [] + chan_ids = [] + for listing in listings: + ch = (listing or {}).get("channel") or {} + cid = ch.get("id") + if cid: + chan_ids.append(cid) + return chan_ids + + def voucher_create(self, input_data): + query = """ + mutation VoucherCreate($input: VoucherInput!) { + voucherCreate(input: $input) { + voucher { id name } + errors { field message } + } + } + """ + variables = {"input": input_data or {}} + data = self.graphql(query, variables) + result = (data or {}).get("voucherCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor voucherCreate errors: {errors}") + return result.get("voucher") + + def voucher_update(self, voucher_id, input_data): + query = """ + mutation VoucherUpdate($id: ID!, $input: VoucherInput!) { + voucherUpdate(id: $id, input: $input) { + voucher { id name } + errors { field message } + } + } + """ + variables = {"id": voucher_id, "input": input_data or {}} + data = self.graphql(query, variables) + result = (data or {}).get("voucherUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor voucherUpdate errors: {errors}") + return result.get("voucher") + + def voucher_channel_listing_update( + self, voucher_id, add_channels=None, remove_channels=None + ): + """Update channel listings for a voucher. + + add_channels: list of channel IDs to add + remove_channels: list of channel IDs to remove + """ + query = """ + mutation VoucherChannelListingUpdate( + $id: ID!, $input: VoucherChannelListingInput! + ) { + voucherChannelListingUpdate(id: $id, input: $input) { + voucher { id } + errors { field message } + } + } + """ + variables = { + "id": voucher_id, + "input": { + "addChannels": add_channels or [], + "removeChannels": remove_channels or [], + }, + } + data = self.graphql(query, variables) + result = (data or {}).get("voucherChannelListingUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor voucherChannelListingUpdate errors: {errors}") + return result.get("voucher") + + def voucher_update_add_codes(self, voucher_id, codes): + """Add codes via voucherUpdate(input: { addCodes }). + If codes already exist in Saleor, the API returns errors; we log and continue. + """ + if not codes: + return True + query = """ + mutation VoucherUpdateAddCodes($id: ID!, $codes: [String!]!) { + voucherUpdate(id: $id, input: { addCodes: $codes }) { + voucher { id } + errors { field message } + } + } + """ + variables = {"id": voucher_id, "codes": list(codes)} + data = self.graphql(query, variables) + result = (data or {}).get("voucherUpdate") or {} + errors = result.get("errors") or [] + if errors: + _logger.warning("voucherUpdate(addCodes) returned errors: %s", errors) + return result.get("voucher") or True + + def voucher_metadata_update(self, voucher_id, metadata): + """Update public metadata for a voucher via updateMetadata.""" + if not metadata: + return True + query = """ + mutation UpdateVoucherMetadata($id: ID!, $input: [MetadataInput!]!) { + updateMetadata(id: $id, input: $input) { + item { + ... on Voucher { + id + metadata { key value } + } + } + errors { field message } + } + } + """ + variables = {"id": voucher_id, "input": metadata} + data = self.graphql(query, variables) + result = (data or {}).get("updateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor updateMetadata (voucher) errors: {errors}") + return result.get("item") or True + + # --- Orders (Draft) --- + def draft_order_create(self, input_data): + query = """ + mutation DraftOrderCreate($input: DraftOrderCreateInput!) { + draftOrderCreate(input: $input) { + order { id number } + errors { field message } + } + } + """ + variables = {"input": input_data or {}} + data = self.graphql(query, variables) + result = (data or {}).get("draftOrderCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor draftOrderCreate errors: {errors}") + order = result.get("order") or {} + return order + + def draft_order_lines_create(self, order_id, lines): + query = """ + mutation OrderLinesCreate($id: ID!, $input: [OrderLineCreateInput!]!) { + orderLinesCreate(id: $id, input: $input) { + order { id } + errors { field message } + } + } + """ + input_lines = [ + {"variantId": ln.get("variantId"), "quantity": int(ln.get("quantity") or 0)} + for ln in (lines or []) + ] + variables = {"id": order_id, "input": input_lines} + data = self.graphql(query, variables) + result = (data or {}).get("orderLinesCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor orderLinesCreate errors: {errors}") + return result.get("order") or True + + def order_update_shipping(self, order_id, shipping_method_id): + """Set the shipping method on an order using orderUpdateShipping. + + Args: + order_id (str): Relay ID of the Saleor order + shipping_method_id (str): Relay ID of the Saleor shipping method + """ + if not order_id or not shipping_method_id: + return None + query = """ + mutation OrderUpdateShipping($order: ID!, $input: OrderUpdateShippingInput!) { + orderUpdateShipping(order: $order, input: $input) { + order { + id + deliveryMethod { + __typename + ... on ShippingMethod { id name type } + } + } + errors { field message } + } + } + """ + variables = {"order": order_id, "input": {"shippingMethod": shipping_method_id}} + data = self.graphql(query, variables) + result = (data or {}).get("orderUpdateShipping") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor orderUpdateShipping errors: {errors}") + return result.get("order") + + def order_available_shipping_methods(self, order_id): + """Return list of available ShippingMethod nodes for an order. + + Each item has fields: { id, name } where id is of GraphQL type ShippingMethod. + """ + if not order_id: + return [] + query = """ + query OrderAvailableShippingMethods($id: ID!) { + order(id: $id) { + id + availableShippingMethods { + id + name + } + } + } + """ + data = self.graphql(query, {"id": order_id}) + order = (data or {}).get("order") or {} + return order.get("availableShippingMethods") or [] + + def order_mark_as_paid(self, order_id, transaction_reference=None): + """Mark an order as paid in Saleor.""" + if not order_id: + return None + query = """ + mutation OrderMarkAsPaid($order: ID!, $transactionReference: String) { + orderMarkAsPaid(id: $order, transactionReference: $transactionReference) { + order { id status number } + errors { field message } + } + } + """ + variables = {"order": order_id, "transactionReference": transaction_reference} + data = self.graphql(query, variables) + result = (data or {}).get("orderMarkAsPaid") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor orderMarkAsPaid errors: {errors}") + return result.get("order") + + def order_get_by_id(self, order_id): + """Fetch an order by ID with fields used for Odoo upsert behavior.""" + query = """ + query OrderById($id: ID!) { + order(id: $id) { + id + number + status + channel { id slug } + metadata { key value } + privateMetadata { key value } + user { id email firstName lastName } + billingAddress { + firstName lastName companyName phone + streetAddress1 streetAddress2 city postalCode + country { code } + countryArea + } + shippingAddress { + firstName lastName companyName phone + streetAddress1 streetAddress2 city postalCode + country { code } + countryArea + } + shippingMethod { id name } + shippingPrice { + net { amount currency } + gross { amount currency } + } + lines { + id + quantity + productName + variant { id } + unitPrice { net { amount currency } gross { amount currency } } + } + discounts { amount { amount currency } reason } + payments { + id + gateway + chargeStatus + total { amount currency } + capturedAmount { amount currency } + pspReference + } + } + } + """ + data = self.graphql(query, {"id": order_id}) + return (data or {}).get("order") + + def draft_order_complete(self, order_id): + """Complete a draft order using draftOrderComplete mutation.""" + if not order_id: + return None + query = """ + mutation DraftOrderComplete($id: ID!) { + draftOrderComplete(id: $id) { + order { + id + number + status + } + errors { field message } + } + } + """ + data = self.graphql(query, {"id": order_id}) + result = (data or {}).get("draftOrderComplete") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor draftOrderComplete errors: {errors}") + return result.get("order") + + def draft_order_update(self, order_id, input_data): + """Update a draft order's base fields (addresses, user/email, channel).""" + query = """ + mutation DraftOrderUpdate($id: ID!, $input: DraftOrderInput!) { + draftOrderUpdate(id: $id, input: $input) { + order { id number } + errors { field message } + } + } + """ + variables = {"id": order_id, "input": input_data or {}} + data = self.graphql(query, variables) + result = (data or {}).get("draftOrderUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor draftOrderUpdate errors: {errors}") + return result.get("order") + + def order_line_delete(self, order_line_id): + """Delete a single order line by ID (works for draft orders).""" + query = """ + mutation OrderLineDelete($id: ID!) { + orderLineDelete(id: $id) { + order { id } + errors { field message } + } + } + """ + data = self.graphql(query, {"id": order_line_id}) + result = (data or {}).get("orderLineDelete") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor orderLineDelete errors: {errors}") + return result.get("order") or True + + def voucher_catalogues_add( + self, + voucher_id, + products=None, + collections=None, + categories=None, + variants=None, + ): + """Attach catalogues to a voucher via voucherCataloguesAdd. + All params are lists of Saleor IDs. Any empty list is treated as []. + """ + products = products or [] + collections = collections or [] + categories = categories or [] + variants = variants or [] + if not any([products, collections, categories, variants]): + return True + query = """ + mutation VoucherCataloguesAdd( + $id: ID!, + $products: [ID!], + $collections: [ID!], + $categories: [ID!], + $variants: [ID!] + ) { + voucherCataloguesAdd( + id: $id, + input: { + products: $products, + collections: $collections, + categories: $categories, + variants: $variants + } + ) { + voucher { id } + errors { field message } + } + } + """ + variables = { + "id": voucher_id, + "products": products, + "collections": collections, + "categories": categories, + "variants": variants, + } + data = self.graphql(query, variables) + result = (data or {}).get("voucherCataloguesAdd") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor voucherCataloguesAdd errors: {errors}") + return result.get("voucher") or True + + def voucher_private_metadata_update(self, voucher_id, private_metadata): + """Update private metadata for a voucher via updatePrivateMetadata.""" + if not private_metadata: + return True + query = """ + mutation UpdateVoucherPrivateMetadata($id: ID!, $input: [MetadataInput!]!) { + updatePrivateMetadata(id: $id, input: $input) { + item { + ... on Voucher { + id + privateMetadata { key value } + } + } + errors { field message } + } + } + """ + variables = {"id": voucher_id, "input": private_metadata} + data = self.graphql(query, variables) + result = (data or {}).get("updatePrivateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor updatePrivateMetadata (voucher) errors: {errors}") + return result.get("item") or True + + # --- Gift Cards --- + def gift_card_create(self, input_data): + """Create a gift card in Saleor and return the giftCard node. + + Expects input_data to conform to GiftCardCreateInput fields, e.g.: + { + "isActive": True, + "balance": {"amount": 10.0, "currency": "USD"}, + "userEmail": "test@example.com", + "channelId": "...", + "expiryDate": "YYYY-MM-DD", + "tags": ["Birthday"], + "note": "...", + } + """ + query = """ + mutation GiftCardCreate($input: GiftCardCreateInput!) { + giftCardCreate(input: $input) { + giftCard { id code displayCode } + errors { field message } + } + } + """ + variables = {"input": input_data or {}} + data = self.graphql(query, variables) + result = (data or {}).get("giftCardCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor giftCardCreate errors: {errors}") + return result.get("giftCard") + + def gift_card_update(self, giftcard_id, input_data): + """Update a gift card in Saleor and return the giftCard node.""" + query = """ + mutation GiftCardUpdate($id: ID!, $input: GiftCardUpdateInput!) { + giftCardUpdate(id: $id, input: $input) { + giftCard { id code displayCode } + errors { field message } + } + } + """ + variables = {"id": giftcard_id, "input": input_data or {}} + data = self.graphql(query, variables) + result = (data or {}).get("giftCardUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor giftCardUpdate errors: {errors}") + return result.get("giftCard") + + def gift_card_metadata_update(self, giftcard_id, metadata): + """Update public metadata for a gift card via updateMetadata.""" + if not metadata: + return True + query = """ + mutation UpdateGiftCardMetadata($id: ID!, $input: [MetadataInput!]!) { + updateMetadata(id: $id, input: $input) { + item { + ... on GiftCard { + id + metadata { key value } + } + } + errors { field message } + } + } + """ + variables = {"id": giftcard_id, "input": metadata} + data = self.graphql(query, variables) + result = (data or {}).get("updateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor updateMetadata (gift card) errors: {errors}") + return result.get("item") or True + + def gift_card_private_metadata_update(self, giftcard_id, private_metadata): + """Update private metadata for a gift card via updatePrivateMetadata.""" + if not private_metadata: + return True + query = """ + mutation UpdateGiftCardPrivateMetadata($id: ID!, $input: [MetadataInput!]!) { + updatePrivateMetadata(id: $id, input: $input) { + item { + ... on GiftCard { + id + privateMetadata { key value } + } + } + errors { field message } + } + } + """ + variables = {"id": giftcard_id, "input": private_metadata} + data = self.graphql(query, variables) + result = (data or {}).get("updatePrivateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception( + f"Saleor updatePrivateMetadata (gift card) errors: {errors}" + ) + return result.get("item") or True + + def product_type_metadata_update(self, product_type_id, metadata): + """Update public metadata for a product type via updateMetadata.""" + if not metadata: + return True + query = """ + mutation UpdateProductTypeMetadata($id: ID!, $input: [MetadataInput!]!) { + updateMetadata(id: $id, input: $input) { + item { + ... on ProductType { + id + } + } + errors { + field + message + } + } + } + """ + variables = {"id": product_type_id, "input": metadata} + data = self.graphql(query, variables) + result = (data or {}).get("updateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor updateMetadata (product type) errors: {errors}") + return result.get("item") or True + + def product_type_private_metadata_update(self, product_type_id, private_metadata): + """Update private metadata for a product type via updatePrivateMetadata.""" + if not private_metadata: + return True + query = """ + mutation UpdateProductTypePrivateMetadata($id: ID!, $input: [MetadataInput!]!) { + updatePrivateMetadata(id: $id, input: $input) { + item { + ... on ProductType { + id + } + } + errors { + field + message + } + } + } + """ + variables = {"id": product_type_id, "input": private_metadata} + data = self.graphql(query, variables) + result = (data or {}).get("updatePrivateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception( + f"Saleor updatePrivateMetadata (product type) errors: {errors}" + ) + return result.get("item") or True + + def tax_class_search_by_name(self, name, first=50): + """Find a TaxClass by exact name.""" + query = """ + query TaxClasses($first: Int) { + taxClasses(first: $first) { + edges { node { id name countries { country { code } rate } } } + } + } + """ + variables = {"first": int(first)} + data = self.graphql(query, variables) + edges = (((data or {}).get("taxClasses") or {}).get("edges")) or [] + exact = None + for e in edges: + node = e.get("node") or {} + if node.get("name") == name: + exact = node + break + return exact + + def tax_class_create(self, input_data): + """Create a Tax Class.""" + query = """ + mutation TaxClassCreate($input: TaxClassCreateInput!) { + taxClassCreate(input: $input) { + taxClass { id name countries { country { code } rate } } + errors { field message code } + } + } + """ + variables = {"input": input_data or {}} + data = self.graphql(query, variables) + res = (data or {}).get("taxClassCreate") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor taxClassCreate errors: {errors}") + return res.get("taxClass") + + def tax_class_update(self, tax_class_id, input_data): + query = """ + mutation TaxClassUpdate($id: ID!, $input: TaxClassUpdateInput!) { + taxClassUpdate(id: $id, input: $input) { + taxClass { id name countries { country { code } rate } } + errors { field message code } + } + } + """ + variables = {"id": tax_class_id, "input": input_data or {}} + data = self.graphql(query, variables) + res = (data or {}).get("taxClassUpdate") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor taxClassUpdate errors: {errors}") + return res.get("taxClass") + + # --- Attribute helpers --- + def attribute_get_by_slug(self, slug): + query = """ + query AttributeBySlug($slug: String!) { + attribute(slug: $slug) { id slug } + } + """ + data = self.graphql(query, {"slug": slug}) + return data.get("attribute") + + def attribute_create(self, input_data): + query = """ + mutation AttributeCreate($input: AttributeCreateInput!) { + attributeCreate(input: $input) { + attribute { id slug } + errors { field message } + } + } + """ + values = input_data.get("values") or [] + variables = { + "input": { + "name": input_data.get("name"), + "slug": input_data.get("slug"), + # Saleor requires a non-null type for attribute creation + "type": "PRODUCT_TYPE", + # Use DROPDOWN for simple selectable values + "inputType": "DROPDOWN", + "values": [{"name": v} for v in values], + } + } + data = self.graphql(query, variables) + result = data.get("attributeCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor attributeCreate errors: {errors}") + return result.get("attribute") + + def attribute_update(self, attribute_id, input_data): + query = """ + mutation AttributeUpdate($id: ID!, $input: AttributeUpdateInput!) { + attributeUpdate(id: $id, input: $input) { + attribute { id slug } + errors { field message } + } + } + """ + variables = { + "id": attribute_id, + "input": { + "name": input_data.get("name"), + "slug": input_data.get("slug"), + # Do not send metadata/privateMetadata here; + # not supported in this mutation + }, + } + data = self.graphql(query, variables) + result = data.get("attributeUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor attributeUpdate errors: {errors}") + return result.get("attribute") + + def attribute_value_create(self, attribute_id, name): + query = """ + mutation AttributeValueCreate($attribute: ID!, $name: String!) { + attributeValueCreate(attribute: $attribute, input: {name: $name}) { + attribute { id } + errors { field message } + } + } + """ + data = self.graphql(query, {"attribute": attribute_id, "name": name}) + result = data.get("attributeValueCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor attributeValueCreate errors: {errors}") + return result.get("attribute") + + def channel_get_by_slug(self, slug): + query = """ + query ChannelBySlug($slug: String!) { + channel(slug: $slug) { + id + slug + name + isActive + currencyCode + defaultCountry { code } + } + } + """ + data = self.graphql(query, {"slug": slug}) + return data.get("channel") + + def channel_get_by_id(self, channel_id): + query = """ + query ChannelById($id: ID!) { + channel(id: $id) { + id + slug + name + isActive + currencyCode + defaultCountry { code } + } + } + """ + data = self.graphql(query, {"id": channel_id}) + return data.get("channel") + + def channel_create(self, input_data): + query = """ + mutation ChannelCreate($input: ChannelCreateInput!) { + channelCreate(input: $input) { + channel { id slug name isActive currencyCode defaultCountry { code } } + errors { field message code } + } + } + """ + variables = {"input": input_data or {}} + data = self.graphql(query, variables) + res = (data or {}).get("channelCreate") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor channelCreate errors: {errors}") + return res.get("channel") + + def channel_update(self, channel_id, input_data): + query = """ + mutation ChannelUpdate($id: ID!, $input: ChannelUpdateInput!) { + channelUpdate(id: $id, input: $input) { + channel { id slug name isActive defaultCountry { code } } + errors { field message code } + } + } + """ + variables = {"id": channel_id, "input": input_data or {}} + data = self.graphql(query, variables) + res = (data or {}).get("channelUpdate") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor channelUpdate errors: {errors}") + return res.get("channel") + + def tax_configuration_get_by_channel_id(self, channel_id): + """Return TaxConfiguration node for a given channel ID, if any.""" + if not channel_id: + return None + query = """ + query TaxConfigurationByChannel($id: ID!) { + channel(id: $id) { + id + taxConfiguration { + id + pricesEnteredWithTax + } + } + } + """ + data = self.graphql(query, {"id": channel_id}) + ch = (data or {}).get("channel") or {} + return ch.get("taxConfiguration") + + def channel_tax_configuration_update(self, channel_id, prices_entered_with_tax): + """Update tax configuration for a channel identified by its Saleor ID. + + Used by the Odoo connector to control pricesEnteredWithTax on + Saleor channels so that behavior matches Odoo pricing semantics. + """ + if not channel_id: + return None + cfg = self.tax_configuration_get_by_channel_id(channel_id) + cfg_id = (cfg or {}).get("id") + if not cfg_id: + return None + query = """ + mutation ChannelTaxConfigUpdate( + $id: ID!, + $pricesEnteredWithTax: Boolean! + ) { + taxConfigurationUpdate( + id: $id, + input: { pricesEnteredWithTax: $pricesEnteredWithTax } + ) { + taxConfiguration { + id + pricesEnteredWithTax + } + errors { field message code } + } + } + """ + variables = { + "id": cfg_id, + "pricesEnteredWithTax": bool(prices_entered_with_tax), + } + data = self.graphql(query, variables) + res = (data or {}).get("taxConfigurationUpdate") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor taxConfigurationUpdate errors: {errors}") + return res.get("taxConfiguration") + + def attribute_values_list(self, attribute_id): + query = """ + query AttributeValues($id: ID!, $first: Int!) { + attribute(id: $id) { + id + choices(first: $first) { edges { node { id name } } } + } + } + """ + data = self.graphql(query, {"id": attribute_id, "first": 200}) + attr = data.get("attribute") or {} + edges = ((attr.get("choices") or {}).get("edges")) or [] + return [e["node"]["name"] for e in edges] + + # --- Product mutations --- + def product_create(self, input_data): + query = """ + mutation ProductCreate($input: ProductCreateInput!) { + productCreate(input: $input) { + product { id slug } + errors { field message } + } + } + """ + variables = { + "input": { + "name": input_data.get("name"), + "slug": input_data.get("slug"), + "description": input_data.get("description"), + "seo": input_data.get("seo") or {}, + "metadata": input_data.get("metadata") or [], + "privateMetadata": input_data.get("privateMetadata") or [], + "rating": input_data.get("rating"), + "productType": input_data.get("productType"), + "category": input_data.get("category"), + "taxClass": input_data.get("taxClass"), + } + } + data = self.graphql(query, variables) + result = data.get("productCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor productCreate errors: {errors}") + return result.get("product") + + def product_channel_listings(self, product_id): + """Return list of current channel listings for a product. + Each item: { channel: { id }, isPublished, publicationDate } + """ + query = """ + query ProductChannels($id: ID!) { + product(id: $id) { + id + channelListings { + channel { id } + isPublished + publicationDate + } + } + } + """ + data = self.graphql(query, {"id": product_id}) + prod = (data or {}).get("product") or {} + return prod.get("channelListings") or [] + + def product_channel_listing_update( + self, product_id, update_channels=None, remove_channels=None + ): + """Update channel listings for a product using delta. + + update_channels: list of dicts, e.g. {"channelId": ID, "isPublished": bool} + remove_channels: list of channel IDs to remove + """ + query = """ + mutation ProductChannelListingUpdate( + $id: ID!, $input: ProductChannelListingUpdateInput! + ) { + productChannelListingUpdate(id: $id, input: $input) { + product { id slug } + errors { field message } + } + } + """ + variables = { + "id": product_id, + "input": { + "updateChannels": update_channels or [], + "removeChannels": remove_channels or [], + }, + } + data = self.graphql(query, variables) + result = data.get("productChannelListingUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor productChannelListingUpdate errors: {errors}") + return result.get("product") + + def product_update(self, product_id, input_data): + query = """ + mutation ProductUpdate($id: ID!, $input: ProductInput!) { + productUpdate(id: $id, input: $input) { + product { id slug } + errors { field message } + } + } + """ + variables = { + "id": product_id, + "input": { + "name": input_data.get("name"), + "slug": input_data.get("slug"), + "description": input_data.get("description"), + "seo": input_data.get("seo") or {}, + "metadata": input_data.get("metadata") or [], + "privateMetadata": input_data.get("privateMetadata") or [], + "rating": input_data.get("rating"), + "category": input_data.get("category"), + "taxClass": input_data.get("taxClass"), + }, + } + data = self.graphql(query, variables) + result = data.get("productUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor productUpdate errors: {errors}") + return result.get("product") + + def product_get_by_slug(self, slug): + query = """ + query ProductBySlug($slug: String!) { + product(slug: $slug) { id slug } + } + """ + data = self.graphql(query, {"slug": slug}) + return data.get("product") + + def product_media_create( + self, product_id, filename, file_bytes, content_type="application/octet-stream" + ): + query = """ + mutation ProductMediaCreate($product: ID!, $image: Upload!, $alt: String) { + productMediaCreate(input: { product: $product, image: $image, alt: $alt }) { + media { id } + errors { field message } + } + } + """ + variables = {"product": product_id, "image": None, "alt": filename} + files_map = { + "0": (filename or "image", file_bytes, content_type, ["variables.image"]) + } + data = self.graphql_multipart(query, variables, files_map) + result = data.get("productMediaCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor productMediaCreate errors: {errors}") + return result.get("media") + + def product_media_delete(self, media_id): + """Delete a product media from Saleor.""" + query = """ + mutation ProductMediaDelete($id: ID!) { + productMediaDelete(id: $id) { + product { + id + } + errors { + field + message + } + } + } + """ + try: + result = self.graphql(query, {"id": media_id}) + media_data = result.get("productMediaDelete") or {} + errors = media_data.get("errors") or [] + if errors: + _logger.error("Failed to delete product media %s: %s", media_id, errors) + return False + + _logger.info("Successfully deleted product media %s", media_id) + return True + + except Exception as e: + _logger.error( + "Error deleting product media %s: %s", media_id, str(e), exc_info=True + ) + return False + + # --- Product Type helpers --- + def product_type_search_by_name(self, name): + """Return first ProductType by name search (case-insensitive).""" + query = """ + query ProductTypes($first: Int!, $search: String) { + productTypes(first: $first, filter: {search: $search}) { + edges { node { id name } } + } + } + """ + data = self.graphql(query, {"first": 5, "search": name}) + edges = (((data or {}).get("productTypes") or {}).get("edges")) or [] + return edges[0]["node"] if edges else None + + # --- Promotion helpers --- + def promotion_get_by_id(self, promotion_id): + """Get a Promotion by ID.""" + query = """ + query PromotionById($id: ID!) { + promotion(id: $id) { id name } + } + """ + data = self.graphql(query, {"id": promotion_id}) + return (data or {}).get("promotion") + + def promotion_create(self, input_data): + query = """ + mutation PromotionCreate($input: PromotionCreateInput!) { + promotionCreate(input: $input) { + promotion { id name } + errors { field message } + } + } + """ + variables = {"input": input_data or {}} + data = self.graphql(query, variables) + res = (data or {}).get("promotionCreate") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor promotionCreate errors: {errors}") + return res.get("promotion") + + def promotion_update(self, promotion_id, input_data): + query = """ + mutation PromotionUpdate($id: ID!, $input: PromotionUpdateInput!) { + promotionUpdate(id: $id, input: $input) { + promotion { id name } + errors { field message } + } + } + """ + variables = {"id": promotion_id, "input": input_data or {}} + data = self.graphql(query, variables) + res = (data or {}).get("promotionUpdate") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor promotionUpdate errors: {errors}") + return res.get("promotion") + + def promotion_rules_list(self, promotion_id): + """List rules for a promotion.""" + query = """ + query PromotionRules($id: ID!) { + promotion(id: $id) { + id + rules { id name } + } + } + """ + data = self.graphql(query, {"id": promotion_id}) + prom = (data or {}).get("promotion") or {} + return (prom or {}).get("rules") or [] + + def promotion_rule_create(self, promotion_id, input_data, channels=None): + query = """ + mutation PromotionRuleCreate( + $promotionId: ID!, $input: PromotionRuleCreateInput! + ) { + promotionRuleCreate(promotionId: $promotionId, input: $input) { + promotionRule { id name } + errors { field message } + } + } + """ + # Merge channels into input if provided + payload = dict(input_data or {}) + if channels: + payload["channels"] = list(channels) + variables = {"promotionId": promotion_id, "input": payload} + data = self.graphql(query, variables) + res = (data or {}).get("promotionRuleCreate") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor promotionRuleCreate errors: {errors}") + return res.get("promotionRule") + + def promotion_rule_update( + self, rule_id, input_data, add_channels=None, remove_channels=None + ): + query = """ + mutation PromotionRuleUpdate($id: ID!, $input: PromotionRuleUpdateInput!) { + promotionRuleUpdate(id: $id, input: $input) { + promotionRule { id name } + errors { field message } + } + } + """ + # Merge channel delta into input if provided + payload = dict(input_data or {}) + if add_channels: + payload["addChannels"] = list(add_channels) + if remove_channels: + payload["removeChannels"] = list(remove_channels) + variables = {"id": rule_id, "input": payload} + data = self.graphql(query, variables) + res = (data or {}).get("promotionRuleUpdate") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor promotionRuleUpdate errors: {errors}") + return res.get("promotionRule") + + def promotion_rule_delete(self, rule_id): + query = """ + mutation PromotionRuleDelete($id: ID!) { + promotionRuleDelete(id: $id) { + errors { field message } + } + } + """ + data = self.graphql(query, {"id": rule_id}) + res = (data or {}).get("promotionRuleDelete") or {} + errors = res.get("errors") or [] + if errors: + raise Exception(f"Saleor promotionRuleDelete errors: {errors}") + return True + + def product_type_create(self, input_data): + """Create ProductType with a flexible input payload.""" + query = """ + mutation ProductTypeCreate($input: ProductTypeInput!) { + productTypeCreate(input: $input) { + productType { id name } + errors { field message } + } + } + """ + variables = { + "input": {k: v for k, v in (input_data or {}).items() if v is not None} + } + data = self.graphql(query, variables) + result = (data or {}).get("productTypeCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor productTypeCreate errors: {errors}") + return result.get("productType") + + def product_type_update(self, ptype_id, input_data): + """Update ProductType by ID.""" + query = """ + mutation ProductTypeUpdate($id: ID!, $input: ProductTypeInput!) { + productTypeUpdate(id: $id, input: $input) { + productType { id name } + errors { field message } + } + } + """ + variables = { + "id": ptype_id, + "input": {k: v for k, v in (input_data or {}).items() if v is not None}, + } + data = self.graphql(query, variables) + result = (data or {}).get("productTypeUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor productTypeUpdate errors: {errors}") + return result.get("productType") + + # --- Product Variant helpers --- + def product_variant_get_by_id(self, variant_id): + """Fetch a product variant by ID from Saleor.""" + query = """ + query ProductVariant($id: ID!) { + productVariant(id: $id) { + id + name + sku + product { + id + name + } + stocks { + warehouse { id name } + quantity + } + } + } + """ + data = self.graphql(query, {"id": variant_id}) + return data.get("productVariant") + + def product_variant_create( + self, product_id, sku, name, attributes=None, weight=None + ): + """Create a product variant in Saleor.""" + query = """ + mutation ProductVariantCreate($input: ProductVariantCreateInput!) { + productVariantCreate(input: $input) { + productVariant { + id + sku + name + } + errors { + field + message + } + } + } + """ + + variables = { + "input": { + "product": product_id, + "sku": sku, + "name": name, + "attributes": attributes or [], + "trackInventory": True, + } + } + # Include weight only if provided and positive + try: + if weight is not None: + w = float(weight) + if w > 0: + variables["input"]["weight"] = w + except Exception as e: + # Log and skip invalid weight to keep mutation robust + _logger.debug( + "product_variant_create: invalid weight '%s' for SKU %s: %s", + weight, + sku, + e, + ) + + data = self.graphql(query, variables) + result = data.get("productVariantCreate", {}) + + if result.get("errors"): + error_messages = [ + e.get("message", "Unknown error") for e in result["errors"] + ] + raise Exception(", ".join(error_messages)) + + return result.get("productVariant") + + def product_variant_update(self, variant_id, payload): + """Update an existing product variant in Saleor. + + Args: + variant_id (str): The Saleor variant ID + payload (dict): The update payload + + Returns: + dict: The updated variant data + """ + query = """ + mutation ProductVariantUpdate($id: ID!, $input: ProductVariantInput!) { + productVariantUpdate(id: $id, input: $input) { + productVariant { + id + sku + name + } + errors { + field + message + } + } + } + """ + + variables = {"id": variant_id, "input": payload} + + data = self.graphql(query, variables) + result = data.get("productVariantUpdate", {}) + + if result.get("errors"): + error_messages = [ + e.get("message", "Unknown error") for e in result["errors"] + ] + raise Exception(", ".join(error_messages)) + + return result.get("productVariant") + + def shipping_zone_create(self, input_data): + query = """ + mutation ShippingZoneCreate($input: ShippingZoneCreateInput!) { + shippingZoneCreate(input: $input) { + shippingZone { + id + name + description + default + countries { code } + warehouses { id name } + channels { id slug } + } + errors { + field + code + message + warehouses + channels + } + } + } + """ + variables = {"input": input_data or {}} + data = self.graphql(query, variables) + result = data.get("shippingZoneCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor shippingZoneCreate errors: {errors}") + zone = result.get("shippingZone") + _logger.info("Saleor shipping_zone_create done: %s", zone) + return zone + + def shipping_zone_update(self, zone_id, input_data): + query = """ + mutation ShippingZoneUpdate($id: ID!, $input: ShippingZoneUpdateInput!) { + shippingZoneUpdate(id: $id, input: $input) { + shippingZone { + id + name + description + default + countries { code } + warehouses { id name } + channels { id slug } + } + errors { + field + code + message + warehouses + channels + } + } + } + """ + variables = {"id": zone_id, "input": input_data or {}} + data = self.graphql(query, variables) + result = data.get("shippingZoneUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor shippingZoneUpdate errors: {errors}") + zone = result.get("shippingZone") + _logger.info("Saleor shipping_zone_update done: %s", zone) + return zone + + def shipping_zone_get_by_id(self, zone_id): + """Fetch a shipping zone strictly by ID (Relay ID).""" + query = """ + query ShippingZoneById($id: ID!) { + shippingZone(id: $id) { + id + name + description + default + countries { code } + warehouses { id name } + channels { id slug } + } + } + """ + data = self.graphql(query, {"id": zone_id}) + zone = data.get("shippingZone") + return (zone and zone.get("id")) or None + + def shipping_method_create(self, zone_id, input_data): + """Create a shipping method (price/weight based) within a shipping zone.""" + # Use the shippingZoneId approach which seems to be the correct way + query = """ + mutation ShippingPriceCreate($input: ShippingPriceInput!) { + shippingPriceCreate(input: $input) { + shippingMethod { + id + name + type + minimumDeliveryDays + maximumDeliveryDays + } + errors { + field + code + message + } + } + } + """ + supported_fields = { + "name", + "type", + "description", + "minimumDeliveryDays", + "maximumDeliveryDays", + "minimumOrderWeight", + "maximumOrderWeight", + "taxClass", + } + input_payload = { + key: value + for key, value in (input_data or {}).items() + if key in supported_fields + } + + # Add the shipping zone ID - this seems to be required + input_payload["shippingZone"] = zone_id + + variables = {"input": input_payload} + _logger.debug("shipping_method_create variables: %s", variables) + + data = self.graphql(query, variables) + result = data.get("shippingPriceCreate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor shippingPriceCreate errors: {errors}") + + method = result.get("shippingMethod") + method_id = method.get("id") if method else None + + if method_id and input_data: + try: + # Handle postal code rules via update + update_payload = {} + if "inclusionType" in input_data or "addPostalCodeRules" in input_data: + if "inclusionType" in input_data: + update_payload["inclusionType"] = input_data["inclusionType"] + if "addPostalCodeRules" in input_data: + update_payload["addPostalCodeRules"] = input_data[ + "addPostalCodeRules" + ] + + if update_payload: + self.shipping_method_update(method_id, update_payload) + + # Handle metadata separately using metadata update mutations + if "metadata" in input_data and input_data["metadata"]: + self.shipping_method_metadata_update( + method_id, input_data["metadata"] + ) + + if "privateMetadata" in input_data and input_data["privateMetadata"]: + self.shipping_method_private_metadata_update( + method_id, input_data["privateMetadata"] + ) + + except Exception as e: + _logger.warning( + "Failed to update shipping method %s with additional fields: %s", + method_id, + e, + ) + + _logger.info("Saleor shipping_method_create done: %s", method) + return method + + # --- Warehouses --- + def warehouse_create(self, input_data): + """Create a Warehouse in Saleor. + Expected minimal input: { name: str } + """ + query = """ + mutation WarehouseCreate($input: WarehouseCreateInput!) { + createWarehouse(input: $input) { + warehouse { id name slug } + errors { field code message } + } + } + """ + variables = {"input": input_data or {}} + data = self.graphql(query, variables) + # The mutation is named createWarehouse; parse that key + result = data.get("createWarehouse") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor warehouseCreate errors: {errors}") + return result.get("warehouse") + + def warehouse_update(self, warehouse_id, input_data): + """Update a Warehouse in Saleor by ID.""" + query = """ + mutation WarehouseUpdate($id: ID!, $input: WarehouseUpdateInput!) { + updateWarehouse(id: $id, input: $input) { + warehouse { id name slug } + errors { field code message } + } + } + """ + variables = {"id": warehouse_id, "input": input_data or {}} + data = self.graphql(query, variables) + # The mutation is named updateWarehouse; parse that key + result = data.get("updateWarehouse") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor warehouseUpdate errors: {errors}") + return result.get("warehouse") + + def warehouse_get_by_id(self, warehouse_id): + """Fetch a Warehouse by its Relay ID from Saleor. + + Returns the warehouse dict on success, or None if not found. + """ + query = """ + query WarehouseById($id: ID!) { + warehouse(id: $id) { + id + name + slug + } + } + """ + variables = {"id": warehouse_id} + data = self.graphql(query, variables) + # For consistency with other helpers, return the dict or None + return data.get("warehouse") + + def shipping_method_metadata_update(self, method_id, metadata): + """Update metadata for a shipping method.""" + query = """ + mutation UpdateMetadata($id: ID!, $input: [MetadataInput!]!) { + updateMetadata(id: $id, input: $input) { + item { + ... on ShippingMethodType { + id + metadata { + key + value + } + } + } + errors { + field + code + message + } + } + } + """ + variables = {"id": method_id, "input": metadata} + data = self.graphql(query, variables) + result = data.get("updateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor updateMetadata errors: {errors}") + _logger.info( + "Saleor shipping_method_metadata_update done for method %s", method_id + ) + return result.get("item") + + def shipping_method_private_metadata_update(self, method_id, private_metadata): + """Update private metadata for a shipping method.""" + query = """ + mutation UpdatePrivateMetadata($id: ID!, $input: [MetadataInput!]!) { + updatePrivateMetadata(id: $id, input: $input) { + item { + ... on ShippingMethodType { + id + privateMetadata { + key + value + } + } + } + errors { + field + code + message + } + } + } + """ + variables = {"id": method_id, "input": private_metadata} + data = self.graphql(query, variables) + result = data.get("updatePrivateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor updatePrivateMetadata errors: {errors}") + _logger.info( + "Saleor shipping_method_private_metadata_update done for method %s", + method_id, + ) + return result.get("item") + + def shipping_method_update(self, method_id, input_data): + """Update a shipping method (price/weight based).""" + query = """ + mutation ShippingPriceUpdate($id: ID!, $input: ShippingPriceInput!) { + shippingPriceUpdate(id: $id, input: $input) { + shippingMethod { + id + name + type + minimumDeliveryDays + maximumDeliveryDays + } + errors { + field + code + message + } + } + } + """ + # Filter input_data to only include fields supported by ShippingPriceInput + supported_fields = { + "name", + "description", + "minimumDeliveryDays", + "maximumDeliveryDays", + "inclusionType", + "addPostalCodeRules", + "deletePostalCodeRules", + "minimumOrderWeight", + "maximumOrderWeight", + "addProducts", + "removeProducts", + "taxClass", + } + input_payload = { + key: value + for key, value in (input_data or {}).items() + if key in supported_fields + } + + variables = {"id": method_id, "input": input_payload} + data = self.graphql(query, variables) + result = data.get("shippingPriceUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor shippingPriceUpdate errors: {errors}") + method = result.get("shippingMethod") + _logger.info("Saleor shipping_method_update done: %s", method) + return method + + def shipping_method_channel_listing_update( + self, method_id, add_channels=None, remove_channels=None + ): + """Update channel listings for a shipping method.""" + query = """ + mutation ShippingMethodChannelListingUpdate( + $id: ID!, $input: ShippingMethodChannelListingInput! + ) { + shippingMethodChannelListingUpdate(id: $id, input: $input) { + shippingMethod { + id + name + type + minimumDeliveryDays + maximumDeliveryDays + } + errors { + field + code + message + } + } + } + """ + input_obj = {} + if add_channels: + input_obj["addChannels"] = add_channels + if remove_channels: + input_obj["removeChannels"] = remove_channels + + variables = {"id": method_id, "input": input_obj} + data = self.graphql(query, variables) + result = data.get("shippingMethodChannelListingUpdate") or {} + errors = result.get("errors") or [] + if errors: + raise Exception( + f"Saleor shippingMethodChannelListingUpdate errors: {errors}" + ) + method = result.get("shippingMethod") + _logger.info("Saleor shipping_method_channel_listing_update done: %s", method) + return method + + def shipping_method_get_postal_codes(self, method_id): + """Get existing postal code rules for a shipping method + by searching through shipping zones.""" + try: + # Get all shipping zones and search for the method + zones_query = """ + query ShippingZones($first: Int!) { + shippingZones(first: $first) { + edges { + node { + id + shippingMethods { + id + postalCodeRules { + id + start + end + inclusionType + } + } + } + } + } + } + """ + zones_data = self.graphql(zones_query, {"first": 100}) + edges = (((zones_data or {}).get("shippingZones") or {}).get("edges")) or [] + + # Search for the method in all zones + for edge in edges: + zone = edge.get("node", {}) + methods = zone.get("shippingMethods", []) + for method in methods: + if method.get("id") == method_id: + postal_rules = method.get("postalCodeRules", []) + _logger.debug( + "Found %d existing postal code rules for method %s", + len(postal_rules), + method_id, + ) + return postal_rules + + _logger.debug("No postal code rules found for method %s", method_id) + return [] + + except Exception as e: + _logger.warning( + "Failed to get postal codes for method %s: %s", method_id, e + ) + return [] + + def shipping_method_get_excluded_products(self, method_id): + """Get existing excluded products for a shipping method + by searching through shipping zones.""" + try: + # First, try to get excluded products using the documented API + zones_query = """ + query ShippingZones($first: Int!) { + shippingZones(first: $first) { + edges { + node { + id + shippingMethods { + id + excludedProducts(first: 100) { + edges { + node { + id + name + } + } + } + } + } + } + } + } + """ + zones_data = self.graphql(zones_query, {"first": 100}) + edges = (((zones_data or {}).get("shippingZones") or {}).get("edges")) or [] + + # Search for the method in all zones + for edge in edges: + zone = edge.get("node", {}) + methods = zone.get("shippingMethods", []) + for method in methods: + if method.get("id") == method_id: + excluded_products_data = method.get("excludedProducts", {}) + excluded_edges = excluded_products_data.get("edges", []) + excluded_products = [ + edge.get("node", {}) for edge in excluded_edges + ] + _logger.debug( + "Found %d existing excluded products for method %s", + len(excluded_products), + method_id, + ) + return excluded_products + + _logger.debug("No excluded products found for method %s", method_id) + return [] + + except Exception as e: + _logger.warning( + "Failed to get excluded products for method %s: %s", + method_id, + e, + ) + # Return empty list if excludedProducts field is not supported + return [] + + def shipping_method_sync_postal_codes( + self, method_id, desired_rules, inclusion_type="INCLUDE" + ): + """Sync postal code rules for a shipping method + by comparing existing vs desired rules.""" + try: + # Get existing postal codes from Saleor + existing_rules = self.shipping_method_get_postal_codes(method_id) + + # Create mappings for comparison + def normalize_rule(rule): + return (rule.get("start", ""), rule.get("end", "")) + + # Map existing rules: (start, end) -> rule_id + existing_map = { + normalize_rule(rule): rule.get("id") for rule in existing_rules + } + existing_set = set(existing_map.keys()) + desired_set = {(rule["start"], rule["end"]) for rule in desired_rules} + + # Calculate what to add and remove + to_add = desired_set - existing_set + to_remove = existing_set - desired_set + + _logger.debug( + "Postal code sync for method %s: %d to add, %d to remove", + method_id, + len(to_add), + len(to_remove), + ) + + # Prepare update payload + update_payload = {} + + if to_add: + add_rules = [{"start": start, "end": end} for start, end in to_add] + update_payload["addPostalCodeRules"] = add_rules + update_payload["inclusionType"] = inclusion_type + _logger.info( + "Adding %d postal code rules to method %s", + len(add_rules), + method_id, + ) + + if to_remove: + # Use postal code rule IDs for deletion, not the postal code data + remove_rule_ids = [ + existing_map[(start, end)] + for start, end in to_remove + if existing_map.get((start, end)) + ] + if remove_rule_ids: + update_payload["deletePostalCodeRules"] = remove_rule_ids + _logger.info( + "Removing %d postal code rules from method %s (IDs: %s)", + len(remove_rule_ids), + method_id, + remove_rule_ids, + ) + + # Apply changes if needed + if update_payload: + self.shipping_method_update(method_id, update_payload) + _logger.info( + "Successfully synced postal codes for method %s", method_id + ) + else: + _logger.debug("No postal code changes needed for method %s", method_id) + + except Exception as e: + _logger.warning( + "Failed to sync postal codes for method %s: %s", method_id, e + ) + raise + + def shipping_method_sync_excluded_products(self, method_id, desired_product_ids): + """Sync excluded products for a shipping method + by comparing existing vs desired products.""" + try: + # Get existing excluded products from Saleor + existing_products = self.shipping_method_get_excluded_products(method_id) + + # Create sets for comparison using Saleor product IDs + existing_product_ids = { + product.get("id") for product in existing_products if product.get("id") + } + desired_product_ids_set = set(desired_product_ids or []) + + # Calculate what to add and remove + to_add = desired_product_ids_set - existing_product_ids + to_remove = existing_product_ids - desired_product_ids_set + + _logger.debug( + "Excluded products sync for method %s: %d to add, %d to remove", + method_id, + len(to_add), + len(to_remove), + ) + + # Prepare update payload + update_payload = {} + + if to_add: + # Add products to exclusion list + add_product_ids = list(to_add) + update_payload["addProducts"] = add_product_ids + _logger.info( + "Adding %d excluded products to method %s (IDs: %s)", + len(add_product_ids), + method_id, + add_product_ids, + ) + + if to_remove: + # Remove products from exclusion list + remove_product_ids = list(to_remove) + update_payload["removeProducts"] = remove_product_ids + _logger.info( + "Removing %d excluded products from method %s (IDs: %s)", + len(remove_product_ids), + method_id, + remove_product_ids, + ) + + # Apply changes using dedicated mutations + if to_add: + try: + add_product_ids = list(to_add) + self.shipping_method_exclude_products(method_id, add_product_ids) + _logger.info( + "Successfully added %d excluded products to method %s", + len(add_product_ids), + method_id, + ) + except Exception as e: + _logger.warning( + "Failed to add excluded products to method %s: %s", method_id, e + ) + + if to_remove: + try: + remove_product_ids = list(to_remove) + self.shipping_method_remove_excluded_products( + method_id, remove_product_ids + ) + _logger.info( + "Successfully removed %d excluded products from method %s", + len(remove_product_ids), + method_id, + ) + except Exception as e: + _logger.warning( + "Failed to remove excluded products from method %s: %s", + method_id, + e, + ) + + if not to_add and not to_remove: + _logger.debug( + "No excluded products changes needed for method %s", method_id + ) + + except Exception as e: + _logger.warning( + "Failed to sync excluded products for method %s: %s", method_id, e + ) + raise + + def shipping_method_exclude_products(self, method_id, product_ids): + """Add products to shipping method exclusion + list using shippingPriceExcludeProducts mutation.""" + query = """ + mutation ShippingMethodExcludeProducts( + $id: ID!, $input: ShippingPriceExcludeProductsInput! + ) { + shippingPriceExcludeProducts(id: $id, input: $input) { + shippingMethod { + id + name + } + errors { + field + code + message + } + } + } + """ + variables = {"id": method_id, "input": {"products": product_ids}} + data = self.graphql(query, variables) + result = data.get("shippingPriceExcludeProducts") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor shippingPriceExcludeProducts errors: {errors}") + method = result.get("shippingMethod") + _logger.info("Saleor shipping_method_exclude_products done: %s", method) + return method + + def shipping_method_remove_excluded_products(self, method_id, product_ids): + """Remove products from shipping method + exclusion list using shippingPriceRemoveProductFromExclude mutation.""" + query = """ + mutation ShippingMethodRemoveExcludedProducts($id: ID!, $products: [ID!]!) { + shippingPriceRemoveProductFromExclude(id: $id, products: $products) { + shippingMethod { + id + name + } + errors { + field + code + message + } + } + } + """ + variables = {"id": method_id, "products": product_ids} + data = self.graphql(query, variables) + result = data.get("shippingPriceRemoveProductFromExclude") or {} + errors = result.get("errors") or [] + if errors: + raise Exception( + f"Saleor shippingPriceRemoveProductFromExclude errors: {errors}" + ) + method = result.get("shippingMethod") + _logger.info("Saleor shipping_method_remove_excluded_products done: %s", method) + return method + + def shipping_zone_metadata_update(self, zone_id, metadata): + """Update metadata for a shipping zone using updateMetadata mutation.""" + query = """ + mutation UpdateShippingZoneMetadata($id: ID!, $input: [MetadataInput!]!) { + updateMetadata(id: $id, input: $input) { + item { + ... on ShippingZone { + id + name + } + } + errors { + field + code + message + } + } + } + """ + variables = {"id": zone_id, "input": metadata} + data = self.graphql(query, variables) + result = data.get("updateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception(f"Saleor updateMetadata (shipping zone) errors: {errors}") + item = result.get("item") + _logger.info("Saleor shipping_zone_metadata_update done: %s", item) + return item + + def shipping_zone_private_metadata_update(self, zone_id, private_metadata): + """Update private metadata for a shipping zone + using updatePrivateMetadata mutation.""" + query = """ + mutation UpdateShippingZonePrivateMetadata( + $id: ID!, $input: [MetadataInput!]! + ) { + updatePrivateMetadata(id: $id, input: $input) { + item { + ... on ShippingZone { + id + name + } + } + errors { + field + code + message + } + } + } + """ + variables = {"id": zone_id, "input": private_metadata} + data = self.graphql(query, variables) + result = data.get("updatePrivateMetadata") or {} + errors = result.get("errors") or [] + if errors: + raise Exception( + f"Saleor updatePrivateMetadata (shipping zone) errors: {errors}" + ) + item = result.get("item") + _logger.info("Saleor shipping_zone_private_metadata_update done: %s", item) + return item + + def shipping_method_get_by_id(self, method_id): + """Fetch a shipping method by its Relay ID by scanning shipping zones.""" + # 1) Get shipping zone IDs (basic page of zones) + zones_query = """ + query ShippingZones($first: Int!) { + shippingZones(first: $first) { + edges { node { id } } + } + } + """ + zones_data = self.graphql(zones_query, {"first": 100}) + edges = (((zones_data or {}).get("shippingZones") or {}).get("edges")) or [] + zone_ids = [ + edge.get("node", {}).get("id") + for edge in edges + if edge.get("node", {}).get("id") + ] + _logger.debug( + "Discovered %s shipping zones while searching for method %s", + len(zone_ids), + method_id, + ) + + # 2) For each zone, fetch shipping methods and search for the ID + zone_query = """ + query ZoneMethods($id: ID!) { + shippingZone(id: $id) { + id + shippingMethods { id } + } + } + """ + for zid in zone_ids: + try: + zdata = self.graphql(zone_query, {"id": zid}) + zone = zdata.get("shippingZone") or {} + methods = zone.get("shippingMethods") or [] + for m in methods: + if m.get("id") == method_id: + _logger.info( + "shipping_method_get_by_id found in zone %s: %s", + zid, + method_id, + ) + return True + except Exception as e: + _logger.warning( + "Failed fetching shipping methods for zone %s: %s", zid, e + ) + continue + return False diff --git a/sale_saleor/views/account_tax_views.xml b/sale_saleor/views/account_tax_views.xml new file mode 100644 index 0000000000..ea09aee2c4 --- /dev/null +++ b/sale_saleor/views/account_tax_views.xml @@ -0,0 +1,69 @@ + + + + + account.tax.form.inherit.saleor + account.tax + + + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + +
    +
    + + + account.tax.list.inherit.saleor + account.tax + + + +
    +
    +
    +
    +
    +
    diff --git a/sale_saleor/views/delivery_carrier_views.xml b/sale_saleor/views/delivery_carrier_views.xml new file mode 100644 index 0000000000..af3d9ec216 --- /dev/null +++ b/sale_saleor/views/delivery_carrier_views.xml @@ -0,0 +1,157 @@ + + + + delivery.carrier.form.inherit.saleor + delivery.carrier + + + + + + delivery_type == 'fixed' or delivery_type == 'base_on_rule' or delivery_type == 'saleor' + + + delivery_type == 'fixed' or delivery_type == 'saleor' + + + delivery_type == 'fixed' or delivery_type == 'saleor' + + + + delivery_type == 'saleor' + + + delivery_type == 'saleor' + + + 1 + + + delivery_type == 'saleor' + + + delivery_type == 'saleor' + + + delivery_type == 'saleor' + + + + + + + + + + + + + + + + + + + diff --git a/sale_saleor/views/discount_rule_views.xml b/sale_saleor/views/discount_rule_views.xml new file mode 100644 index 0000000000..4a807830c1 --- /dev/null +++ b/sale_saleor/views/discount_rule_views.xml @@ -0,0 +1,118 @@ + + + + discount.rule.form + discount.rule + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    +
    +
    +
    diff --git a/sale_saleor/views/loyalty_program_views.xml b/sale_saleor/views/loyalty_program_views.xml new file mode 100644 index 0000000000..2b3ac68c8f --- /dev/null +++ b/sale_saleor/views/loyalty_program_views.xml @@ -0,0 +1,105 @@ + + + + loyalty.program.view.form.inherit.saleor + loyalty.program + + + + + program_type == 'saleor' + + + program_type == 'saleor' + + + program_type == 'saleor' + + + program_type == 'saleor' + + + program_type == 'saleor' + + + program_type in ('loyalty', 'gift_card', 'ewallet', 'saleor') + + + program_type in ('gift_card', 'ewallet', 'saleor') or program_type != 'loyalty' + + + program_type == 'saleor' + + + program_type in ('gift_card', 'ewallet', 'saleor') + + + program_type == 'saleor' + + + program_type == 'saleor' + + + program_type == 'saleor' + + + program_type == 'saleor' + + + program_type == 'saleor' + + + program_type in ('gift_card', 'ewallet', 'saleor') + + + - delivery_type == 'fixed' or delivery_type == 'base_on_rule' or delivery_type == 'saleor' + + {'invisible': [('delivery_type', 'in', ('fixed','base_on_rule','saleor'))]} + - delivery_type == 'fixed' or delivery_type == 'saleor' + + {'invisible': [('delivery_type', '=', 'saleor')]} + - + + + {'invisible': [('delivery_type', '=', 'saleor')]} + + + + - delivery_type == 'saleor' + + {'invisible': [('delivery_type', '=', 'saleor')]} + - delivery_type == 'saleor' + + {'invisible': [('delivery_type', '=', 'saleor')]} + + 1 - delivery_type == 'saleor' + + {'invisible': [('delivery_type', '=', 'saleor')]} + - delivery_type == 'saleor' + + {'invisible': [('delivery_type', '=', 'saleor')]} + - delivery_type == 'saleor' + + {'invisible': [('delivery_type', '=', 'saleor')]} + + + + @@ -79,9 +103,10 @@ - + - + - - + + - + @@ -118,36 +147,41 @@ /> - + - + - + - + - - - + + + - + - + - + - + diff --git a/sale_saleor/views/discount_rule_views.xml b/sale_saleor/views/discount_rule_views.xml index 4a807830c1..ebf42ebcc7 100644 --- a/sale_saleor/views/discount_rule_views.xml +++ b/sale_saleor/views/discount_rule_views.xml @@ -8,7 +8,7 @@ @@ -24,8 +24,8 @@ - - + + + - + @@ -81,18 +82,18 @@ - + @@ -89,7 +92,12 @@ /> - + @@ -101,7 +109,7 @@ Channels saleor.channel - list,form + tree,form

    Create and manage Saleor Channels. diff --git a/sale_saleor/views/saleor_gift_card_views.xml b/sale_saleor/views/saleor_gift_card_views.xml index 0277bb4658..82a2363e10 100644 --- a/sale_saleor/views/saleor_gift_card_views.xml +++ b/sale_saleor/views/saleor_gift_card_views.xml @@ -4,7 +4,7 @@ saleor.giftcard.tree saleor.giftcard - +

    @@ -58,18 +58,26 @@ nolabel="1" /> + - - + + @@ -77,16 +85,16 @@ @@ -103,27 +111,31 @@ - - - + + + - + - - - + + + - + @@ -135,7 +147,7 @@ Gift Cards saleor.giftcard - list,form + tree,form
    diff --git a/sale_saleor/views/saleor_product_type.xml b/sale_saleor/views/saleor_product_type.xml index b8d896a9ff..221de2d98b 100644 --- a/sale_saleor/views/saleor_product_type.xml +++ b/sale_saleor/views/saleor_product_type.xml @@ -4,9 +4,9 @@ saleor.product.type.tree.view saleor.product.type - + - + @@ -26,7 +26,10 @@ - + @@ -37,47 +40,49 @@ - + - + - + - + - - - + + + - + - - - + + + - + @@ -89,7 +94,7 @@ Product Types saleor.product.type - list,form + tree,form {}

    diff --git a/sale_saleor/views/saleor_shipping_zone_views.xml b/sale_saleor/views/saleor_shipping_zone_views.xml index a1545c90c1..1b520b817d 100644 --- a/sale_saleor/views/saleor_shipping_zone_views.xml +++ b/sale_saleor/views/saleor_shipping_zone_views.xml @@ -4,7 +4,7 @@ saleor.shipping.zone.tree saleor.shipping.zone - +

    @@ -61,6 +61,7 @@ +
    @@ -43,6 +44,9 @@
    + + + Date: Fri, 30 Jan 2026 17:27:48 +0700 Subject: [PATCH 09/11] [ADD] sale_saleor: add Saleor webhook activation --- sale_saleor/controllers/saleor_webhook.py | 53 +++++++++++++++++ sale_saleor/models/__init__.py | 1 + sale_saleor/models/saleor_account.py | 35 +++++++++-- .../models/saleor_webhook_activation.py | 59 +++++++++++++++++++ sale_saleor/security/ir.model.access.csv | 2 + sale_saleor/views/saleor_account_views.xml | 16 +++++ 6 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 sale_saleor/models/saleor_webhook_activation.py diff --git a/sale_saleor/controllers/saleor_webhook.py b/sale_saleor/controllers/saleor_webhook.py index 68063e54bf..24a0cba409 100644 --- a/sale_saleor/controllers/saleor_webhook.py +++ b/sale_saleor/controllers/saleor_webhook.py @@ -67,6 +67,25 @@ def to_origin(value: str) -> str: return None +def _saleor_is_webhook_active(account, kind): + """Return True if the given webhook kind is active in Odoo for this account. + + kind is a human-readable name matching the name_suffix used when ensuring + webhooks (e.g. "Customer", "Payment", "Order", "Draft Order"). + """ + Activation = request.env["saleor.webhook.activation"].sudo() + # Match by account and kind contained in the remote webhook name + # (e.g. "Odoo Customer Webhook (Test Odoo 16.0)"). + rec = Activation.search( + [ + ("saleor_account_id", "=", account.id), + ("name", "ilike", kind), + ], + limit=1, + ) + return bool(rec and rec.status == "active") + + def _saleor_verify_signature(account, headers, body): secret_str = (account.saleor_webhook_secret or "").strip() if not secret_str: @@ -548,6 +567,13 @@ def saleor_order_created_updated(self, **kwargs): # pylint: disable=unused-argu ok, resp = _saleor_verify_signature(account, headers, body) if not ok: return resp + if not _saleor_is_webhook_active(account, "Order"): + _logger.info("Saleor order webhook: currently, webook is inactive in Odoo") + return request.make_response( + "currently, webook is inactive in Odoo", + headers=[("Content-Type", "text/plain")], + status=200, + ) event_type, event_type_raw, data, payload = _saleor_parse_payload(body, headers) _logger.info( "Saleor order webhook: event=%s raw=%s", event_type, event_type_raw @@ -629,6 +655,15 @@ def saleor_draft_order_webhook(self, **kwargs): # pylint: disable=unused-argume ok, resp = _saleor_verify_signature(account, headers, body) if not ok: return resp + if not _saleor_is_webhook_active(account, "Draft Order"): + _logger.info( + "Saleor draft order webhook: currently, webook is inactive in Odoo" + ) + return request.make_response( + "currently, webook is inactive in Odoo", + headers=[("Content-Type", "text/plain")], + status=200, + ) event_type, event_type_raw, data, payload = _saleor_parse_payload(body, headers) _logger.info( "Saleor draft order webhook: event=%s raw=%s", event_type, event_type_raw @@ -715,6 +750,15 @@ def saleor_customer_webhook(self, **kwargs): # pylint: disable=unused-argument ok, resp = _saleor_verify_signature(account, headers, body) if not ok: return resp + if not _saleor_is_webhook_active(account, "Customer"): + _logger.info( + "Saleor customer webhook: currently, webook is inactive in Odoo" + ) + return request.make_response( + "currently, webook is inactive in Odoo", + headers=[("Content-Type", "text/plain")], + status=200, + ) # Parse payload and event event_type, event_type_raw, data, payload = _saleor_parse_payload(body, headers) if event_type != "CUSTOMER_UPDATED": @@ -765,6 +809,15 @@ def saleor_order_payment_webhook(self, **kwargs): # pylint: disable=unused-argu ok, resp = _saleor_verify_signature(account, headers, body) if not ok: return resp + if not _saleor_is_webhook_active(account, "Payment"): + _logger.info( + "Saleor order payment webhook: currently, webook is inactive in Odoo" + ) + return request.make_response( + "currently, webook is inactive in Odoo", + headers=[("Content-Type", "text/plain")], + status=200, + ) event_type, event_type_raw, data, payload = _saleor_parse_payload(body, headers) _logger.info( "Saleor order payment webhook: event=%s raw=%s", event_type, event_type_raw diff --git a/sale_saleor/models/__init__.py b/sale_saleor/models/__init__.py index 9c888cec80..c54364f66e 100644 --- a/sale_saleor/models/__init__.py +++ b/sale_saleor/models/__init__.py @@ -40,6 +40,7 @@ from . import saleor_gift_card from . import saleor_gift_card_tag from . import saleor_giftcard_meta_line +from . import saleor_webhook_activation from . import sale_order from . import sale_order_line from . import stock_picking diff --git a/sale_saleor/models/saleor_account.py b/sale_saleor/models/saleor_account.py index 4691ffabc3..842f25b972 100644 --- a/sale_saleor/models/saleor_account.py +++ b/sale_saleor/models/saleor_account.py @@ -135,6 +135,12 @@ class SaleorAccount(models.Model): copy=False, readonly=True, string="Saleor Draft Order Webhook ID" ) + webhook_activation_ids = fields.One2many( + comodel_name="saleor.webhook.activation", + inverse_name="saleor_account_id", + string="Webhook Activations", + ) + def _get_client(self): self.ensure_one() # Prefer App token if available (long-lived). Otherwise use staff JWT. @@ -330,19 +336,18 @@ def _ensure_webhook(self, client, url, id_field, events, name_suffix): webhook = None if webhook: - need_update = webhook.get("targetUrl") != target_url or not webhook.get( - "isActive", True - ) + need_update = webhook.get("targetUrl") != target_url if need_update: upd = client.webhook_update( webhook_id=webhook.get("id"), target_url=target_url, events=events, secret_key=self.saleor_webhook_secret, - is_active=True, ) if upd and upd.get("id") and upd.get("id") != webhook_id: self.write({id_field: upd.get("id")}) + if upd: + webhook = upd else: created = client.webhook_create( app_id=self.saleor_app_id, @@ -355,6 +360,28 @@ def _ensure_webhook(self, client, url, id_field, events, name_suffix): if created and created.get("id"): self.write({id_field: created.get("id")}) + webhook_obj = created or webhook or {} + activation_name = webhook_obj.get("name") or name_suffix + Activation = self.env["saleor.webhook.activation"].sudo() + existing = Activation.search( + [ + ("saleor_account_id", "=", self.id), + ("name", "=", activation_name), + ], + limit=1, + ) + if existing: + if activation_name and existing.name != activation_name: + existing.write({"name": activation_name}) + else: + Activation.create( + { + "name": activation_name, + "status": "inactive", + "saleor_account_id": self.id, + } + ) + # --- Saleor → Odoo Order upsert --- def _import_saleor_order(self, order): """Idempotently create/update a sale.order from a Saleor order payload.""" diff --git a/sale_saleor/models/saleor_webhook_activation.py b/sale_saleor/models/saleor_webhook_activation.py new file mode 100644 index 0000000000..472370aa3c --- /dev/null +++ b/sale_saleor/models/saleor_webhook_activation.py @@ -0,0 +1,59 @@ +from odoo import fields, models + +from ..utils import _logger + + +class SaleorWebhookActivation(models.Model): + _name = "saleor.webhook.activation" + _description = "Saleor Webhook Activation" + + name = fields.Char(required=True) + status = fields.Selection( + selection=[("inactive", "Inactive"), ("active", "Active")], + default="inactive", + required=True, + ) + saleor_account_id = fields.Many2one( + comodel_name="saleor.account", + string="Saleor Account", + required=True, + ondelete="cascade", + ) + + def _sync_status_to_saleor(self): + for rec in self: + account = rec.saleor_account_id + if not account or not account.saleor_app_id: + continue + client = account._get_client() + is_active = rec.status == "active" + name = (rec.name or "").strip().lower() + field_name = None + if name == "customer": + field_name = "saleor_customer_webhook_id" + elif name == "payment": + field_name = "saleor_payment_webhook_id" + elif name == "order": + field_name = "saleor_order_webhook_id" + elif name == "draft order": + field_name = "saleor_draft_order_webhook_id" + if not field_name: + continue + webhook_id = getattr(account, field_name, None) + if not webhook_id: + continue + try: + client.webhook_update(webhook_id=webhook_id, is_active=is_active) + except Exception as e: # pragma: no cover - best-effort sync + _logger.warning( + "Failed to sync Saleor webhook activation for account %s (%s): %s", + account.id, + rec.name, + e, + ) + + def write(self, vals): + res = super().write(vals) + if "status" in vals: + self._sync_status_to_saleor() + return res diff --git a/sale_saleor/security/ir.model.access.csv b/sale_saleor/security/ir.model.access.csv index 41add9956f..4d40d45cc4 100644 --- a/sale_saleor/security/ir.model.access.csv +++ b/sale_saleor/security/ir.model.access.csv @@ -79,3 +79,5 @@ access_product_type_meta_line_user,saleor.product.type.meta.line user,model_prod access_product_type_meta_line_manager,saleor.product.type.meta.line manager,model_product_type_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 access_product_type_private_meta_line_user,saleor.product.type.private.meta.line user,model_product_type_private_meta_line,sale_saleor.group_saleor_user,1,1,1,0 access_product_type_private_meta_line_manager,saleor.product.type.private.meta.line manager,model_product_type_private_meta_line,sale_saleor.group_saleor_manager,1,1,1,1 +access_saleor_webhook_activation_user,saleor.webhook.activation user,model_saleor_webhook_activation,sale_saleor.group_saleor_user,1,1,1,0 +access_saleor_webhook_activation_manager,saleor.webhook.activation manager,model_saleor_webhook_activation,sale_saleor.group_saleor_manager,1,1,1,1 diff --git a/sale_saleor/views/saleor_account_views.xml b/sale_saleor/views/saleor_account_views.xml index d75d1ad176..30ef3d54da 100644 --- a/sale_saleor/views/saleor_account_views.xml +++ b/sale_saleor/views/saleor_account_views.xml @@ -44,6 +44,22 @@ />
    + + + + + + + + + + + +
    From 4f71cbcb57840e5fd360b7f93c681b41fba295e9 Mon Sep 17 00:00:00 2001 From: Kimkhoi3010 Date: Mon, 2 Feb 2026 11:02:20 +0700 Subject: [PATCH 10/11] [IMP] sale_saleor: improve Saleor IDs visibility and sync logs --- sale_saleor/models/account_tax.py | 3 +- sale_saleor/models/delivery_carrier.py | 3 +- sale_saleor/models/loyalty_program.py | 56 ++++++++----- sale_saleor/models/saleor_account.py | 9 ++- sale_saleor/models/stock_location.py | 79 +++++++++---------- sale_saleor/models/stock_warehouse.py | 53 ++++++++----- sale_saleor/views/account_tax_views.xml | 13 +++ sale_saleor/views/delivery_carrier_views.xml | 12 +++ sale_saleor/views/loyalty_program_views.xml | 11 +++ sale_saleor/views/product_attribute_views.xml | 13 +++ sale_saleor/views/product_category_views.xml | 11 +++ .../views/product_collection_views.xml | 10 ++- sale_saleor/views/product_template_views.xml | 15 +++- sale_saleor/views/product_views.xml | 1 + sale_saleor/views/sale_order_views.xml | 8 +- sale_saleor/views/saleor_account_views.xml | 5 ++ sale_saleor/views/saleor_channel_views.xml | 12 ++- sale_saleor/views/saleor_gift_card_views.xml | 10 ++- sale_saleor/views/saleor_product_type.xml | 6 +- .../views/saleor_shipping_zone_views.xml | 11 ++- sale_saleor/views/saleor_voucher_views.xml | 12 ++- sale_saleor/views/stock_location_views.xml | 12 +++ sale_saleor/views/stock_warehouse_views.xml | 12 +++ 23 files changed, 273 insertions(+), 104 deletions(-) diff --git a/sale_saleor/models/account_tax.py b/sale_saleor/models/account_tax.py index 93137737c4..ff96e8bc94 100644 --- a/sale_saleor/models/account_tax.py +++ b/sale_saleor/models/account_tax.py @@ -8,7 +8,8 @@ class AccountTax(models.Model): - _inherit = "account.tax" + _name = "account.tax" + _inherit = ["account.tax", "mail.thread", "mail.activity.mixin"] saleor_metadata_line_ids = fields.One2many( "saleor.tax.meta.line", diff --git a/sale_saleor/models/delivery_carrier.py b/sale_saleor/models/delivery_carrier.py index 203cf971a5..fecccc9d3b 100644 --- a/sale_saleor/models/delivery_carrier.py +++ b/sale_saleor/models/delivery_carrier.py @@ -13,7 +13,8 @@ class DeliveryCarrier(models.Model): - _inherit = "delivery.carrier" + _name = "delivery.carrier" + _inherit = ["delivery.carrier", "mail.thread", "mail.activity.mixin"] delivery_type = fields.Selection( selection_add=[ diff --git a/sale_saleor/models/loyalty_program.py b/sale_saleor/models/loyalty_program.py index e56e418c03..504d10f0cb 100644 --- a/sale_saleor/models/loyalty_program.py +++ b/sale_saleor/models/loyalty_program.py @@ -5,11 +5,17 @@ from odoo import _, fields, models from odoo.exceptions import UserError -from ..helpers import get_active_saleor_account, html_to_editorjs, to_saleor_datetime +from ..helpers import ( + get_active_saleor_account, + html_to_editorjs, + make_link, + to_saleor_datetime, +) class LoyaltyProgram(models.Model): - _inherit = "loyalty.program" + _name = "loyalty.program" + _inherit = ["loyalty.program", "mail.thread", "mail.activity.mixin"] program_type = fields.Selection( selection_add=[ @@ -80,7 +86,6 @@ def action_saleor_sync(self): prog = programs payload = prog._saleor_prepare_promotion_payload() account.job_promotion_sync(prog.id, payload) - queued = False else: batch_size = getattr(account, "job_batch_size", 10) or 10 items = [] @@ -93,21 +98,30 @@ def action_saleor_sync(self): account.with_delay().job_promotion_sync_batch(chunk) else: account.job_promotion_sync_batch(chunk) - queued = True - # Notify via client action - msg = _( - "Queued sync of %s promotion(s) to Saleor.", - len(programs) - if queued - else _("Triggered sync of %s promotion(s) to Saleor.", len(programs)), - ) - return { - "type": "ir.actions.client", - "tag": "display_notification", - "params": { - "title": _("Saleor Promotion Sync"), - "message": msg, - "type": "success", - "sticky": False, - }, - } + # Log detailed message per program, similar to other Saleor objects + base = (account.base_url or "").rstrip("/") + for prog in programs: + dash_url = "" + if base and prog.saleor_promotion_id: + dash_url = ( + f"{base}/dashboard/discounts/sales/{prog.saleor_promotion_id}" + ) + + link_html = ( + f"
  • Saleor: {make_link('View in Saleor', dash_url)}
  • " + if dash_url + else "" + ) + + body = ( + "

    Synced promotion to Saleor

    " + "
      " + f"
    • Account: {account.email or account.name}
    • " + f"
    • Program: {prog.display_name}
    • " + f"
    • Saleor Promotion ID: {prog.saleor_promotion_id or ''}
    • " + f"{link_html}" + "
    " + ) + prog.message_post(body=body) + + return True diff --git a/sale_saleor/models/saleor_account.py b/sale_saleor/models/saleor_account.py index 842f25b972..d920774187 100644 --- a/sale_saleor/models/saleor_account.py +++ b/sale_saleor/models/saleor_account.py @@ -51,6 +51,7 @@ class SaleorAccount(models.Model): _name = "saleor.account" _description = "Saleor Account" _rec_name = "name" + _inherit = ["mail.thread", "mail.activity.mixin"] TOKEN_EXPIRY_MINUTES = 4 DEFAULT_APP_PERMISSIONS = ["MANAGE_USERS", "MANAGE_TAXES", "MANAGE_ORDERS"] @@ -2469,7 +2470,7 @@ def _post_success( product_id=parent_pid, ) body = format_kv_list( - "Synced variant to Saleor:", + "Synced variant to Saleor successfully:", [ ("Account", self.email), ( @@ -2506,7 +2507,7 @@ def _post_success( # For products, show explicit Storefront/Saleor links. if kind == "product": body = format_kv_list( - "Synced to Saleor:", + "Synced to Saleor successfully:", [ ("Account", self.email), ("Slug", slug_val), @@ -2526,7 +2527,7 @@ def _post_success( ) else: body = format_kv_list( - "Synced to Saleor:", + "Synced to Saleor successfully:", [ ("Account", self.email), ("Slug", slug_val), @@ -3417,7 +3418,7 @@ def job_tax_sync(self, tax_id, payload): self.base_url, "tax", id=saleor_tax_id ) body = format_kv_list( - "Synced to Saleor:", + "Synced to Saleor successfully:", [ ("Account", self.email or self.name), ("TaxClass ID", saleor_tax_id), diff --git a/sale_saleor/models/stock_location.py b/sale_saleor/models/stock_location.py index f95b313a46..5ca8da6988 100644 --- a/sale_saleor/models/stock_location.py +++ b/sale_saleor/models/stock_location.py @@ -6,13 +6,14 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError -from ..helpers import get_active_saleor_account +from ..helpers import get_active_saleor_account, make_link _logger = logging.getLogger(__name__) class Location(models.Model): - _inherit = "stock.location" + _name = "stock.location" + _inherit = ["stock.location", "mail.thread", "mail.activity.mixin"] warehouse_id = fields.Many2one( "stock.warehouse", @@ -103,18 +104,29 @@ def action_sync_to_saleor_warehouse(self): loc = records payload = loc._saleor_prepare_warehouse_payload() account.job_location_sync(loc.id, payload) - title = _("Saleor Sync") - msg = _("Location synced successfully: %s", loc.display_name) - return { - "type": "ir.actions.client", - "tag": "display_notification", - "params": { - "title": title, - "message": msg, - "sticky": False, - "type": "success", - }, - } + + base = (account.base_url or "").rstrip("/") + dash_url = "" + if base and loc.saleor_warehouse_id: + dash_url = f"{base}/dashboard/warehouses/{loc.saleor_warehouse_id}" + + link_html = ( + f"
  • Saleor: {make_link('View in Saleor', dash_url)}
  • " + if dash_url + else "" + ) + + body = ( + "

    Synced location to Saleor

    " + "
      " + f"
    • Account: {account.email or account.name}
    • " + f"
    • Location: {loc.display_name}
    • " + f"
    • Saleor Warehouse ID: {loc.saleor_warehouse_id or ''}
    • " + f"{link_html}" + "
    " + ) + loc.message_post(body=body) + return True # Multiple: batch batch_size = getattr(account, "job_batch_size", 10) or 10 @@ -128,18 +140,9 @@ def action_sync_to_saleor_warehouse(self): account.with_delay().job_location_sync_batch(chunk) else: account.job_location_sync_batch(chunk) - title = _("Saleor Sync") - msg = _("Location sync started for %s record(s).", len(records)) - return { - "type": "ir.actions.client", - "tag": "display_notification", - "params": { - "title": title, - "message": msg, - "sticky": False, - "type": "success", - }, - } + msg = _("Location sync started for %s record(s).") % len(records) + records.message_post(body=msg) + return True def action_sync_product_quantities(self): """ @@ -173,23 +176,15 @@ def action_sync_product_quantities(self): ) success_count += 1 - return { - "type": "ir.actions.client", - "tag": "display_notification", - "params": { - "title": _("Saleor Sync Completed"), - "message": _( - "Successfully updated %(success)s product(s)." - "\nSkipped %(skip)s product(s) without Saleor Variant ID.", - { - "success": success_count, - "skip": skip_count, - }, - ), - "sticky": False, - "type": "success", - }, + message = _( + "Successfully updated %(success)s product(s)." + "\nSkipped %(skip)s product(s) without Saleor Variant ID." + ) % { + "success": success_count, + "skip": skip_count, } + self.message_post(body=message) + return True @api.constrains("is_saleor_warehouse", "warehouse_id") def _check_saleor_location_vs_warehouse(self): diff --git a/sale_saleor/models/stock_warehouse.py b/sale_saleor/models/stock_warehouse.py index 55852dd689..3e33eec87a 100644 --- a/sale_saleor/models/stock_warehouse.py +++ b/sale_saleor/models/stock_warehouse.py @@ -6,13 +6,14 @@ from odoo import _, api, fields, models from odoo.exceptions import UserError -from ..helpers import get_active_saleor_account +from ..helpers import get_active_saleor_account, make_link _logger = logging.getLogger(__name__) class Warehouse(models.Model): - _inherit = "stock.warehouse" + _name = "stock.warehouse" + _inherit = ["stock.warehouse", "mail.thread", "mail.activity.mixin"] is_saleor_warehouse = fields.Boolean( default=False, @@ -86,6 +87,28 @@ def action_sync_to_saleor_warehouse(self): wh = records payload = wh._saleor_prepare_warehouse_payload() account.job_warehouse_sync(wh.id, payload) + + base = (account.base_url or "").rstrip("/") + dash_url = "" + if base and wh.saleor_warehouse_id: + dash_url = f"{base}/dashboard/warehouses/{wh.saleor_warehouse_id}" + + link_html = ( + f"
  • Saleor: {make_link('View in Saleor', dash_url)}
  • " + if dash_url + else "" + ) + + body = ( + "

    Synced warehouse to Saleor

    " + "
      " + f"
    • Account: {account.email or account.name}
    • " + f"
    • Warehouse: {wh.display_name}
    • " + f"
    • Saleor Warehouse ID: {wh.saleor_warehouse_id or ''}
    • " + f"{link_html}" + "
    " + ) + wh.message_post(body=body) return True # Multiple: batch @@ -100,6 +123,8 @@ def action_sync_to_saleor_warehouse(self): account.with_delay().job_warehouse_sync_batch(chunk) else: account.job_warehouse_sync_batch(chunk) + msg = _("Warehouse sync started for %s record(s).", len(records)) + records.message_post(body=msg) return True def action_sync_product_quantities(self): @@ -138,23 +163,15 @@ def action_sync_product_quantities(self): ) success_count += 1 - return { - "type": "ir.actions.client", - "tag": "display_notification", - "params": { - "title": _("Saleor Sync Completed"), - "message": _( - "Successfully updated %(success)s product(s)." - "\nSkipped %(skip)s product(s) without Saleor Variant ID.", - { - "success": success_count, - "skip": skip_count, - }, - ), - "sticky": False, - "type": "success", - }, + message = _( + "Successfully updated %(success)s product(s)." + "\nSkipped %(skip)s product(s) without Saleor Variant ID." + ) % { + "success": success_count, + "skip": skip_count, } + self.message_post(body=message) + return True @api.constrains("is_saleor_warehouse") def _check_saleor_warehouse_vs_locations(self): diff --git a/sale_saleor/views/account_tax_views.xml b/sale_saleor/views/account_tax_views.xml index 8088e13f90..48b24ae163 100644 --- a/sale_saleor/views/account_tax_views.xml +++ b/sale_saleor/views/account_tax_views.xml @@ -30,6 +30,12 @@ string="Saleor" attrs="{'invisible': ['|', '|', ('sync_to_saleor', '=', False), ('amount_type', '!=', 'percent'), ('type_tax_use', '!=', 'sale')]}" > + + + @@ -52,6 +58,13 @@ + +
    + + + +
    +
    diff --git a/sale_saleor/views/delivery_carrier_views.xml b/sale_saleor/views/delivery_carrier_views.xml index 68284824eb..53d7f21695 100644 --- a/sale_saleor/views/delivery_carrier_views.xml +++ b/sale_saleor/views/delivery_carrier_views.xml @@ -83,6 +83,11 @@ > + @@ -186,6 +191,13 @@ + +
    + + + +
    +
    diff --git a/sale_saleor/views/loyalty_program_views.xml b/sale_saleor/views/loyalty_program_views.xml index fb4fb07a38..eb15b29139 100644 --- a/sale_saleor/views/loyalty_program_views.xml +++ b/sale_saleor/views/loyalty_program_views.xml @@ -102,6 +102,10 @@ attrs="{'invisible': [('program_type', '!=', 'saleor')]}" > + @@ -125,6 +129,13 @@
    + +
    + + + +
    +
    diff --git a/sale_saleor/views/product_attribute_views.xml b/sale_saleor/views/product_attribute_views.xml index 505b0e9992..e9f79df64c 100644 --- a/sale_saleor/views/product_attribute_views.xml +++ b/sale_saleor/views/product_attribute_views.xml @@ -36,6 +36,12 @@ + + + @@ -58,6 +64,13 @@ + +
    + + + +
    +
    diff --git a/sale_saleor/views/product_category_views.xml b/sale_saleor/views/product_category_views.xml index 2a558203fe..d0f98a1c02 100644 --- a/sale_saleor/views/product_category_views.xml +++ b/sale_saleor/views/product_category_views.xml @@ -26,6 +26,10 @@ + @@ -61,6 +65,13 @@ + +
    + + + +
    +