[IMP] websale_stock: back in stock notification

This commit adds a message "Be notified when in stock" on a product when it is out
of stock. When clicking the message, a form appears where the user can enter
his email. If he is a logged user, the email associated to the partner of the
user is pre-filled in the form. Otherwise the user enters his email.

task-id 2806828

closes odoo/odoo#96633

Related: odoo/upgrade#3718
Signed-off-by: William Braeckman (wbr) <wbr@odoo.com>
This commit is contained in:
Alexandre de Pape (alpa)
2022-08-25 19:57:26 +02:00
parent 55ab08904e
commit 2e4a225fb4
26 changed files with 485 additions and 221 deletions
+6 -1
View File
@@ -19,7 +19,9 @@ Then it can be made specific at the product level.
'views/product_template_views.xml',
'views/res_config_settings_views.xml',
'views/website_sale_stock_templates.xml',
'views/stock_picking_views.xml'
'views/stock_picking_views.xml',
'data/template_email.xml',
'data/ir_cron_data.xml',
],
'demo': [
'data/website_sale_stock_demo.xml',
@@ -29,6 +31,9 @@ Then it can be made specific at the product level.
'web.assets_frontend': [
'website_sale_stock/static/src/js/**/*',
],
'web.assets_tests': [
'website_sale_stock/static/tests/tours/website_sale_stock_stock_notification.js',
],
},
'license': 'LGPL-3',
}
+30 -2
View File
@@ -2,10 +2,11 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.website_sale.controllers import main as website_sale_controller
from odoo import http
from odoo.tools import email_re
from odoo import http, _
from odoo.http import request
from odoo.exceptions import ValidationError
from werkzeug.exceptions import BadRequest
class PaymentPortal(website_sale_controller.PaymentPortal):
@@ -26,3 +27,30 @@ class PaymentPortal(website_sale_controller.PaymentPortal):
if values:
raise ValidationError(' '.join(values))
return super().shop_payment_transaction(*args, **kwargs)
class WebsiteSale(website_sale_controller.WebsiteSale):
@http.route(['/shop/add/stock_notification'], type="json", auth="public", website=True)
def add_stock_email_notification(self, email, product_id):
if not email_re.match(email):
raise BadRequest(_("Invalid Email"))
product = request.env['product.product'].browse(int(product_id))
partners = request.env['res.partner'].sudo()._mail_find_partner_from_emails([email], force_create=True)
partner = partners[0]
if not product._has_stock_notification(partner):
product.sudo().stock_notification_partner_ids += partner
if request.website.is_public_user():
request.session['product_with_stock_notification_enabled'] = request.session.get(
'product_with_stock_notification_enabled',
set()
) | {product_id}
request.session['stock_notification_email'] = email
def _prepare_product_values(self, product, category='', search='', **kwargs):
values = super()._prepare_product_values(product, category, search, **kwargs)
# We need the user mail to prefill the back of stock notification, so we put it in the value that will be sent
values['user_email'] = request.env.user.email or request.session.get('stock_notification_email', '')
return values
@@ -1,12 +1,12 @@
<?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="name">Product: 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="model_id" ref="model_product_product"/>
<field name="code">model._send_availability_email()</field>
<field name="state">code</field>
</record>
@@ -5,18 +5,24 @@
<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 t-attf-href="#{product.website_url}">
<img t-attf-src="/web/image/product.product/#{product.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 t-esc="product.name"/>
<p t-if="product.product_template_attribute_value_ids"
style="margin-left: 0.5em;">
(<t
t-out="', '.join(product.product_template_attribute_value_ids.mapped('name'))"
/>)
</p>
<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}"/>
<p t-esc="product.list_price" t-options="{'widget': 'monetary', 'display_currency': product.currency_id}"/>
</div>
<p t-esc="wishlist.product_id.description_sale"/>
<p t-esc="product.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;">
<a t-attf-href="#{product.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>
@@ -1,17 +1,25 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo import models, fields, _
from odoo.http import request
class ProductProduct(models.Model):
_inherit = 'product.product'
stock_notification_partner_ids = fields.Many2many('res.partner', relation='stock_notification_product_partner_rel', string='Back in stock Notifications')
def _has_stock_notification(self, partner):
self.ensure_one()
return partner in self.stock_notification_partner_ids
def _get_cart_qty(self, website=None):
if not self.allow_out_of_stock_order:
website = website or self.env['website'].get_current_website()
cart = website and request and website.sale_get_order() or None
# When the cron is run manually, request has no attribute website, and that would cause a crash
# so we check for it
cart = website and request and hasattr(request, 'website') and website.sale_get_order() or None
if cart:
return sum(
cart._get_common_product_lines(product=self).mapped('product_uom_qty')
@@ -21,3 +29,26 @@ class ProductProduct(models.Model):
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
def _send_availability_email(self):
for product in self.search([('stock_notification_partner_ids', '!=', False)]):
if product._is_sold_out():
continue
for partner in product.stock_notification_partner_ids:
body_html = self.env['ir.qweb']._render('website_sale_stock.availability_email_body', {"product": product})
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=_("Product")),
)
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": partner.email_formatted,
"body_html": full_mail,
}
mail = self.env["mail.mail"].sudo().create(mail_values)
mail.send(raise_exception=False)
product.stock_notification_partner_ids -= partner
@@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo.tools.translate import html_translate
from odoo.http import request
class ProductTemplate(models.Model):
@@ -25,6 +26,11 @@ class ProductTemplate(models.Model):
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._get_warehouse_available()).free_qty
has_stock_notification = product._has_stock_notification(self.env.user.partner_id) \
or request \
and product.id in request.session.get('product_with_stock_notification_enabled',
set())
stock_notification_email = request and request.session.get('stock_notification_email', '')
combination_info.update({
'free_qty': free_qty,
'product_type': product.type,
@@ -35,6 +41,8 @@ class ProductTemplate(models.Model):
'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,
'has_stock_notification': has_stock_notification,
'stock_notification_email': stock_notification_email,
})
else:
product_template = self.sudo()
@@ -0,0 +1,63 @@
/** @odoo-module alias=website_sale_stock.website_sale**/
import { WebsiteSale } from 'website_sale.website_sale';
import { is_email } from 'web.utils';
WebsiteSale.include({
events: Object.assign({}, WebsiteSale.prototype.events, {
'click #product_stock_notification_message': '_onClickProductStockNotificationMessage',
'click #product_stock_notification_form_submit_button': '_onClickSubmitProductStockNotificationForm',
}),
_onClickProductStockNotificationMessage: function (ev) {
const partnerEmail = document.querySelector('#wsale_user_email').value;
const emailInputEl = document.querySelector('#stock_notification_input');
emailInputEl.value = partnerEmail;
this._handleClickStockNotificationMessage(ev);
},
_onClickSubmitProductStockNotificationForm: function (ev) {
const formEl = ev.currentTarget.closest('#stock_notification_form');
const productId = parseInt(formEl.querySelector('input[name="product_id"]').value);
this._handleClickSubmitStockNotificationForm(ev, productId);
},
_handleClickStockNotificationMessage(ev) {
ev.currentTarget.classList.add('d-none');
ev.currentTarget.parentElement.querySelector('#stock_notification_form').classList.remove('d-none');
},
_handleClickSubmitStockNotificationForm(ev, productId) {
const stockNotificationEl = ev.currentTarget.closest('#stock_notification_div');
const formEl = stockNotificationEl.querySelector('#stock_notification_form');
const email = stockNotificationEl.querySelector('#stock_notification_input').value.trim();
if (!is_email(email)) {
return this._displayEmailIncorrectMessage(stockNotificationEl);
}
this._rpc({
route: "/shop/add/stock_notification",
params: {
product_id: productId,
email,
},
}).then((data) => {
const message = stockNotificationEl.querySelector('#stock_notification_success_message');
message.classList.remove('d-none');
formEl.classList.add('d-none');
}).catch((error) => {
this._displayEmailIncorrectMessage(stockNotificationEl);
});
},
_displayEmailIncorrectMessage(stockNotificationEl) {
const incorrectIconEl = stockNotificationEl.querySelector('#stock_notification_input_incorrect');
incorrectIconEl.classList.remove('d-none');
}
});
export default WebsiteSale;
@@ -3,10 +3,47 @@
<templates>
<t t-name="website_sale_stock.product_availability">
<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} text-danger fw-bold">
<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 t-if="free_qty lte 0 and !cart_qty" t-attf-class="availability_message_#{product_template} mb-1">
<div id="out_of_stock_message">
<t t-if='has_out_of_stock_message' t-out='out_of_stock_message'/>
<t t-elif="!allow_out_of_stock_order">
<div class="text-danger fw-bold">
<i class="fa fa-times me-1"/>
Out of Stock
</div>
</t>
</div>
<div id="stock_notification_div" t-if="!allow_out_of_stock_order">
<div class="btn btn-link px-0" t-if="!has_stock_notification"
id="product_stock_notification_message">
<i class="fa fa-envelope-o me-1"/>
Get notified when back in stock
</div>
<div id="stock_notification_form" class="d-none">
<div class="input-group">
<input class="form-control"
id="stock_notification_input" name="email"
type="text" placeholder="youremail@gmail.com" t-value="stock_notification_email if stock_notification_email else None"/>
<input name="product_id" type="hidden" t-att-value="product_id"/>
<div id="product_stock_notification_form_submit_button" class="btn btn-secondary">
<i class="fa fa-paper-plane"/>
</div>
<div id="stock_notification_input_incorrect" class="btn d-none">
<i class="fa fa-times text-danger"/>
Invalid email
</div>
</div>
</div>
<div id="stock_notification_success_message"
t-att-class="has_stock_notification ? '' : 'd-none'">
<div class="text-muted">
<i class="fa fa-bell"/>
We'll notify you once the product is back in stock.
</div>
</div>
</div>
</div>
<div id="threshold_message" t-elif="show_availability and free_qty lte available_threshold" t-attf-class="availability_message_#{product_template} text-warning fw-bold">
Only <t t-esc='free_qty'/> <t t-esc="uom_name" /> left in stock.
@@ -0,0 +1,32 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
tour.register('back_in_stock_notification_product', {
test: true,
url: '/shop?search=Macbook%20Pro',
},
[
{
content: "Open product page",
trigger: 'a:contains("Macbook Pro")',
},
{
content: "Click on 'Be notified when back in stock'",
trigger: '#product_stock_notification_message',
},
{
content: "Fill email form",
trigger: 'div[id="stock_notification_form"] input[name="email"]',
run: 'text test@test.test',
},
{
content: "Click on the button",
trigger: '#product_stock_notification_form_submit_button',
},
{
content: "Success Message",
trigger: '#stock_notification_success_message',
},
],
);
@@ -2,3 +2,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_website_sale_stock_product_warehouse
from . import test_website_sale_stock_stock_notification
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.tests.common import HttpCase
@tagged('post_install', '-at_install')
class TestStockNotificationProduct(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
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
cls.product = cls.env['product.product'].create({
'name': 'Macbook Pro',
'website_published': True,
'type': 'product',
'allow_out_of_stock_order': False,
})
cls.pricelist = cls.env['product.pricelist'].create({
'name': 'Public Pricelist',
})
cls.currency = cls.env.ref("base.USD")
def test_back_in_stock_notification_product(self):
self.start_tour("/", 'back_in_stock_notification_product')
partner_ids = self.env['res.partner']._mail_find_partner_from_emails(['test@test.test'])
partner = partner_ids[0]
ProductProduct = self.env['product.product']
product = ProductProduct.browse(self.product.id)
self.assertTrue(product._has_stock_notification(partner))
# No email should be sent
ProductProduct._send_availability_email()
emails = self.env['mail.mail'].search([('email_to', '=', partner.email_formatted)])
self.assertEqual(len(emails), 0)
# Replenish Product
quants = self.env['stock.quant'].with_context(inventory_mode=True).create({
'product_id': self.product.id,
'inventory_quantity': 10.0,
'location_id': self.warehouse.lot_stock_id.id,
})
quants.action_apply_inventory()
ProductProduct._send_availability_email()
emails = self.env['mail.mail'].search([('email_to', '=', partner.email_formatted)])
self.assertEqual(emails[0].subject, "The product 'Macbook Pro' is now available")
self.assertFalse(product._has_stock_notification(partner))
@@ -14,6 +14,9 @@
<xpath expr="//div[@id='o_wsale_cta_wrapper']" position="after">
<div class="availability_messages o_not_editable"/>
</xpath>
<xpath expr="//div[@id='product_details']" position="inside">
<input id="wsale_user_email" type="hidden" t-att-value="user_email"/>
</xpath>
</template>
</odoo>
@@ -14,13 +14,14 @@ Allow the user to select if he wants to receive email notifications when a produ
],
'data': [
'views/website_sale_stock_wishlist_templates.xml',
'data/template_email.xml',
'data/ir_cron_data.xml',
],
'assets': {
'web.assets_frontend': [
'website_sale_stock_wishlist/static/src/**/*',
],
'web.assets_tests': [
'website_sale_stock_wishlist/static/tests/tours/website_sale_stock_wishlist_stock_notification.js',
],
},
'auto_install': True,
'license': 'LGPL-3',
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
from . import variant
@@ -1,13 +0,0 @@
# -*- 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']
@@ -1,5 +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
from . import product_wishlist
@@ -20,6 +20,6 @@ class ProductTemplate(models.Model):
if combination_info['product_id']:
product = self.env['product.product'].sudo().browse(combination_info["product_id"])
combination_info['wish'] = product._is_in_wishlist()
combination_info['is_in_wishlist'] = product._is_in_wishlist()
return combination_info
@@ -1,53 +1,19 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, _
from odoo import fields, models, api
class ProductWishlist(models.Model):
_inherit = "product.wishlist"
stock_notification = fields.Boolean(default=False, required=True)
stock_notification = fields.Boolean(compute='_compute_stock_notification', 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()
@api.depends("product_id", "partner_id")
def _compute_stock_notification(self):
for record in self:
record.stock_notification = record.product_id._has_stock_notification(record.partner_id)
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
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 = self.env['ir.qweb']._render('website_sale_stock_wishlist.availability_email_body', {"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
def _inverse_stock_notification(self):
for record in self:
if record.stock_notification:
record.product_id.stock_notification_partner_ids += record.partner_id
@@ -0,0 +1,20 @@
/** @odoo-module **/
import WebsiteSale from 'website_sale_stock.website_sale';
WebsiteSale.include({
events: Object.assign({}, WebsiteSale.prototype.events, {
'click #wishlist_stock_notification_message': '_onClickWishlistStockNotificationMessage',
'click #wishlist_stock_notification_form_submit_button': '_onClickSubmitWishlistStockNotificationForm',
}),
_onClickWishlistStockNotificationMessage(ev) {
this._handleClickStockNotificationMessage(ev);
},
_onClickSubmitWishlistStockNotificationForm(ev) {
const productId = JSON.parse(ev.currentTarget.closest('tr').dataset.productTrackingInfo).item_id;
this._handleClickSubmitStockNotificationForm(ev, productId);
},
});
@@ -4,13 +4,6 @@ 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',
}),
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* Removes wishlist indication when adding a product to the wishlist.
@@ -19,29 +12,11 @@ publicWidget.registry.ProductWishlist.include({
*/
_addNewProducts: function () {
this._super(...arguments);
const wishlistMessageEl = this.el.querySelector('#stock_wishlist_message');
if (wishlistMessageEl) {
wishlistMessageEl.classList.add('d-none');
const saveForLaterButtonEl = document.querySelector('#wsale_save_for_later_button');
const addedToYourWishListAlertEl = document.querySelector('#wsale_added_to_your_wishlist_alert');
if (saveForLaterButtonEl) {
saveForLaterButtonEl.classList.add('d-none');
addedToYourWishListAlertEl.classList.remove('d-none');
}
},
/**
* @private
* @param {Event} ev
*/
_onClickNotifyStock: function (ev) {
const targetEl = ev.currentTarget;
const wishID = targetEl.closest('tr').dataset.wishId;
const iconEl = targetEl.querySelector('i');
const currentNotify = targetEl.dataset.notify === 'True';
this._rpc({
route: `/shop/wishlist/notify/${wishID}`,
params: {
notify: !currentNotify,
}
}).then((notify) => {
targetEl.dataset.notify = notify ? 'True' : 'False';
iconEl.classList.toggle('fa-check-square-o', notify);
iconEl.classList.toggle('fa-square-o', !notify);
});
},
});
@@ -4,27 +4,22 @@
<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" t-attf-class="availability_message_#{product_template} my-2 d-flex align-items-center flex-column flex-md-row">
<button
t-if="!wish"
type="button"
role="button"
class="btn btn-primary btn-lg text-nowrap my-2 o_add_wishlist_dyn"
t-att-data-product-template-id="product_id"
t-att-data-product-product-id="product_id"
data-action="o_wishlist"
title="Add to wishlist">
<i class="fa fa-heart-o me-2" role="img" aria-label="Add to wishlist"/>
Save for later
id="wsale_save_for_later_button"
t-if="!is_in_wishlist"
type="button"
role="button"
class="btn btn-secondary text-nowrap o_add_wishlist_dyn"
t-att-data-product-template-id="product_id"
t-att-data-product-product-id="product_id"
data-action="o_wishlist"
title="Add to wishlist">
<i class="fa fa-clock-o me-2"/>
Save for later
</button>
<small t-if="!wish" class="text-muted m-2">
Add to your wishlist to be notified when it's back in stock.
</small>
<div t-if="wish" class="alert alert-info mb-0" role="alert">
<h6 class="alert-heading"><i class="fa fa-check-square-o me-2"/>Added to your wishlist</h6>
You'll be notified as soon as the product will be available again.
<div class="text-end mt-2">
<a class="alert-link" href="/shop/wishlist" title="My wishlist">
<small>Check my wishlist <i class="fa fa-angle-right"></i></small>
</a>
<div id="wsale_added_to_your_wishlist_alert" t-att-class="is_in_wishlist ? '' : 'd-none'">
<div class="alert alert-success">
<i class="fa fa-heart me-1"/>
Added to your wishlist
</div>
</div>
@@ -0,0 +1,28 @@
/** @odoo-module **/
import tour from 'web_tour.tour';
tour.register('stock_notification_wishlist', {
test: true,
url: '/shop/wishlist',
},
[
{
content: "Click on 'Be notified when back in stock'",
trigger: '#wishlist_stock_notification_message',
},
{
content: "Fill email form",
trigger: 'div[id="stock_notification_form"] input[name="email"]',
run: 'text test@test.test',
},
{
content: "Click on the button",
trigger: '#wishlist_stock_notification_form_submit_button',
},
{
content: "Success Message",
trigger: '#stock_notification_success_message',
},
],
);
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_wishlist_emails
from . import test_website_sale_stock_wishlist_stock_notification
@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import tagged
from odoo.tests.common import HttpCase
@tagged('post_install', '-at_install')
class TestStockNotificationWishlist(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
Warehouse = cls.env['stock.warehouse']
Product = cls.env['product.product']
Pricelist = cls.env['product.pricelist']
Wishlist = cls.env['product.wishlist']
Partner = cls.env['res.partner']
warehouse = 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
cls.product = Product.create({
'name': 'Macbook Pro',
'website_published': True,
'type': 'product',
'allow_out_of_stock_order': False,
})
cls.pricelist = Pricelist.create({
'name': 'Public Pricelist',
})
cls.currency = cls.env.ref("base.USD")
cls.partner = Partner.search([('id', '=', 3)])
Wishlist.create({
'partner_id': cls.partner.id,
'product_id': cls.product.id,
'website_id': current_website.id,
'pricelist_id': cls.pricelist.id
})
def test_stock_notification_wishlist(self):
self.start_tour("/", 'stock_notification_wishlist', login='admin')
partner_ids = self.env['res.partner']._mail_find_partner_from_emails(['test@test.test'])
partner = partner_ids[0]
ProductProduct = self.env['product.product']
product = ProductProduct.browse(self.product.id)
self.assertTrue(product._has_stock_notification(partner))
# No email should be sent
ProductProduct._send_availability_email()
emails = self.env['mail.mail'].search([('email_to', '=', partner.email_formatted)])
self.assertEqual(len(emails), 0)
# Replenish Product
quants = self.env['stock.quant'].with_context(inventory_mode=True).create({
'product_id': self.product.id,
'inventory_quantity': 10.0,
'location_id': self.warehouse.lot_stock_id.id,
})
quants.action_apply_inventory()
ProductProduct._send_availability_email()
emails = self.env['mail.mail'].search([('email_to', '=', partner.email_formatted)])
self.assertEqual(emails[0].subject, "The product 'Macbook Pro' is now available")
self.assertFalse(product._has_stock_notification(partner))
@@ -1,87 +0,0 @@
# -*- 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.list_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.list_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)
@@ -5,10 +5,43 @@
<small class="text-danger d-md-block" t-if="wish.product_id.sudo()._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_user,base.group_portal" t-att-data-notify="notify" t-if="notify or wish.product_id.sudo()._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>
<t t-if="not wish.product_id.allow_out_of_stock_order and wish.product_id._is_sold_out()">
<t t-set="has_stock_notification"
t-value="wish.product_id._has_stock_notification(wish.partner_id) or request and wish.product_id.id in request.session.get('product_with_stock_notification_enabled', set())"/>
<div id="stock_notification_div" t-if="not wish.product_id.allow_out_of_stock_order"
class="small d-inline-block">
<div class="btn btn-link" t-if="not has_stock_notification"
id="wishlist_stock_notification_message">
<small>
<i class="fa fa-envelope-o"/>
Get notified when back in stock
</small>
</div>
<div id="stock_notification_form" class="d-none">
<div class="input-group">
<input id="stock_notification_input"
class="form-control"
name="email"
type="text"
placeholder="youremail@gmail.com"
t-att-value="request.env.user.partner_id.email or request.session.get('stock_notification_email', '')"/>
<div id="wishlist_stock_notification_form_submit_button"
class="btn btn-secondary">
<i class="fa fa-paper-plane"/>
</div>
<div id="stock_notification_input_incorrect" class="btn d-none">
<i class="fa fa-times text-danger"/>
Invalid email
</div>
</div>
</div>
<div id="stock_notification_success_message" class="text-muted"
t-att-class="'text-muted' if has_stock_notification else 'd-none'">
<i class="fa fa-bell"/>
We'll notify you once the product is back in stock.
</div>
</div>
</t>
</xpath>
<xpath expr="//button[@id='add_to_cart_button']" position="replace">
<t t-set="is_sold_out"