diff --git a/addons/coupon/models/coupon.py b/addons/coupon/models/coupon.py index f2d7d704a92..54eadb0e842 100644 --- a/addons/coupon/models/coupon.py +++ b/addons/coupon/models/coupon.py @@ -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 """ diff --git a/addons/coupon/views/coupon_views.xml b/addons/coupon/views/coupon_views.xml index 67bdd3b3177..8b7cc3968aa 100644 --- a/addons/coupon/views/coupon_views.xml +++ b/addons/coupon/views/coupon_views.xml @@ -9,6 +9,8 @@ + + @@ -29,7 +31,7 @@ - + diff --git a/addons/sale_coupon/models/coupon.py b/addons/sale_coupon/models/coupon.py index d5a4ed179f8..a5812534515 100644 --- a/addons/sale_coupon/models/coupon.py +++ b/addons/sale_coupon/models/coupon.py @@ -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 diff --git a/addons/sale_coupon/models/coupon_program.py b/addons/sale_coupon/models/coupon_program.py index 7916cb6daae..2df040cc9ed 100644 --- a/addons/sale_coupon/models/coupon_program.py +++ b/addons/sale_coupon/models/coupon_program.py @@ -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): diff --git a/addons/website/tools.py b/addons/website/tools.py index 924df41d2e0..40fcd65c817 100644 --- a/addons/website/tools.py +++ b/addons/website/tools.py @@ -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 ) diff --git a/addons/website_sale_coupon/__init__.py b/addons/website_sale_coupon/__init__.py index 7d34c7c054a..88710707227 100644 --- a/addons/website_sale_coupon/__init__.py +++ b/addons/website_sale_coupon/__init__.py @@ -3,3 +3,4 @@ from . import controllers from . import models +from . import wizard diff --git a/addons/website_sale_coupon/__manifest__.py b/addons/website_sale_coupon/__manifest__.py index 6a35db5cb10..ba9f72a47ab 100644 --- a/addons/website_sale_coupon/__manifest__.py +++ b/addons/website_sale_coupon/__manifest__.py @@ -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/**/*', ], diff --git a/addons/website_sale_coupon/controllers/main.py b/addons/website_sale_coupon/controllers/main.py index a9246cc057d..9334927d424 100644 --- a/addons/website_sale_coupon/controllers/main.py +++ b/addons/website_sale_coupon/controllers/main.py @@ -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/'], 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): diff --git a/addons/website_sale_coupon/models/sale_coupon.py b/addons/website_sale_coupon/models/sale_coupon.py index 62b93fbeae3..3bd3973907e 100644 --- a/addons/website_sale_coupon/models/sale_coupon.py +++ b/addons/website_sale_coupon/models/sale_coupon.py @@ -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) diff --git a/addons/website_sale_coupon/models/sale_coupon_program.py b/addons/website_sale_coupon/models/sale_coupon_program.py index b55d2a0e566..bca4164cd41 100644 --- a/addons/website_sale_coupon/models/sale_coupon_program.py +++ b/addons/website_sale_coupon/models/sale_coupon_program.py @@ -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) diff --git a/addons/website_sale_coupon/models/sale_order.py b/addons/website_sale_coupon/models/sale_order.py index 08ff012371a..435de355033 100644 --- a/addons/website_sale_coupon/models/sale_order.py +++ b/addons/website_sale_coupon/models/sale_order.py @@ -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()`). diff --git a/addons/website_sale_coupon/models/website.py b/addons/website_sale_coupon/models/website.py new file mode 100644 index 00000000000..06be29c42d1 --- /dev/null +++ b/addons/website_sale_coupon/models/website.py @@ -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() diff --git a/addons/website_sale_coupon/security/ir.model.access.csv b/addons/website_sale_coupon/security/ir.model.access.csv new file mode 100644 index 00000000000..208fe7ebb18 --- /dev/null +++ b/addons/website_sale_coupon/security/ir.model.access.csv @@ -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 diff --git a/addons/website_sale_coupon/static/src/js/coupon_toaster_widget.js b/addons/website_sale_coupon/static/src/js/coupon_toaster_widget.js new file mode 100644 index 00000000000..fe966e94e6f --- /dev/null +++ b/addons/website_sale_coupon/static/src/js/coupon_toaster_widget.js @@ -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; diff --git a/addons/website_sale_coupon/tests/__init__.py b/addons/website_sale_coupon/tests/__init__.py index cc015ce47c9..35870353044 100644 --- a/addons/website_sale_coupon/tests/__init__.py +++ b/addons/website_sale_coupon/tests/__init__.py @@ -1,2 +1,3 @@ +from . import test_apply_pending_coupon from . import test_sale_coupon_multiwebsite from . import test_shop_sale_coupon diff --git a/addons/website_sale_coupon/tests/test_apply_pending_coupon.py b/addons/website_sale_coupon/tests/test_apply_pending_coupon.py new file mode 100644 index 00000000000..f8eecbf8487 --- /dev/null +++ b/addons/website_sale_coupon/tests/test_apply_pending_coupon.py @@ -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%") diff --git a/addons/website_sale_coupon/views/coupon_share_views.xml b/addons/website_sale_coupon/views/coupon_share_views.xml new file mode 100644 index 00000000000..29fdc252305 --- /dev/null +++ b/addons/website_sale_coupon/views/coupon_share_views.xml @@ -0,0 +1,32 @@ + + + + coupon.share.form + coupon.share + + + + + + + You can share this promotion with your customers. + It will be applied at checkout when the customer uses this link. + + + + + + + + + + + + + + diff --git a/addons/website_sale_coupon/views/sale_coupon_coupon_views.xml b/addons/website_sale_coupon/views/sale_coupon_coupon_views.xml new file mode 100644 index 00000000000..3544c8a80d2 --- /dev/null +++ b/addons/website_sale_coupon/views/sale_coupon_coupon_views.xml @@ -0,0 +1,13 @@ + + + + coupon.coupon.tree + coupon.coupon + + + + + + + + diff --git a/addons/website_sale_coupon/views/sale_coupon_program_views.xml b/addons/website_sale_coupon/views/sale_coupon_program_views.xml index 06ce21f546b..c122229149a 100644 --- a/addons/website_sale_coupon/views/sale_coupon_program_views.xml +++ b/addons/website_sale_coupon/views/sale_coupon_program_views.xml @@ -24,7 +24,9 @@ + + diff --git a/addons/website_sale_coupon/views/website_sale_templates.xml b/addons/website_sale_coupon/views/website_sale_templates.xml index 05debafb629..6bebb0834c2 100644 --- a/addons/website_sale_coupon/views/website_sale_templates.xml +++ b/addons/website_sale_coupon/views/website_sale_templates.xml @@ -6,6 +6,22 @@ not line.is_reward_line + + + + + + + Could not apply the promo code: + + + + + + The following promo code was applied on your order: + + + diff --git a/addons/website_sale_coupon/wizard/__init__.py b/addons/website_sale_coupon/wizard/__init__.py new file mode 100644 index 00000000000..4731e9ff622 --- /dev/null +++ b/addons/website_sale_coupon/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import sale_coupon_share diff --git a/addons/website_sale_coupon/wizard/sale_coupon_share.py b/addons/website_sale_coupon/wizard/sale_coupon_share.py new file mode 100644 index 00000000000..554d18de39c --- /dev/null +++ b/addons/website_sale_coupon/wizard/sale_coupon_share.py @@ -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, + } + }
+ You can share this promotion with your customers. + It will be applied at checkout when the customer uses this link. +