From 88cb6db6de933908aaae4f15f2ebae167ad164ff Mon Sep 17 00:00:00 2001 From: Jeremy Kersten Date: Mon, 29 Mar 2021 06:49:51 +0000 Subject: [PATCH] [IMP] website_sale*: add to whislist and notify stock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This module allows users to be notified by email when a product out of stock comes back in stock. For that they can add it to their wishlist and select the appropriate option. We show out-of-stock warning in all case whatever the show availibility option. You cannot custom you 'in stock' message ot free message. Spec of Pde and Fp Part of https://github.com/odoo/odoo/pull/68221 task-2458165 closes odoo/odoo#68221 Related: odoo/upgrade#2405 Signed-off-by: Jérémy Kersten (jke) Co-authored-by: Kersten Jeremy Co-authored-by: Younn Olivier --- addons/website_sale/controllers/main.py | 2 +- addons/website_sale/data/data.xml | 5 ++ addons/website_sale/models/product.py | 10 +++ .../models/res_config_settings.py | 5 -- .../views/res_config_settings_views.xml | 11 --- .../__manifest__.py | 1 + addons/website_sale_stock/controllers/main.py | 4 +- .../models/product_template.py | 36 ++++---- .../models/res_config_settings.py | 26 +++--- .../website_sale_stock/models/sale_order.py | 2 +- .../static/src/js/variant_mixin.js | 43 ++++++--- ...ebsite_sale_stock_product_availability.xml | 40 ++++----- ...st_website_sale_stock_product_warehouse.py | 4 +- .../views/product_template_views.xml | 18 +++- .../views/res_config_settings_views.xml | 59 ++++++++----- .../views/website_sale_stock_templates.xml | 3 +- .../views/product_configurator_templates.xml | 2 +- .../website_sale_stock_wishlist/__init__.py | 5 ++ .../__manifest__.py | 27 ++++++ .../controllers/__init__.py | 5 ++ .../controllers/main.py | 13 +++ .../controllers/variant.py | 13 +++ .../data/ir_cron_data.xml | 13 +++ .../data/template_email.xml | 26 ++++++ .../models/__init__.py | 5 ++ .../models/product_template.py | 25 ++++++ .../models/product_wishlist.py | 54 ++++++++++++ .../static/src/js/website_sale.js | 35 ++++++++ .../static/src/js/wishlist.js | 43 +++++++++ .../src/scss/website_sale_stock_wishlist.scss | 2 +- .../static/src/xml/product_availability.xml | 9 ++ .../tests/__init__.py | 4 + .../tests/test_wishlist_emails.py | 87 +++++++++++++++++++ .../views/templates.xml | 14 +++ .../views/website_sale_wishlist_template.xml | 6 +- 35 files changed, 539 insertions(+), 118 deletions(-) create mode 100644 addons/website_sale_stock_wishlist/__init__.py create mode 100644 addons/website_sale_stock_wishlist/__manifest__.py create mode 100644 addons/website_sale_stock_wishlist/controllers/__init__.py create mode 100644 addons/website_sale_stock_wishlist/controllers/main.py create mode 100644 addons/website_sale_stock_wishlist/controllers/variant.py create mode 100644 addons/website_sale_stock_wishlist/data/ir_cron_data.xml create mode 100644 addons/website_sale_stock_wishlist/data/template_email.xml create mode 100644 addons/website_sale_stock_wishlist/models/__init__.py create mode 100644 addons/website_sale_stock_wishlist/models/product_template.py create mode 100644 addons/website_sale_stock_wishlist/models/product_wishlist.py create mode 100644 addons/website_sale_stock_wishlist/static/src/js/website_sale.js create mode 100644 addons/website_sale_stock_wishlist/static/src/js/wishlist.js rename addons/{website_sale_comparison_wishlist => website_sale_stock_wishlist}/static/src/scss/website_sale_stock_wishlist.scss (85%) create mode 100644 addons/website_sale_stock_wishlist/static/src/xml/product_availability.xml create mode 100644 addons/website_sale_stock_wishlist/tests/__init__.py create mode 100644 addons/website_sale_stock_wishlist/tests/test_wishlist_emails.py create mode 100644 addons/website_sale_stock_wishlist/views/templates.xml diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py index bb38549709e..a105f38863d 100644 --- a/addons/website_sale/controllers/main.py +++ b/addons/website_sale/controllers/main.py @@ -73,7 +73,7 @@ class TableCompute(object): self.table[(pos // ppr) + y2][(pos % ppr) + x2] = False self.table[pos // ppr][pos % ppr] = { 'product': p, 'x': x, 'y': y, - 'ribbon': p.website_ribbon_id, + 'ribbon': p._get_website_ribbon(), } if index <= ppg: maxy = max(maxy, y + (pos // ppr)) diff --git a/addons/website_sale/data/data.xml b/addons/website_sale/data/data.xml index 06cdea13de4..f491f9252ac 100644 --- a/addons/website_sale/data/data.xml +++ b/addons/website_sale/data/data.xml @@ -28,6 +28,11 @@ bg-danger o_ribbon_left + + Out of stock + bg-warning o_ribbon_left + + New! bg-primary o_ribbon_left diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py index 739134ca128..cb47cf64972 100644 --- a/addons/website_sale/models/product.py +++ b/addons/website_sale/models/product.py @@ -413,6 +413,12 @@ class ProductTemplate(models.Model): if product.id: product.website_url = "/shop/%s" % slug(product) + def _is_sold_out(self): + return self.product_variant_id._is_sold_out() + + def _get_website_ribbon(self): + return self.website_ribbon_id + # --------------------------------------------------------- # Rating Mixin API # --------------------------------------------------------- @@ -485,3 +491,7 @@ class Product(models.Model): # [1:] to remove the main image from the template, we only display # the template extra images here return variant_images + self.product_tmpl_id._get_images()[1:] + + def _is_sold_out(self): + combination_info = self.with_context(website_sale_stock_get_quantity=True).product_tmpl_id._get_combination_info(product_id=self.id) + return combination_info['product_type'] == 'product' and combination_info['free_qty'] <= 0 diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py index 39aedffb8a7..e516981d74c 100644 --- a/addons/website_sale/models/res_config_settings.py +++ b/addons/website_sale/models/res_config_settings.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from ast import literal_eval - from odoo import api, models, fields @@ -24,10 +22,7 @@ class ResConfigSettings(models.TransientModel): module_website_sale_digital = fields.Boolean("Digital Content") module_website_sale_wishlist = fields.Boolean("Wishlists") module_website_sale_comparison = fields.Boolean("Product Comparison Tool") - module_website_sale_stock = fields.Boolean("Inventory", help='Installs the "Website Delivery Information" application') module_website_sale_gift_card = fields.Boolean("Gift Card") - - module_account = fields.Boolean("Invoicing") cart_recovery_mail_template = fields.Many2one('mail.template', string='Cart Recovery Email', domain="[('model', '=', 'sale.order')]", diff --git a/addons/website_sale/views/res_config_settings_views.xml b/addons/website_sale/views/res_config_settings_views.xml index 97172f7deaa..ceb4ed7f754 100644 --- a/addons/website_sale/views/res_config_settings_views.xml +++ b/addons/website_sale/views/res_config_settings_views.xml @@ -84,17 +84,6 @@ -
-
- -
-
-
-

