[IMP] website_sale*: add to whislist and notify stock
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) <jke@openerp.com> Co-authored-by: Kersten Jeremy <jke@odoo.com> Co-authored-by: Younn Olivier <yol@odoo.com>
This commit is contained in:
co-authored by
Younn Olivier
parent
91ba4a399d
commit
88cb6db6de
@@ -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))
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
<field name="html_class">bg-danger o_ribbon_left</field>
|
||||
</record>
|
||||
|
||||
<record id="website_sale.out_of_stock_ribbon" model="product.ribbon">
|
||||
<field name="html">Out of stock</field>
|
||||
<field name="html_class">bg-warning o_ribbon_left</field>
|
||||
</record>
|
||||
|
||||
<record id="website_sale.new_ribbon" model="product.ribbon">
|
||||
<field name="html">New!</field>
|
||||
<field name="html_class">bg-primary o_ribbon_left</field>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')]",
|
||||
|
||||
@@ -84,17 +84,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="product_availability_setting">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="module_website_sale_stock"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane" name="stock_inventory_availability">
|
||||
<label for="module_website_sale_stock"/>
|
||||
<div class="text-muted">
|
||||
Manage availability of products
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Pricing</h2>
|
||||
|
||||
@@ -21,4 +21,5 @@ It allows for comparing products from the wishlist
|
||||
],
|
||||
},
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
+18
-22
@@ -1,29 +1,25 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
|
||||
<t t-name="website_sale_stock.product_availability">
|
||||
<t t-if="product_type == 'product' and _.contains(['always', 'threshold'], inventory_availability)">
|
||||
<t t-if="free_qty gt 0">
|
||||
<div t-if="inventory_availability == 'always'" t-attf-class="availability_message_#{product_template} text-success mt16">
|
||||
<t t-esc="free_qty_formatted" /> <t t-esc="uom_name" /> available
|
||||
</div>
|
||||
<t t-if="inventory_availability == 'threshold'">
|
||||
<div t-if="free_qty lte available_threshold" t-attf-class="availability_message_#{product_template} text-warning mt16">
|
||||
<i class="fa fa-exclamation-triangle" title="Warning" role="img" aria-label="Warning"/>
|
||||
<t t-esc="free_qty_formatted" /> <t t-esc="uom_name" /> available
|
||||
</div>
|
||||
<div t-if="free_qty gt available_threshold" t-attf-class="availability_message_#{product_template} text-success mt16">In stock</div>
|
||||
</t>
|
||||
</t>
|
||||
<div t-if="cart_qty" t-attf-class="availability_message_#{product_template} text-warning mt8">
|
||||
You already added <t t-if="!free_qty">all</t> <t t-esc="cart_qty" /> <t t-esc="uom_name" /> in your cart.
|
||||
<t t-if="product_type == 'product'">
|
||||
<!-- show out_of_stock_message whatever the show_availability - pde's spec-->
|
||||
<div id="out_of_stock_message" t-if="free_qty lte 0 and !cart_qty" t-attf-class="availability_message_#{product_template}">
|
||||
<t t-if='has_out_of_stock_message' t-out='out_of_stock_message'/>
|
||||
<t t-elif="!allow_out_of_stock_order">Out of Stock</t>
|
||||
</div>
|
||||
<div id="threshold_message" t-elif="show_availibility and free_qty lte available_threshold" t-attf-class="availability_message_#{product_template}">
|
||||
Only <t t-esc='free_qty'/> <t t-esc="uom_name" /> left in stock.
|
||||
</div>
|
||||
<div t-if="!cart_qty and free_qty lte 0" t-attf-class="availability_message_#{product_template} text-danger mt16"><i class="fa fa-exclamation-triangle" role="img" aria-label="Warning" title="Warning"/> Temporarily out of stock</div>
|
||||
</t>
|
||||
<div t-if="product_type == 'product' and inventory_availability == 'custom'" t-attf-class="availability_message_#{product_template} text-success mt16">
|
||||
<t t-esc="custom_message" />
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<div t-if="!allow_out_of_stock_order and show_availability and cart_qty" t-attf-class="availability_message_#{product_template} text-warning mt8">
|
||||
<t t-if='!free_qty'>
|
||||
You already added all the available product in your cart.
|
||||
</t>
|
||||
<t t-else=''>
|
||||
You already added <t t-esc="cart_qty" /> <t t-esc="uom_name" /> in your cart.
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -6,9 +6,21 @@
|
||||
<field name="inherit_id" ref="website_sale.product_template_form_view" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='public_categ_ids']" position="after">
|
||||
<field name="inventory_availability" string="Availability" widget="selection" attrs="{'invisible': [('type', 'in', ['service', 'consu'])]}"/>
|
||||
<field name="available_threshold" attrs="{'invisible': ['|', ('type', 'in', ['service', 'consu']), ('inventory_availability', '!=', 'threshold')], 'required': [('inventory_availability', '=', 'threshold'), ('type', '=', 'product')]}"/>
|
||||
<field name="custom_message" attrs="{'invisible': ['|', ('type', 'in', ['service', 'consu']), ('inventory_availability', '!=', 'custom')]}"/>
|
||||
<label for="allow_out_of_stock_order" attrs="{'invisible': [('type', 'in', ['service', 'consu'])]}" string="Out-of-Stock"/>
|
||||
<div attrs="{'invisible': [('type', 'in', ['service', 'consu'])]}">
|
||||
<field name="allow_out_of_stock_order" class="oe_inline" /> Continue Selling
|
||||
</div>
|
||||
|
||||
<label for="show_availability" attrs="{'invisible': [('type', 'in', ['service', 'consu'])]}" string="Show Available Qty"/>
|
||||
<div attrs="{'invisible': [('type', 'in', ['service', 'consu'])]}">
|
||||
<field name="show_availability" class="oe_inline" />
|
||||
<span attrs="{'invisible': [('show_availability', '=', False)]}">
|
||||
<label for="available_threshold" string="only if below" class="o_light_label"/>
|
||||
<field name="available_threshold" class="oe_inline col-1" widget="integer"/>
|
||||
Units
|
||||
</span>
|
||||
</div>
|
||||
<field name="out_of_stock_message" attrs="{'invisible': [('type', 'in', ['service', 'consu'])]}" options="{'height': 150}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
@@ -6,27 +6,46 @@
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="website_sale.res_config_settings_view_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@id='product_availability_setting']" position="attributes">
|
||||
<attribute name="title">Manage the inventory of your products and display their availability status on the website.</attribute>
|
||||
</xpath>
|
||||
|
||||
<xpath expr="//div[@name='stock_inventory_availability']" position="inside">
|
||||
<div class="content-group">
|
||||
<div class="row mt16"
|
||||
id="website_warehouse_setting"
|
||||
groups="stock.group_stock_multi_warehouses">
|
||||
<label for="website_warehouse_id" string="Warehouse" class="col-lg-3 o_light_label" />
|
||||
<field name="website_warehouse_id"/>
|
||||
<xpath expr="//div[@id='comparator_option_setting']" position="after">
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="product_availability_setting">
|
||||
<div class="o_setting_left_pane">
|
||||
</div>
|
||||
<div class="row mt16"
|
||||
id="inventory_availability_setting"
|
||||
title="Default availability mode set on newly created storable products. This can be changed at the product level.">
|
||||
<label for="inventory_availability" string="Mode" class="col-lg-3 o_light_label" />
|
||||
<field name="inventory_availability" string="Inventory"/>
|
||||
</div><br/>
|
||||
<div class="row" id="available_treshold_setting" attrs="{'invisible': [('inventory_availability', '!=', 'threshold')]}">
|
||||
<label for="available_threshold" string="Threshold" class="col-lg-3 o_light_label" />
|
||||
<field name="available_threshold" class="oe_inline" attrs="{'required': [('inventory_availability', '=', 'threshold')]}"/>
|
||||
<div class="o_setting_right_pane" name="stock_inventory_availability">
|
||||
<div class="o_form_label">
|
||||
Inventory
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="row mt16"
|
||||
id="website_warehouse_setting"
|
||||
groups="stock.group_stock_multi_warehouses">
|
||||
<label for="website_warehouse_id" string="Warehouse" class="col-lg-3 o_light_label" />
|
||||
<field name="website_warehouse_id"/>
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="row mt16"
|
||||
id="allow_out_of_stock_order_setting"
|
||||
title="Default availability mode set on newly created storable products. This can be changed at the product level.">
|
||||
<div class="col-12">
|
||||
<label for="allow_out_of_stock_order" string="Out-of-Stock" class="p-0 col-4 o_light_label"/>
|
||||
<field name="allow_out_of_stock_order" class=" w-auto"/>
|
||||
<label for="allow_out_of_stock_order" class="o_light_label" string="Continue Selling"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="row"
|
||||
id="show_availability_setting"
|
||||
title="Default visibility for custom messages.">
|
||||
<div class="col-12">
|
||||
<label for="show_availability" string="Show Available Qty" class="p-0 col-4 o_light_label" />
|
||||
<field name="show_availability" class="w-auto"/>
|
||||
<label for="available_threshold" string="only if below" class="o_light_label"/>
|
||||
<field name="available_threshold" class="oe_inline col-1" widget="integer"/>
|
||||
Units
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!-- Shopping Cart Lines -->
|
||||
<template id="website_sale_stock_cart_lines" inherit_id="website_sale.cart_lines" name="Shopping Cart Lines">
|
||||
<xpath expr="//input[@type='text'][hasclass('quantity')]" position="attributes">
|
||||
<attribute name='t-att-data-max'>(line.product_uom_qty + (line.product_id.free_qty - line.product_id.cart_qty)) if line.product_id.type == 'product' and line.product_id.inventory_availability in ['always', 'threshold'] else None</attribute>
|
||||
<attribute name='t-att-data-max'>(line.product_uom_qty + (line.product_id.free_qty - line.product_id.cart_qty)) if line.product_id.type == 'product' and not line.product_id.allow_out_of_stock_order else None</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('css_quantity')]//i[hasclass('fa-plus')]/.." position="replace">
|
||||
<t t-if="line._get_stock_warning(clear=False)">
|
||||
@@ -50,5 +50,4 @@
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
<odoo>
|
||||
<template id="website_sale_stock_modal" inherit_id="sale_product_configurator.product_quantity_config" name="Stocks Modal">
|
||||
<xpath expr="//input[@type='text'][hasclass('quantity')]" position="attributes">
|
||||
<attribute name='t-att-data-max'>max(product.sudo().free_qty - product.cart_qty, 1) if handle_stock and product.type == "product" and product.inventory_availability in ["always", "threshold"] else None</attribute>
|
||||
<attribute name='t-att-data-max'>max(product.sudo().free_qty - product.cart_qty, 1) if handle_stock and product.type == "product" and not product.allow_out_of_stock_order else None</attribute>
|
||||
</xpath>
|
||||
<xpath expr="//div[hasclass('css_quantity')]" position="after">
|
||||
<t t-if="handle_stock">
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -0,0 +1,27 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
{
|
||||
'name': 'Product Availability Notifications',
|
||||
'category': 'Website/Website',
|
||||
'summary': 'Notify the user when a product is back in stock',
|
||||
'description': """
|
||||
Allow the user to select if he wants to receive email notifications when a product of his wishlist gets back in stock.
|
||||
""",
|
||||
'depends': [
|
||||
'website_sale_stock',
|
||||
'website_sale_wishlist',
|
||||
],
|
||||
'data': [
|
||||
'views/templates.xml',
|
||||
'data/template_email.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'website_sale_stock_wishlist/static/src/**/*',
|
||||
],
|
||||
},
|
||||
'auto_install': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
from . import variant
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
from odoo.addons.website_sale.controllers.main import WebsiteSale
|
||||
|
||||
|
||||
class WebsiteSaleStockWishlist(WebsiteSale):
|
||||
@http.route(['/shop/wishlist/notify/<model("product.wishlist"):wish>'], type='json', auth="public", website=True)
|
||||
def notify_stock(self, wish, notify=True, **kw):
|
||||
if not request.website.is_public_user():
|
||||
wish['stock_notification'] = notify
|
||||
return wish['stock_notification']
|
||||
@@ -0,0 +1,13 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import http
|
||||
from odoo.addons.website_sale.controllers.variant import WebsiteSaleVariantController
|
||||
|
||||
|
||||
class WebsiteSaleStockWishlistVariantController(WebsiteSaleVariantController):
|
||||
@http.route()
|
||||
def get_combination_info_website(self, product_template_id, product_id, combination, add_qty, **kw):
|
||||
kw['context'] = kw.get('context', {})
|
||||
kw['context'].update(website_sale_stock_wishlist_get_wish=True)
|
||||
return super().get_combination_info_website(product_template_id, product_id, combination, add_qty, **kw)
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="ir_cron_send_availability_email" model="ir.cron">
|
||||
<field name="name">Wishlist: send email regarding products availability</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="model_id" ref="model_product_wishlist"/>
|
||||
<field name="code">model._send_availability_email()</field>
|
||||
<field name="state">code</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" ?>
|
||||
<odoo>
|
||||
<template id="availability_email_body">
|
||||
<div id="body">
|
||||
<p>Dear Customer,</p>
|
||||
<p>The following product is now available.</p>
|
||||
<div style="display: flex; justify-content: center; width: 100%;">
|
||||
<a t-attf-href="#{wishlist.product_id.website_url}">
|
||||
<img t-attf-src="/web/image/product.product/#{wishlist.product_id.id}/image_1920"/>
|
||||
</a>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: row; align-items: center; justify-content: center; width: 100%;">
|
||||
<p t-esc="wishlist.product_id.name"/>
|
||||
<p style="margin-left: 0.5em; margin-right: 0.5em">-</p>
|
||||
<p t-esc="wishlist.price" t-options="{'widget': 'monetary', 'display_currency': wishlist.currency_id}"/>
|
||||
</div>
|
||||
<p t-esc="wishlist.product_id.description_sale"/>
|
||||
<div style="display: flex; justify-content: center; width: 100%;">
|
||||
<a t-attf-href="#{wishlist.product_id.website_url}" style="background-color: #875A7B; padding: 8px 16px 8px 16px; text-decoration: none; color: #fff; border-radius: 5px; font-size:13px;">
|
||||
Order Now
|
||||
</a>
|
||||
</div>
|
||||
<p>Regards,</p>
|
||||
</div>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import product_wishlist
|
||||
from . import product_template
|
||||
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import models
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
||||
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(
|
||||
combination=combination,
|
||||
product_id=product_id,
|
||||
add_qty=add_qty,
|
||||
pricelist=pricelist,
|
||||
parent_combination=parent_combination,
|
||||
only_template=only_template,
|
||||
)
|
||||
|
||||
if not self.env.context.get('website_sale_stock_wishlist_get_wish'):
|
||||
return combination_info
|
||||
|
||||
if combination_info['product_id']:
|
||||
product = self.env['product.product'].sudo().browse(combination_info["product_id"])
|
||||
combination_info['wish'] = product._is_in_wishlist()
|
||||
|
||||
return combination_info
|
||||
@@ -0,0 +1,54 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo import fields, models, _
|
||||
|
||||
|
||||
class ProductWishlist(models.Model):
|
||||
_inherit = "product.wishlist"
|
||||
|
||||
stock_notification = fields.Boolean(default=False, required=True)
|
||||
|
||||
def _add_to_wishlist(self, pricelist_id, currency_id, website_id, price, product_id, partner_id=False):
|
||||
wish = super()._add_to_wishlist(
|
||||
pricelist_id=pricelist_id,
|
||||
currency_id=currency_id,
|
||||
website_id=website_id,
|
||||
price=price,
|
||||
product_id=product_id,
|
||||
partner_id=partner_id,
|
||||
)
|
||||
wish['stock_notification'] = wish.product_id._is_sold_out()
|
||||
|
||||
return wish
|
||||
|
||||
def _send_availability_email(self):
|
||||
to_notify = self.env['product.wishlist'].search([('stock_notification', '=', True)])
|
||||
|
||||
if not to_notify:
|
||||
return
|
||||
|
||||
notified = self.env['product.wishlist']
|
||||
|
||||
# cannot group by product_id because it depend of website_id -> warehouse_id
|
||||
tmpl = self.env.ref("website_sale_stock_wishlist.availability_email_body")
|
||||
for wishlist in to_notify:
|
||||
product = wishlist.with_context(website_id=wishlist.website_id.id).product_id
|
||||
if not product._is_sold_out():
|
||||
body_html = tmpl._render({"wishlist": wishlist})
|
||||
msg = self.env["mail.message"].sudo().new(dict(body=body_html, record_name=product.name))
|
||||
full_mail = self.env["mail.render.mixin"]._render_encapsulate(
|
||||
"mail.mail_notification_light",
|
||||
body_html,
|
||||
add_context=dict(message=msg, model_description=_("Wishlist")),
|
||||
)
|
||||
mail_values = {
|
||||
"subject": _("The product '%(product_name)s' is now available") % {'product_name': product.name},
|
||||
"email_from": (product.company_id.partner_id or self.env.user).email_formatted,
|
||||
"email_to": wishlist.partner_id.email_formatted,
|
||||
"body_html": full_mail,
|
||||
}
|
||||
|
||||
mail = self.env["mail.mail"].sudo().create(mail_values)
|
||||
mail.send(raise_exception=False)
|
||||
notified += wishlist
|
||||
notified.stock_notification = False
|
||||
@@ -0,0 +1,35 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import publicWidget from "web.public.widget";
|
||||
import "website_sale.website_sale";
|
||||
import ajax from "web.ajax";
|
||||
import { qweb as QWeb } from "web.core";
|
||||
|
||||
const loadXml = async () => {
|
||||
return ajax.loadXML(
|
||||
"/website_sale_stock_wishlist/static/src/xml/product_availability.xml",
|
||||
QWeb
|
||||
);
|
||||
};
|
||||
|
||||
publicWidget.registry.WebsiteSale.include({
|
||||
/**
|
||||
* It will display additional info messages regarding the select product's stock and the wishlist.
|
||||
* @override
|
||||
*/
|
||||
_onChangeCombination: function (ev, $parent, combination) {
|
||||
this._super.apply(this, arguments);
|
||||
loadXml().then(function () {
|
||||
if ($('.o_add_wishlist_dyn').length) {
|
||||
var $message = $(
|
||||
QWeb.render(
|
||||
"website_sale_stock_wishlist.product_availability",
|
||||
combination
|
||||
)
|
||||
);
|
||||
$message.appendTo($("div.availability_messages"));
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import publicWidget from "web.public.widget";
|
||||
import "website_sale_wishlist.wishlist";
|
||||
|
||||
publicWidget.registry.ProductWishlist.include({
|
||||
events: _.extend(
|
||||
{},
|
||||
publicWidget.registry.ProductWishlist.prototype.events,
|
||||
{
|
||||
"click .wishlist-section .o_notify_stock": "_onClickNotifyStock",
|
||||
}
|
||||
),
|
||||
/**
|
||||
* It will remove wishlist indication when adding a product to the wishlist.
|
||||
* @override
|
||||
*/
|
||||
_addNewProducts: function () {
|
||||
this._super.apply(this, arguments);
|
||||
this.$("#stock_wishlist_message").addClass("d-none");
|
||||
},
|
||||
/**
|
||||
* @private
|
||||
* @param {Event} ev
|
||||
*/
|
||||
_onClickNotifyStock: function (ev) {
|
||||
const tr = $(ev.currentTarget).parents("tr");
|
||||
const wish = tr.data("wish-id");
|
||||
const icon = $(ev.currentTarget).find("i");
|
||||
const currentNotify = ev.currentTarget.dataset.notify === 'True';
|
||||
this._rpc({
|
||||
route: "/shop/wishlist/notify/" + wish,
|
||||
params: {
|
||||
notify: !currentNotify,
|
||||
}
|
||||
}).then((notify) => {
|
||||
ev.currentTarget.dataset.notify = notify ? 'True' : 'False';
|
||||
icon.toggleClass("fa-check-square-o", notify);
|
||||
icon.toggleClass("fa-square-o", !notify);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.o_add_to_compare {
|
||||
.o_notify_stock {
|
||||
text-align: left;
|
||||
|
||||
small {
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
|
||||
<templates>
|
||||
<t t-name="website_sale_stock_wishlist.product_availability" inherit_id="website_sale_stock.product_availability">
|
||||
<div id="stock_wishlist_message" t-if="product_type == 'product' and !free_qty and !allow_out_of_stock_order and !wish" t-attf-class="availability_message_#{product_template} text-success mt8">
|
||||
Add the item to your wishlist to be notified when the product is back in stock.
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_wishlist_emails
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.website_sale_stock.tests.test_website_sale_stock_product_warehouse import TestWebsiteSaleStockProductWarehouse
|
||||
|
||||
|
||||
class TestWishlistEmail(TestWebsiteSaleStockProductWarehouse):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# Create a partner
|
||||
cls.partner = cls.env['res.partner'].create({
|
||||
'name': 'John',
|
||||
'email': 'john@doe.com'
|
||||
})
|
||||
|
||||
# Create the warehouse
|
||||
warehouse = cls.env['stock.warehouse'].create({
|
||||
'name': 'Wishlist Warehouse',
|
||||
'code': 'W_WH'
|
||||
})
|
||||
current_website = cls.env['website'].get_current_website()
|
||||
current_website.warehouse_id = warehouse
|
||||
|
||||
cls.warehouse = warehouse
|
||||
cls.current_website = current_website
|
||||
|
||||
# Create two stockable products
|
||||
cls.product_1 = cls.env['product.product'].create({
|
||||
'name': 'Product A',
|
||||
'allow_out_of_stock_order': False,
|
||||
'type': 'product',
|
||||
'default_code': 'E-COM1',
|
||||
})
|
||||
cls.product_2 = cls.env['product.product'].create({
|
||||
'name': 'Product B',
|
||||
'allow_out_of_stock_order': False,
|
||||
'type': 'product',
|
||||
'default_code': 'E-COM2',
|
||||
})
|
||||
|
||||
# Pricelist and currency
|
||||
cls.pricelist = cls.env['product.pricelist'].create({
|
||||
'name': 'Public Pricelist',
|
||||
})
|
||||
cls.currency = cls.env.ref("base.USD")
|
||||
|
||||
def test_send_availability_email(self):
|
||||
"""
|
||||
For two products in a users wishlist, test that an email is sent
|
||||
when one is replenished
|
||||
"""
|
||||
# Update quantity of Product A
|
||||
quants = self.env['stock.quant'].with_context(inventory_mode=True).create({
|
||||
'product_id': self.product_1.id,
|
||||
'inventory_quantity': 10.0,
|
||||
'location_id': self.warehouse.lot_stock_id.id,
|
||||
})
|
||||
quants.action_apply_inventory()
|
||||
|
||||
# Only sold out product B wishlist should be registered for
|
||||
# email notifications
|
||||
Wishlist = self.env['product.wishlist']
|
||||
|
||||
wish_1 = Wishlist._add_to_wishlist(product_id=self.product_1.id, partner_id=self.partner.id, website_id=self.current_website.id, currency_id=self.currency.id, pricelist_id=self.pricelist.id, price=self.product_1.price)
|
||||
self.assertEqual(wish_1.stock_notification, False)
|
||||
wish_2 = Wishlist._add_to_wishlist(product_id=self.product_2.id, partner_id=self.partner.id, website_id=self.current_website.id, currency_id=self.currency.id, pricelist_id=self.pricelist.id, price=self.product_2.price)
|
||||
self.assertEqual(wish_2.stock_notification, True)
|
||||
|
||||
# No email should be sent
|
||||
Wishlist._send_availability_email()
|
||||
emails = self.env['mail.mail'].search([('email_to', '=', self.partner.email)])
|
||||
self.assertEqual(len(emails), 0)
|
||||
|
||||
# Replenish Product B
|
||||
quants = self.env['stock.quant'].with_context(inventory_mode=True).create({
|
||||
'product_id': self.product_2.id,
|
||||
'inventory_quantity': 10.0,
|
||||
'location_id': self.warehouse.lot_stock_id.id,
|
||||
})
|
||||
quants.action_apply_inventory()
|
||||
|
||||
# An email should be sent for Product B
|
||||
Wishlist._send_availability_email()
|
||||
emails = self.env['mail.mail'].search([('email_to', '=', self.partner.email_formatted)])
|
||||
self.assertEqual(emails[0].subject, "The product 'Product B' is now available")
|
||||
self.assertEqual(wish_2.stock_notification, False)
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="product_wishlist" inherit_id="website_sale_wishlist.product_wishlist">
|
||||
<xpath expr="//button[hasclass('o_wish_rm')]" position="before">
|
||||
<small class="text-danger d-md-block" t-if="wish.product_id.product_tmpl_id._is_sold_out()">Temporarily out of stock</small>
|
||||
</xpath>
|
||||
<xpath expr="//button[hasclass('o_wish_rm')]" position="after">
|
||||
<t t-set="notify" t-value="wish.stock_notification"/>
|
||||
<button groups="base.group_no_one" t-att-data-notify="notify" t-if="notify or wish.product_id.product_tmpl_id._is_sold_out()" type="button" class="btn btn-link o_notify_stock no-decoration">
|
||||
<small><i t-attf-class="fa #{'fa-check-square-o' if notify else 'fa-square-o'}"></i> Be notified when back in stock</small>
|
||||
</button>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
@@ -174,7 +174,7 @@
|
||||
<body>
|
||||
<t t-foreach="wishes" t-as="wish">
|
||||
<tr t-att-data-wish-id='wish.id' t-att-data-product-id='wish.product_id.id'>
|
||||
<td class='td-img'>
|
||||
<td class='td-img align-middle'>
|
||||
<a t-att-href="wish.product_id.website_url">
|
||||
<img t-attf-src="/web/image/product.product/#{wish.product_id.id}/image_128" class="img img-fluid" style="margin:auto;" alt="Product image"/>
|
||||
</a>
|
||||
@@ -184,11 +184,11 @@
|
||||
<small class='d-none d-md-block'><p t-field="wish.product_id.description_sale" class="text-muted"/></small>
|
||||
<button type="button" class="btn btn-link o_wish_rm no-decoration"><small><i class='fa fa-trash-o'></i> Remove</small></button>
|
||||
</td>
|
||||
<td>
|
||||
<td class="align-middle">
|
||||
<t t-set="combination_info" t-value="wish.product_id._get_combination_info_variant()"/>
|
||||
<t t-esc="combination_info['price']" t-options="{'widget': 'monetary', 'display_currency': website.pricelist_id.currency_id}"/>
|
||||
</td>
|
||||
<td class='text-center td-wish-btn'>
|
||||
<td class='text-center td-wish-btn align-middle'>
|
||||
<input name="product_id" t-att-value="wish.product_id.id" type="hidden"/>
|
||||
<button type="button" role="button" class="btn btn-secondary btn-block o_wish_add mb4" >Add <span class='d-none d-md-inline'>to Cart</span></button>
|
||||
</td>
|
||||
|
||||
Reference in New Issue
Block a user