[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:
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-2
@@ -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>
|
||||
+12
-6
@@ -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;
|
||||
+41
-4
@@ -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>
|
||||
|
||||
|
||||
+28
@@ -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
|
||||
|
||||
+73
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user