Pricing

diff --git a/addons/website_sale_comparison_wishlist/__manifest__.py b/addons/website_sale_comparison_wishlist/__manifest__.py index 7d657c348c5..911ee0cbea6 100644 --- a/addons/website_sale_comparison_wishlist/__manifest__.py +++ b/addons/website_sale_comparison_wishlist/__manifest__.py @@ -21,4 +21,5 @@ It allows for comparing products from the wishlist ], }, 'auto_install': True, + 'license': 'LGPL-3', } diff --git a/addons/website_sale_stock/controllers/main.py b/addons/website_sale_stock/controllers/main.py index 09a59b84cf2..94af3015363 100644 --- a/addons/website_sale_stock/controllers/main.py +++ b/addons/website_sale_stock/controllers/main.py @@ -3,7 +3,7 @@ from odoo.addons.website_sale.controllers import main as website_sale_controller -from odoo import http,_ +from odoo import http, _ from odoo.http import request from odoo.exceptions import ValidationError @@ -18,7 +18,7 @@ class PaymentPortal(website_sale_controller.PaymentPortal): order = request.website.sale_get_order() values = [] for line in order.order_line: - if line.product_id.type == 'product' and line.product_id.inventory_availability in ['always', 'threshold']: + if line.product_id.type == 'product' and not line.product_id.allow_out_of_stock_order: cart_qty = sum(order.order_line.filtered(lambda p: p.product_id.id == line.product_id.id).mapped('product_uom_qty')) avl_qty = line.product_id.with_context(warehouse=order.warehouse_id.id).free_qty if cart_qty > avl_qty: diff --git a/addons/website_sale_stock/models/product_template.py b/addons/website_sale_stock/models/product_template.py index 91761c1078e..75f165f0369 100644 --- a/addons/website_sale_stock/models/product_template.py +++ b/addons/website_sale_stock/models/product_template.py @@ -1,20 +1,17 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. - -from odoo import fields, models, api +from odoo import fields, models +from odoo.tools.translate import html_translate class ProductTemplate(models.Model): _inherit = 'product.template' - inventory_availability = fields.Selection([ - ('never', 'Sell regardless of inventory'), - ('always', 'Show inventory on website and prevent sales if not enough stock'), - ('threshold', 'Show inventory below a threshold and prevent sales if not enough stock'), - ('custom', 'Show product-specific notifications'), - ], string='Inventory Availability', help='Adds an inventory availability status on the web product page.', default='never') - available_threshold = fields.Float(string='Availability Threshold', default=5.0) - custom_message = fields.Text(string='Custom Message', default='', translate=True) + allow_out_of_stock_order = fields.Boolean(string='Continue selling when out-of-stock', default=True) + + available_threshold = fields.Float(string='Show Threshold', default=5.0) + show_availability = fields.Boolean(string='Show availability Qty', default=False) + out_of_stock_message = fields.Html(string="Out-of-Stock Message", translate=html_translate) def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False): combination_info = super(ProductTemplate, self)._get_combination_info( @@ -27,28 +24,29 @@ class ProductTemplate(models.Model): if combination_info['product_id']: product = self.env['product.product'].sudo().browse(combination_info['product_id']) website = self.env['website'].get_current_website() - free_qty = product.with_context(warehouse=website.warehouse_id.id).free_qty + product_with_context = product.with_context(warehouse=website.warehouse_id.id) + + free_qty = product_with_context.free_qty combination_info.update({ 'free_qty': free_qty, - 'free_qty_formatted': self.env['ir.qweb.field.float'].value_to_html(free_qty, {'precision': 0}), 'product_type': product.type, - 'inventory_availability': product.inventory_availability, - 'available_threshold': product.available_threshold, - 'custom_message': product.custom_message, - 'product_template': product.product_tmpl_id.id, + 'product_template': self.id, + 'available_threshold': self.available_threshold, 'cart_qty': product.cart_qty, 'uom_name': product.uom_id.name, + 'allow_out_of_stock_order': self.allow_out_of_stock_order, + 'show_availability': self.show_availability, + 'out_of_stock_message': self.out_of_stock_message, }) else: product_template = self.sudo() combination_info.update({ 'free_qty': 0, 'product_type': product_template.type, - 'inventory_availability': product_template.inventory_availability, + 'allow_out_of_stock_order': product_template.allow_out_of_stock_order, 'available_threshold': product_template.available_threshold, - 'custom_message': product_template.custom_message, 'product_template': product_template.id, - 'cart_qty': 0 + 'cart_qty': 0, }) return combination_info diff --git a/addons/website_sale_stock/models/res_config_settings.py b/addons/website_sale_stock/models/res_config_settings.py index 17fcafc8276..3afa9f7666c 100644 --- a/addons/website_sale_stock/models/res_config_settings.py +++ b/addons/website_sale_stock/models/res_config_settings.py @@ -7,27 +7,31 @@ from odoo import fields, models, api class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' - inventory_availability = fields.Selection([ - ('never', 'Sell regardless of inventory'), - ('always', 'Show inventory on website and prevent sales if not enough stock'), - ('threshold', 'Show inventory when below the threshold and prevent sales if not enough stock'), - ('custom', 'Show product-specific notifications'), - ], string='Inventory Availability', default='never') - available_threshold = fields.Float(string='Availability Threshold') + allow_out_of_stock_order = fields.Boolean(string='Continue selling when out-of-stock', default=True) + available_threshold = fields.Float(string='Show Threshold', default=5.0) + show_availability = fields.Boolean(string='Show availability Qty', default=False) + website_warehouse_id = fields.Many2one('stock.warehouse', related='website_id.warehouse_id', domain="[('company_id', '=', website_company_id)]", readonly=False) def set_values(self): super(ResConfigSettings, self).set_values() IrDefault = self.env['ir.default'].sudo() - IrDefault.set('product.template', 'inventory_availability', self.inventory_availability) - IrDefault.set('product.template', 'available_threshold', self.available_threshold if self.inventory_availability == 'threshold' else None) + + IrDefault.set('product.template', 'allow_out_of_stock_order', self.allow_out_of_stock_order) + IrDefault.set('product.template', 'available_threshold', self.available_threshold) + IrDefault.set('product.template', 'show_availability', self.show_availability) @api.model def get_values(self): res = super(ResConfigSettings, self).get_values() IrDefault = self.env['ir.default'].sudo() - res.update(inventory_availability=IrDefault.get('product.template', 'inventory_availability') or 'never', - available_threshold=IrDefault.get('product.template', 'available_threshold') or 5.0) + + res.update( + allow_out_of_stock_order=IrDefault.get('product.template', 'allow_out_of_stock_order') or True, + available_threshold=IrDefault.get('product.template', 'available_threshold') or 5.0, + show_availability=IrDefault.get('product.template', 'show_availability') or False + ) + return res @api.onchange('website_company_id') diff --git a/addons/website_sale_stock/models/sale_order.py b/addons/website_sale_stock/models/sale_order.py index 6aa7a773981..fda99c3f8b6 100644 --- a/addons/website_sale_stock/models/sale_order.py +++ b/addons/website_sale_stock/models/sale_order.py @@ -15,7 +15,7 @@ class SaleOrder(models.Model): line_id = values.get('line_id') for line in self.order_line: - if line.product_id.type == 'product' and line.product_id.inventory_availability in ['always', 'threshold']: + if line.product_id.type == 'product' and not line.product_id.allow_out_of_stock_order: cart_qty = sum(self.order_line.filtered(lambda p: p.product_id.id == line.product_id.id).mapped('product_uom_qty')) # The quantity should be computed based on the warehouse of the website, not the # warehouse of the SO. diff --git a/addons/website_sale_stock/static/src/js/variant_mixin.js b/addons/website_sale_stock/static/src/js/variant_mixin.js index dd864fde9bd..02bbfba225e 100644 --- a/addons/website_sale_stock/static/src/js/variant_mixin.js +++ b/addons/website_sale_stock/static/src/js/variant_mixin.js @@ -1,15 +1,17 @@ odoo.define('website_sale_stock.VariantMixin', function (require) { 'use strict'; +const {Markup} = require('web.utils'); var VariantMixin = require('sale.VariantMixin'); var publicWidget = require('web.public.widget'); var ajax = require('web.ajax'); var core = require('web.core'); var QWeb = core.qweb; -var xml_load = ajax.loadXML( - '/website_sale_stock/static/src/xml/website_sale_stock_product_availability.xml', - QWeb -); + +const loadXml = async () => { + return ajax.loadXML('/website_sale_stock/static/src/xml/website_sale_stock_product_availability.xml', QWeb); +}; + require('website_sale.website_sale'); /** @@ -28,32 +30,33 @@ require('website_sale.website_sale'); * @param {Array} combination */ VariantMixin._onChangeCombinationStock = function (ev, $parent, combination) { - var product_id = 0; + let product_id = 0; // needed for list view of variants if ($parent.find('input.product_id:checked').length) { product_id = $parent.find('input.product_id:checked').val(); } else { product_id = $parent.find('.product_id').val(); } - var isMainProduct = combination.product_id && + const isMainProduct = combination.product_id && ($parent.is('.js_main_product') || $parent.is('.main_product')) && combination.product_id === parseInt(product_id); - if (!this.isWebsite || !isMainProduct){ + if (!this.isWebsite || !isMainProduct) { return; } - var qty = $parent.find('input[name="add_qty"]').val(); + const $addQtyInput = $parent.find('input[name="add_qty"]'); + let qty = $addQtyInput.val(); $parent.find('#add_to_cart').removeClass('out_of_stock'); $parent.find('.o_we_buy_now').removeClass('out_of_stock'); - if (combination.product_type === 'product' && _.contains(['always', 'threshold'], combination.inventory_availability)) { + if (combination.product_type === 'product' && !combination.allow_out_of_stock_order) { combination.free_qty -= parseInt(combination.cart_qty); + $addQtyInput.data('max', combination.free_qty || 1); if (combination.free_qty < 0) { combination.free_qty = 0; } if (qty > combination.free_qty) { - var $addQtyInput = $parent.find('input[name="add_qty"]'); qty = combination.free_qty || 1; $addQtyInput.val(qty); } @@ -63,12 +66,13 @@ VariantMixin._onChangeCombinationStock = function (ev, $parent, combination) { } } - xml_load.then(function () { + loadXml().then(function (result) { $('.oe_website_sale') .find('.availability_message_' + combination.product_template) .remove(); - - var $message = $(QWeb.render( + combination.has_out_of_stock_message = $(combination.out_of_stock_message).text() !== ''; + combination.out_of_stock_message = Markup(combination.out_of_stock_message); + const $message = $(QWeb.render( 'website_sale_stock.product_availability', combination )); @@ -81,9 +85,20 @@ publicWidget.registry.WebsiteSale.include({ * Adds the stock checking to the regular _onChangeCombination method * @override */ - _onChangeCombination: function (){ + _onChangeCombination: function () { this._super.apply(this, arguments); VariantMixin._onChangeCombinationStock.apply(this, arguments); + }, + /** + * Recomputes the combination after adding a product to the cart + * @override + */ + _onClickAdd(ev) { + return this._super.apply(this, arguments).then(() => { + if ($('div.availability_messages').length) { + this._getCombinationInfo(ev); + } + }); } }); diff --git a/addons/website_sale_stock/static/src/xml/website_sale_stock_product_availability.xml b/addons/website_sale_stock/static/src/xml/website_sale_stock_product_availability.xml index 1710ab1a4cb..b03ee7ec91a 100644 --- a/addons/website_sale_stock/static/src/xml/website_sale_stock_product_availability.xml +++ b/addons/website_sale_stock/static/src/xml/website_sale_stock_product_availability.xml @@ -1,29 +1,25 @@ - - - -
- available -
- -
- - available -
-
In stock
-
-
-
- You already added all in your cart. + + +
+ + Out of Stock +
+
+ Only left in stock.
-
Temporarily out of stock
-
-
- -
-
+
+ + You already added all the available product in your cart. + + + You already added in your cart. + +
+
+ diff --git a/addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py b/addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py index 42a794c0914..eff2e65b0e8 100644 --- a/addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py +++ b/addons/website_sale_stock/tests/test_website_sale_stock_product_warehouse.py @@ -31,14 +31,14 @@ class TestWebsiteSaleStockProductWarehouse(TestWebsiteSaleProductAttributeValueC # Create two stockable products product_1 = self.env['product.product'].create({ 'name': 'Product A', - 'inventory_availability': 'always', + 'allow_out_of_stock_order': False, 'type': 'product', 'default_code': 'E-COM1', }) product_2 = self.env['product.product'].create({ 'name': 'Product B', - 'inventory_availability': 'always', + 'allow_out_of_stock_order': False, 'type': 'product', 'default_code': 'E-COM2', }) diff --git a/addons/website_sale_stock/views/product_template_views.xml b/addons/website_sale_stock/views/product_template_views.xml index 21ffdbf6bf2..aee1ae398d3 100644 --- a/addons/website_sale_stock/views/product_template_views.xml +++ b/addons/website_sale_stock/views/product_template_views.xml @@ -6,9 +6,21 @@ - - - + diff --git a/addons/website_sale_stock/views/res_config_settings_views.xml b/addons/website_sale_stock/views/res_config_settings_views.xml index 64b527e5b99..0ae5827bf36 100644 --- a/addons/website_sale_stock/views/res_config_settings_views.xml +++ b/addons/website_sale_stock/views/res_config_settings_views.xml @@ -6,27 +6,46 @@ res.config.settings - - Manage the inventory of your products and display their availability status on the website. - - - -
-
-