[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:
Jeremy Kersten
2021-08-09 14:13:58 +00:00
co-authored by Younn Olivier
parent 91ba4a399d
commit 88cb6db6de
35 changed files with 539 additions and 118 deletions
+1 -1
View File
@@ -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))
+5
View File
@@ -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>
+10
View File
@@ -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);
}
});
}
});
@@ -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>
@@ -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);
});
},
});
@@ -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>