[IMP] website: add the possibility to share coupon and promotion links

Coupons and promotion programs can now be shared by creating a link that can
directly be used by the customer.

Using the link, the system will try to apply the coupon/promotion to the
customer's order.
If this can't be done, the error will be shown to the user, for instance
"A minimum amount of 1000$ is required to get the free ipad".
That coupon/promotion will then be stored in session and the system will try
automatically reapply it when something is added to the cart.

task-2489749

closes odoo/odoo#69053

Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
Co-authored-by: Romain Derie <rde@odoo.com>
Co-authored-by: Jeremy Kersten <jke@odoo.com>
Co-authored-by: Tom De Caluwé <tdc@odoo.com>
This commit is contained in:
Tom De Caluwé
2021-08-09 14:16:54 +00:00
committed by Romain Derie
co-authored by Romain Derie Jeremy Kersten
parent 280e2f6e04
commit f57a13f3f6
22 changed files with 350 additions and 9 deletions
+1 -1
View File
@@ -52,7 +52,7 @@ class Coupon(models.Model):
def _get_default_template(self):
return False
def action_coupon_sent(self):
def action_coupon_send(self):
""" Open a window to compose an email, with the edi invoice template
message loaded by default
"""
+3 -1
View File
@@ -9,6 +9,8 @@
<field name="expiration_date"/>
<field name="program_id"/>
<field name="partner_id"/>
<button name="action_coupon_send" string="Send" type="object" icon="fa-paper-plane-o" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
<button name="action_coupon_cancel" string="Cancel" type="object" icon="fa-times" attrs="{'invisible': [('state', '!=', 'new')]}"/>
<field name="state"/>
</tree>
</field>
@@ -29,7 +31,7 @@
<field name="arch" type="xml">
<form string="Coupons" create="false" edit="false" delete="false">
<header>
<button name="action_coupon_sent" type="object" string="Send by Email" class="oe_highlight" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
<button name="action_coupon_send" type="object" string="Send by Email" class="oe_highlight" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
<button name="action_coupon_cancel" type="object" string="Cancel" class="oe_highlight" attrs="{'invisible': [('state', '!=', 'new')]}"/>
<field name="state" widget="statusbar" statusbar_visible="new,sent,used,expired" context="{'state': state}"/>
</header>
+3 -2
View File
@@ -43,6 +43,7 @@ class Coupon(models.Model):
return message
def _get_default_template(self):
if self.order_id:
default_template = super()._get_default_template()
if not default_template:
return self.env.ref('sale_coupon.mail_template_sale_coupon', False)
return super()._get_default_template()
return default_template
+1 -1
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo import fields, models, _
from babel.dates import format_datetime
class CouponProgram(models.Model):
+3 -1
View File
@@ -65,7 +65,8 @@ def werkzeugRaiseNotFound(*args, **kwargs):
def MockRequest(
env, *, routing=True, multilang=True,
context=None,
cookies=None, country_code=None, website=None, sale_order_id=None
cookies=None, country_code=None, website=None, sale_order_id=None,
website_sale_current_pl=None,
):
router = MagicMock()
match = router.return_value.bind.return_value.match
@@ -102,6 +103,7 @@ def MockRequest(
geoip={'country_code': country_code},
debug=False,
sale_order_id=sale_order_id,
website_sale_current_pl=website_sale_current_pl,
),
website=website
)
+1
View File
@@ -3,3 +3,4 @@
from . import controllers
from . import models
from . import wizard
+8 -2
View File
@@ -10,14 +10,20 @@ Coupon & promotion programs can be edited in the Catalog menu of the Website app
""",
'category': 'Website/Website',
'version': '1.0',
'depends': ['website_sale', 'sale_coupon'],
'depends': ['website_sale', 'website_links', 'sale_coupon'],
'data': [
'security/ir.model.access.csv',
'views/coupon_share_views.xml',
'views/website_sale_templates.xml',
'views/res_config_settings_views.xml',
'views/sale_coupon_coupon_views.xml',
'views/sale_coupon_program_views.xml',
],
'auto_install': True,
'auto_install': ['website_sale', 'sale_coupon'],
'assets': {
'web.assets_frontend': [
'website_sale_coupon/static/src/js/coupon_toaster_widget.js',
],
'web.assets_tests': [
'website_sale_coupon/static/tests/**/*',
],
+24 -1
View File
@@ -1,8 +1,11 @@
# -*- coding: utf-8 -*-
from odoo import http
from odoo import http, _
from odoo.addons.website_sale.controllers import main
from odoo.exceptions import UserError
from odoo.http import request
from werkzeug.urls import url_encode, url_parse
class WebsiteSale(main.WebsiteSale):
@@ -28,6 +31,26 @@ class WebsiteSale(main.WebsiteSale):
order.recompute_coupon_lines()
return super(WebsiteSale, self).cart(**post)
@http.route(['/coupon/<string:code>'], type='http', auth='public', website=True, sitemap=False)
def activate_coupon(self, code, r='/shop', **kw):
url_parts = url_parse(r)
url_query = url_parts.decode_query()
url_query.pop('coupon_error', False) # trust only Odoo error message
request.session['pending_coupon_code'] = code
order = request.website.sale_get_order()
if order:
result = order._try_pending_coupon()
order.recompute_coupon_lines()
if isinstance(result, UserError):
url_query['coupon_error'] = result
else:
url_query['notify_coupon'] = code
else:
url_query['coupon_error'] = _("The coupon will be automatically applied when you add something in your cart.")
redirect = url_parts.replace(query=url_encode(url_query))
return request.redirect(redirect.to_url())
# Override
# Add in the rendering the free_shipping_line
def _get_shop_payment_values(self, order, **kwargs):
@@ -12,3 +12,8 @@ class SaleCoupon(models.Model):
if order and self.program_id.website_id and self.program_id.website_id != order.website_id:
return {'error': 'This coupon is not valid on this website.'}
return super()._check_coupon_code(order_date, partner_id, **kwargs)
def action_coupon_share(self):
""" Open a window to copy the coupon link """
self.ensure_one()
return self.env['coupon.share'].create_share_action(coupon=self)
@@ -34,3 +34,8 @@ class CouponProgram(models.Model):
if self.website_id and self.website_id != order.website_id:
return {'error': 'This promo code is not valid on this website.'}
return super()._check_promo_code(order, coupon_code)
def action_program_share(self):
""" Open a window to copy the program link """
self.ensure_one()
return self.env['coupon.share'].create_share_action(program=self)
@@ -2,12 +2,33 @@
from datetime import timedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.http import request
class SaleOrder(models.Model):
_inherit = "sale.order"
def _try_pending_coupon(self):
if not request:
return
pending_coupon_code = request.session.get('pending_coupon_code')
if pending_coupon_code:
try:
self.env['sale.coupon.apply.code'].with_context(active_id=self.id).create({
'coupon_code': pending_coupon_code
}).process_coupon()
request.session.pop('pending_coupon_code')
except UserError as e:
return e
return True
def recompute_coupon_lines(self):
for order in self:
order._try_pending_coupon()
return super().recompute_coupon_lines()
def _compute_website_order_line(self):
""" This method will merge multiple discount lines generated by a same program
into a single one (temporary line with `new()`).
@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
from odoo import models
from odoo.http import request
class Website(models.Model):
_inherit = 'website'
def sale_reset(self):
request.session.pop('pending_coupon_code')
return super().sale_reset()
@@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_coupon_share,access_coupon_share,model_coupon_share,sales_team.group_sale_manager,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_coupon_share access_coupon_share model_coupon_share sales_team.group_sale_manager 1 1 1 0
@@ -0,0 +1,34 @@
/** @odoo-module **/
import publicWidget from 'web.public.widget';
import {registry} from "@web/core/registry";
const CouponToasterWidget = publicWidget.Widget.extend({
start() {
let options = {};
const $content = this.$('.coupon-message-content');
const $title = this.$('.coupon-message-title');
if ($content.length) {
Object.assign(options, {message: $content[0].innerHTML});
}
if ($title.length) {
Object.assign(options, {title: $title[0].innerHTML});
}
if (this.$el.hasClass('coupon-info-message')) {
this.displayNotification(Object.assign({type: 'success'}, options));
} else if (this.$el.hasClass('coupon-error-message')) {
this.displayNotification(Object.assign({type: 'danger'}, options));
}
return this._super(...arguments);
},
});
registry.category("public_root_widgets").add("CouponToasterWidget", {
Widget: CouponToasterWidget,
selector: '.coupon-message',
});
export default CouponToasterWidget;
@@ -1,2 +1,3 @@
from . import test_apply_pending_coupon
from . import test_sale_coupon_multiwebsite
from . import test_shop_sale_coupon
@@ -0,0 +1,61 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale_coupon.tests.test_program_numbers import TestSaleCouponProgramNumbers
from odoo.addons.website.tools import MockRequest
from odoo.addons.website_sale_coupon.controllers.main import WebsiteSale
from odoo.tests import tagged
@tagged('-at_install', 'post_install')
class TestSaleCouponApplyPending(TestSaleCouponProgramNumbers):
def setUp(self):
super().setUp()
self.WebsiteSaleController = WebsiteSale()
self.website = self.env['website'].browse(1)
self.global_program = self.p1
self.coupon_program = self.env['coupon.program'].create({
'name': 'One Free Product',
'program_type': 'coupon_program',
'rule_min_quantity': 2.0,
'reward_type': 'product',
'reward_product_id': self.largeCabinet.id,
'active': True,
})
self.coupon = self.env['coupon.coupon'].create({
'program_id': self.coupon_program.id,
})
def test_01_activate_coupon_with_existing_program(self):
order = self.empty_order
with MockRequest(self.env, website=self.website, sale_order_id=order.id, website_sale_current_pl=1) as request:
self.WebsiteSaleController.cart_update_json(self.largeCabinet.id, set_qty=3)
self.WebsiteSaleController.pricelist(self.global_program.promo_code)
self.assertEqual(order.amount_total, 864, "The order total should equal 864: 3*320 - 10% discount ")
self.WebsiteSaleController.activate_coupon(self.coupon.code)
promo_code = request.session.get('pending_coupon_code')
self.assertFalse(promo_code, "The promo code should be removed from the pending coupon dict")
self.assertEqual(order.amount_total, 576, "The order total should equal 576: 3*320 - 320 (free product) - 10%")
def test_02_pending_coupon_with_existing_program(self):
order = self.empty_order
with MockRequest(self.env, website=self.website, sale_order_id=order.id, website_sale_current_pl=1) as request:
self.WebsiteSaleController.cart_update_json(self.largeCabinet.id, set_qty=1)
self.WebsiteSaleController.pricelist(self.global_program.promo_code)
self.assertEqual(order.amount_total, 288, "The order total should equal 288: 320 - 10%")
self.WebsiteSaleController.activate_coupon(self.coupon.code)
promo_code = request.session.get('pending_coupon_code')
self.assertEqual(order.amount_total, 288, "The order total should still equal 288 as the coupon for free product can't be applied since it requires 2 min qty")
self.assertEqual(promo_code, self.coupon.code, "The promo code should be set in the pending coupon dict as it couldn't be applied, we save it for later reuse")
self.WebsiteSaleController.cart_update_json(self.largeCabinet.id, add_qty=2)
promo_code = request.session.get('pending_coupon_code')
self.assertFalse(promo_code, "The promo code should be removed from the pending coupon dict as it should have been applied")
self.assertEqual(order.amount_total, 576, "The order total should equal 576: 3*320 - 320 (free product) - 10%")
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="coupon_share_view_form" model="ir.ui.view">
<field name="name">coupon.share.form</field>
<field name="model">coupon.share</field>
<field name="arch" type="xml">
<form string="Share Coupon" edit="1" create="0">
<field name="program_website_id" invisible="1"/>
<group attrs="{'invisible': [('website_id', '=', False)]}">
<div colspan="2">
<p>
You can share this promotion with your customers.
It will be applied at checkout when the customer uses this link.
</p>
</div>
<field name="share_link" widget="CopyClipboardURL" nolabel="1"/>
</group>
<hr attrs="{'invisible': ['|', ('website_id', '=', False), ('id', '!=', False)]}"/>
<group attrs="{'invisible': [('id', '!=', False)]}">
<field name="website_id" groups="website.group_multi_website" widget="selection"
attrs="{'invisible': [('program_website_id', '!=', False)]}"/>
<field name="redirect"/>
</group>
<footer>
<button string="Done" class="btn-primary" special="cancel" data-hotkey="z"/>
<button string="Generate Short Link" class="btn-secondary" type="object" name="action_generate_short_link"
attrs="{'invisible': ['|', ('website_id', '=', False), ('id', '!=', False)]}" data-hotkey="g"/>
</footer>
</form>
</field>
</record>
</odoo>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="coupon_view_tree" model="ir.ui.view">
<field name="name">coupon.coupon.tree</field>
<field name="model">coupon.coupon</field>
<field name="inherit_id" ref="coupon.coupon_view_tree"/>
<field name="arch" type="xml">
<button name="action_coupon_send" position="after">
<button name="action_coupon_share" string="Share" type="object" icon="fa-share-alt" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
</button>
</field>
</record>
</odoo>
@@ -24,7 +24,9 @@
<field name="inherit_id" ref="coupon.coupon_program_view_tree"/>
<field name="arch" type="xml">
<field name="name" position="after">
<field name="promo_code" invisible="1"/>
<field name="website_id" groups="website.group_multi_website"/>
<button name="action_program_share" string="Share" type="object" icon="fa-share-alt" attrs="{'invisible': [('promo_code', '=', False)]}"/>
</field>
</field>
</record>
@@ -6,6 +6,22 @@
<attribute name="t-if">not line.is_reward_line</attribute>
</xpath>
</template>
<template id="layout" inherit_id="website.layout">
<body position="inside">
<t t-set="coupon_error" t-value="request.params.get('coupon_error')"/>
<t t-set="pending_coupon_code" t-value="request.session.get('pending_coupon_code')"/>
<t t-if="coupon_error and pending_coupon_code">
<div class="d-none coupon-message coupon-error-message">
<span class="coupon-message-title">Could not apply the promo code: <t t-out="pending_coupon_code"/></span>
<span class="coupon-message-content" t-out="coupon_error"/>
</div>
</t>
<t t-set="notify_coupon" t-value="request.params.get('notify_coupon')"/>
<div t-if="notify_coupon" class="d-none coupon-message coupon-info-message">
<span class="coupon-message-content">The following promo code was applied on your order: <t t-out="notify_coupon"/></span>
</div>
</body>
</template>
<template id="sale_coupon_result" inherit_id="website_sale.coupon_form">
<xpath expr="//form[@name='coupon_code']" position="after">
<t t-if="website_sale_order and website_sale_order.applied_coupon_ids">
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import sale_coupon_share
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.urls import url_encode
from odoo import fields, models, api, _
from odoo.exceptions import UserError, ValidationError
class SaleCouponShare(models.TransientModel):
_name = 'coupon.share'
_description = 'Create links that apply a coupon and redirect to a specific page'
def _get_default_website_id(self):
# program_website_id = self.program_website_id
program_website_id = self.env['coupon.program'].browse(self.env.context.get('default_program_id')).website_id
if program_website_id:
return program_website_id
else:
Website = self.env['website']
websites = Website.search([])
return len(websites) == 1 and websites or Website
website_id = fields.Many2one('website', required=True, default=_get_default_website_id)
coupon_id = fields.Many2one('coupon.coupon', domain="[('program_id', '=', program_id)]")
program_id = fields.Many2one('coupon.program', required=True, domain=[
'|', ('program_type', '=', 'coupon_program'),
('promo_code_usage', '=', 'code_needed'),
])
program_website_id = fields.Many2one('website', string='Program Website', related='program_id.website_id')
promo_code = fields.Char(compute='_compute_promo_code')
share_link = fields.Char(compute='_compute_share_link')
redirect = fields.Char(required=True, default='/shop')
@api.constrains('coupon_id', 'program_id')
def _check_program(self):
if self.filtered(lambda record: not record.coupon_id and record.program_id.program_type == 'coupon_program'):
raise ValidationError(_("A coupon is needed for coupon programs."))
@api.constrains('website_id', 'program_id')
def _check_website(self):
if self.filtered(lambda record: record.program_website_id and record.program_website_id != record.website_id):
raise ValidationError(_("The shared website should correspond to the website of the program."))
@api.depends('coupon_id.code', 'program_id.promo_code')
def _compute_promo_code(self):
for record in self:
record.promo_code = record.coupon_id.code or record.program_id.promo_code
@api.depends('website_id', 'redirect')
@api.depends_context('use_short_link')
def _compute_share_link(self):
for record in self:
target_url = '{base}/coupon/{code}?{query}'.format(
base=record.website_id.get_base_url(),
code=record.promo_code,
query=url_encode({'r': record.redirect}),
)
if record.env.context.get('use_short_link'):
tracker = self.env['link.tracker'].search([('url', '=', target_url)], limit=1)
if not tracker:
tracker = self.env['link.tracker'].create({'url': target_url})
record.share_link = tracker.short_url
else:
record.share_link = target_url
def action_generate_short_link(self):
return {
'name': _('Share Coupon'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'coupon.share',
'target': 'new',
'res_id': self.id,
'context': {
'use_short_link': True,
}
}
@api.model
def create_share_action(self, coupon=None, program=None):
if bool(program) == bool(coupon):
raise UserError(_("Provide either a coupon or a program."))
return {
'name': _('Share Coupon'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'coupon.share',
'target': 'new',
'flags': {'form': {'action_buttons': True}},
'context': {
'form_view_initial_mode': 'edit',
'default_program_id': program and program.id or coupon.program_id.id,
'default_coupon_id': coupon and coupon.id or None,
}
}