[IMP] loyalty*: misc loyalty improvements

This commit improves some messages and usability for loyalty and its
submodules:
 - Coupons should not be restricted to the partner assigned to them
   anymore
 - Merged "I have a promo code" and "Use a gift card" into a single
   toggle
 - Boolean fields have been added to each app to disable them where we
   don't want them.
 - Reduced the size of coupon codes
   This means that we increase the risk of collision which should be
   handled by the model as well now.
   Creating a coupon with an existing code will retry the creation (up
   to 10 times) until the coupon is indeed created.
 - Make it clearer in the coupon generation wizard that an email will be
   sent to the customer.
 - Reintroduce coupon/gift card details on sale order lines in eCommerce
   which was wrongfully removed.
 - Remove the "on product with taxes: " message on order lines when
   the amount is not split between taxes or if the tax message would be
   empty.
 - Make it possible for some error messages to be displayed as warning
   instead of errors
 - Split program types in two menuitems, one for promotions discount
   loyalty etc and one for gift cards and ewallet, the latter has
   simplified views.
 - Views overall have been reviewed to make it easier to understand how
   to programs work. New program types have also been added.

TaskId-2951413

Part-of: odoo/odoo#99023
Co-authored-by: William Braeckman <wbr@odoo.com>
This commit is contained in:
eres-odoo
2022-09-02 20:10:59 +02:00
committed by William Braeckman
co-authored by William Braeckman
parent feef532b41
commit 6927dcda9f
51 changed files with 1140 additions and 500 deletions
+4
View File
@@ -28,10 +28,14 @@
'loyalty/static/src/js/loyalty_control_panel_widget.js',
'loyalty/static/src/js/loyalty_list_view.js',
'loyalty/static/src/scss/loyalty.scss',
'loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js',
],
'web.assets_qweb': [
'loyalty/static/src/xml/loyalty_templates.xml',
],
'web.qunit_suite_tests': [
'loyalty/static/tests/**/*.js',
],
},
'installable': True,
'license': 'LGPL-3',
+14 -10
View File
@@ -3,8 +3,10 @@
<!-- 10 percent with code -->
<record id="10_percent_with_code" model="loyalty.program">
<field name="name">Code for 10% on orders</field>
<field name="program_type">promotion</field>
<field name="program_type">promo_code</field>
<field name="trigger">with_code</field>
<field name="portal_visible">False</field>
<field name="portal_point_name">Discount point(s)</field>
</record>
<record id="10_percent_with_code_rule" model="loyalty.rule">
@@ -20,13 +22,14 @@
<field name="discount_applicability">order</field>
<field name="program_id" ref="loyalty.10_percent_with_code"/>
</record>
<!-- 3 cabinet + 1 free -->
<record id="3_cabinets_plus_1_free" model="loyalty.program">
<field name="name">Buy 3 large cabinets, get one for free</field>
<field name="program_type">promotion</field>
<field name="program_type">buy_x_get_y</field>
<field name="applies_on">current</field>
<field name="trigger">auto</field>
<field name="portal_visible">False</field>
<field name="portal_point_name">Credit(s)</field>
</record>
<record id="3_cabinets_plus_1_free_rule" model="loyalty.rule">
@@ -35,7 +38,7 @@
<field name="reward_point_amount">1</field>
<field name="product_ids" eval="[(4, ref('product.product_product_6'))]"/>
<field name="program_id" ref="loyalty.3_cabinets_plus_1_free"/>
</record>
</record>
<record id="3_cabinets_plus_1_free_reward" model="loyalty.reward">
<field name="reward_type">product</field>
@@ -43,12 +46,13 @@
<field name="required_points">3</field>
<field name="program_id" ref="loyalty.3_cabinets_plus_1_free"/>
</record>
<!-- 10 percent coupons -->
<record id="10_percent_coupon" model="loyalty.program">
<field name="name">10% Discount</field>
<field name="applies_on">future</field>
<field name="name">10% Discount Coupons</field>
<field name="program_type">coupons</field>
<field name="applies_on">current</field>
<field name="trigger">with_code</field>
<field name="portal_point_name">Coupon points</field>
</record>
<record id="10_percent_coupon_rule" model="loyalty.rule">
@@ -68,13 +72,14 @@
<field name="mail_template_id" ref="loyalty.mail_template_loyalty_card"/>
<field name="program_id" ref="loyalty.10_percent_coupon"/>
</record>
<!-- Gift Cards -->
<record id="gift_card_program" model="loyalty.program">
<field name="name">Gift Cards</field>
<field name="program_type">gift_card</field>
<field name="applies_on">future</field>
<field name="trigger">auto</field>
<field name="portal_visible">True</field>
<field name="portal_point_name">$</field>
</record>
<record id="gift_card_program_rule" model="loyalty.rule">
@@ -93,5 +98,4 @@
<field name="required_points">1</field>
<field name="program_id" ref="loyalty.gift_card_program"/>
</record>
</odoo>
+8 -2
View File
@@ -17,18 +17,19 @@ class LoyaltyCard(models.Model):
"""
Barcode identifiable codes.
"""
return '044' + str(uuid4())[4:-8][3:]
return '044' + str(uuid4())[7:-18]
def name_get(self):
return [(card.id, f'{card.program_id.name}: {card.code}') for card in self]
program_id = fields.Many2one('loyalty.program', ondelete='restrict')
program_id = fields.Many2one('loyalty.program', ondelete='restrict', default=lambda self: self.env.context.get('active_id', None))
company_id = fields.Many2one(related='program_id.company_id', store=True)
currency_id = fields.Many2one(related='program_id.currency_id')
# Reserved for this partner if non-empty
partner_id = fields.Many2one('res.partner', index=True)
points = fields.Float(tracking=True)
point_name = fields.Char(related='program_id.portal_point_name', readonly=True)
points_display = fields.Char(compute='_compute_points_display')
code = fields.Char(default=lambda self: self._generate_code(), required=True, readonly=True, index=True)
expiration_date = fields.Date()
@@ -45,6 +46,11 @@ class LoyaltyCard(models.Model):
if self.env['loyalty.rule'].search_count([('mode', '=', 'with_code'), ('code', 'in', self.mapped('code'))]):
raise ValidationError(_('A trigger with the same code as one of your coupon already exists.'))
@api.depends('points', 'point_name')
def _compute_points_display(self):
for card in self:
card.points_display = "%.2f %s" % (card.points or 0, card.point_name or '')
# Meant to be overriden
def _compute_use_count(self):
self.use_count = 0
+182 -49
View File
@@ -24,21 +24,31 @@ class LoyaltyProgram(models.Model):
total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count")
rule_ids = fields.One2many('loyalty.rule', 'program_id', 'Triggers', copy=True,
rule_ids = fields.One2many('loyalty.rule', 'program_id', 'Conditional rules', copy=True,
compute='_compute_from_program_type', readonly=False, store=True)
reward_ids = fields.One2many('loyalty.reward', 'program_id', 'Rewards', copy=True,
compute='_compute_from_program_type', readonly=False, store=True)
communication_plan_ids = fields.One2many('loyalty.mail', 'program_id', copy=True,
compute='_compute_from_program_type', readonly=False, store=True)
# These fields are used for the simplified view of gift_card and ewallet
mail_template_id = fields.Many2one('mail.template', compute='_compute_mail_template_id', inverse='_inverse_mail_template_id', string="Email template", readonly=False)
trigger_product_ids = fields.Many2many(related='rule_ids.product_ids', readonly=False)
coupon_ids = fields.One2many('loyalty.card', 'program_id')
coupon_count = fields.Integer(compute='_compute_coupon_count')
coupon_count_display = fields.Char(compute='_compute_coupon_count_display', string="Items")
program_type = fields.Selection([
('coupons', 'Coupons'),
('gift_card', 'Gift Card'),
('loyalty', 'Loyalty Cards'),
('promotion', 'Promotions'),
('ewallet', 'eWallet')], default='promotion', required=True,
('ewallet', 'eWallet'),
('promo_code', 'Discount Code'),
('buy_x_get_y', 'Buy X Get Y'),
('next_order_coupons', 'Next Order Coupons')],
default='promotion', required=True,
)
date_to = fields.Date(string='Validity')
limit_usage = fields.Boolean(string='Limit Usage')
@@ -67,9 +77,16 @@ class LoyaltyProgram(models.Model):
Show in web portal, PoS customer ticket, eCommerce checkout, the number of points available and used by reward.
""")
portal_point_name = fields.Char(default='Points', translate=True,
compute='_compute_from_program_type', readonly=False, store=True)
compute='_compute_portal_point_name', readonly=False, store=True)
is_nominative = fields.Boolean(compute='_compute_is_nominative')
# Technical field used for a label
available_on = fields.Boolean("Available On", store=False,
help="""
Manage where your program should be available for use.
"""
)
_sql_constraints = [
('check_max_usage', 'CHECK (limit_usage = False OR max_usage > 0)',
'Max usage must be strictly positive if a limit is used.'),
@@ -77,12 +94,43 @@ class LoyaltyProgram(models.Model):
@api.constrains('reward_ids')
def _constrains_reward_ids(self):
if self.env.context.get('loyalty_skip_reward_check'):
return
if any(not program.reward_ids for program in self):
raise ValidationError(_('A program must have at least one reward.'))
def _compute_total_order_count(self):
self.total_order_count = 0
@api.depends('coupon_count', 'program_type')
def _compute_coupon_count_display(self):
program_items_name = self._program_items_name()
for program in self:
program.coupon_count_display = "%i %s" % (program.coupon_count or 0, program_items_name[program.program_type] or '')
@api.depends("communication_plan_ids.mail_template_id")
def _compute_mail_template_id(self):
for program in self:
program.mail_template_id = program.communication_plan_ids.mail_template_id[:1]
def _inverse_mail_template_id(self):
for program in self:
if program.program_type not in ("gift_card", "ewallet"):
continue
if not program.mail_template_id:
program.communication_plan_ids = [(5, 0, 0)]
elif not program.communication_plan_ids:
program.communication_plan_ids = self.env['loyalty.mail'].create({
'program_id': program.id,
'trigger': 'create',
'mail_template_id': program.mail_template_id.id,
})
else:
program.communication_plan_ids.write({
'trigger': 'create',
'mail_template_id': program.mail_template_id.id,
})
@api.depends('company_id')
def _compute_currency_id(self):
for program in self:
@@ -101,17 +149,30 @@ class LoyaltyProgram(models.Model):
program.is_nominative = program.applies_on == 'both' or\
(program.program_type == 'ewallet' and program.applies_on == 'future')
@api.model
def _program_items_name(self):
return {
'coupons': _('Coupons'),
'promotion': _('Promos'),
'gift_card': _('Gift Cards'),
'loyalty': _('Loyalty Cards'),
'ewallet': _('eWallets'),
'promo_code': _('Discounts'),
'buy_x_get_y': _('Promos'),
'next_order_coupons': _('Coupons'),
}
@api.model
def _program_type_default_values(self):
# All values to change when program_type changes
# NOTE: any field used in `rule_ids`, `reward_ids` and `communication_plan_ids` MUST be present in the kanban view for it to work properly.
first_sale_product = self.env['product.product'].search([('sale_ok', '=', True)], limit=1)
return {
'coupons': {
'applies_on': 'current',
'trigger': 'with_code',
'portal_visible': False,
'portal_point_name': _('Points'),
# Coupons don't use rules by default
'portal_point_name': _('Coupon point(s)'),
'rule_ids': [(5, 0, 0)],
'reward_ids': [(5, 0, 0), (0, 0, {
'required_points': 1,
@@ -126,7 +187,7 @@ class LoyaltyProgram(models.Model):
'applies_on': 'current',
'trigger': 'auto',
'portal_visible': False,
'portal_point_name': _('Points'),
'portal_point_name': _('Promo point(s)'),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_amount': 1,
'reward_point_mode': 'order',
@@ -155,7 +216,7 @@ class LoyaltyProgram(models.Model):
'discount': 1,
'discount_applicability': 'order',
'required_points': 1,
'description': _('Pay With Gift Card'),
'description': _('Gift Card'),
})],
'communication_plan_ids': [(5, 0, 0), (0, 0, {
'trigger': 'create',
@@ -166,7 +227,7 @@ class LoyaltyProgram(models.Model):
'applies_on': 'both',
'trigger': 'auto',
'portal_visible': True,
'portal_point_name': _('Loyalty Points'),
'portal_point_name': _('Loyalty point(s)'),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_mode': 'money',
})],
@@ -177,8 +238,8 @@ class LoyaltyProgram(models.Model):
'communication_plan_ids': [(5, 0, 0)],
},
'ewallet': {
'applies_on': 'future',
'trigger': 'auto',
'applies_on': 'future',
'portal_visible': True,
'portal_point_name': self.env.company.currency_id.symbol,
'rule_ids': [(5, 0, 0), (0, 0, {
@@ -192,9 +253,62 @@ class LoyaltyProgram(models.Model):
'discount': 1,
'discount_applicability': 'order',
'required_points': 1,
'description': _('eWallet'),
})],
'communication_plan_ids': [(5, 0, 0)],
},
'promo_code': {
'applies_on': 'current',
'trigger': 'with_code',
'portal_visible': False,
'portal_point_name': _('Discount point(s)'),
'rule_ids': [(5, 0, 0), (0, 0, {
'mode': 'with_code',
'code': 'PROMO_CODE_' + str(uuid4())[:4], # We should try not to trigger any unicity constraint
})],
'reward_ids': [(5, 0, 0), (0, 0, {
'discount_applicability': 'specific',
'discount_product_ids': first_sale_product,
'discount_mode': 'percent',
'discount': 10,
})],
'communication_plan_ids': [(5, 0, 0)],
},
'buy_x_get_y': {
'applies_on': 'current',
'trigger': 'auto',
'portal_visible': False,
'portal_point_name': _('Credit(s)'),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_mode': 'unit',
'product_ids': first_sale_product,
})],
'reward_ids': [(5, 0, 0), (0, 0, {
'reward_type': 'product',
'reward_product_id': first_sale_product.id,
'required_points': 2,
})],
'communication_plan_ids': [(5, 0, 0)],
},
'next_order_coupons': {
'applies_on': 'future',
'trigger': 'auto',
'portal_visible': True,
'portal_point_name': _('Coupon point(s)'),
'rule_ids': [(5, 0, 0), (0, 0, {
'minimum_amount': 100,
})],
'reward_ids': [(5, 0, 0), (0, 0, {
'reward_type': 'discount',
'discount_mode': 'percent',
'discount': 15,
'discount_applicability': 'order',
})],
'communication_plan_ids': [(5, 0, 0), (0, 0, {
'trigger': 'create',
'mail_template_id': (self.env.ref('loyalty.mail_template_gift_card', raise_if_not_found=False) or self.env['mail.template']).id,
})],
},
}
@api.depends('program_type')
@@ -207,6 +321,13 @@ class LoyaltyProgram(models.Model):
if program_type in program_type_defaults:
programs.write(program_type_defaults[program_type])
@api.depends("currency_id", "program_type")
def _compute_portal_point_name(self):
for program in self:
if program.program_type not in ('ewallet', 'gift_card'):
continue
program.portal_point_name = program.currency_id.symbol or ''
def _get_valid_products(self, products):
'''
Returns a dict containing the products that match per rule of the program
@@ -220,6 +341,13 @@ class LoyaltyProgram(models.Model):
rule_products[rule] = products
return rule_products
def action_open_loyalty_cards(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id("loyalty.loyalty_card_action")
action['name'] = self._program_items_name()[self.program_type]
action['display_name'] = action['name']
return action
@api.ondelete(at_uninstall=False)
def _unlink_except_active(self):
if any(program.active for program in self):
@@ -234,22 +362,39 @@ class LoyaltyProgram(models.Model):
program.communication_plan_ids.active = program.active
program.reward_ids.discount_line_product_id.active = program.active
def write(self, vals):
# There is an issue when we change the program type, since we clear the rewards and create new ones.
# The orm actually does it in this order upon writing, triggering the constraint before creating the new rewards.
# However we can check that the result of reward_ids would actually be empty or not, and if not, skip the constraint.
if 'reward_ids' in vals and self._fields['reward_ids'].convert_to_cache(vals['reward_ids'], self):
self = self.with_context(loyalty_skip_reward_check=True)
return super().write(vals)
@api.model
def get_program_templates(self):
'''
Returns the templates to be used for promotional programs.
'''
ctx_menu_type = self.env.context.get('menu_type')
if ctx_menu_type == 'gift_ewallet':
return {
'gift_card': {
'title': _('Gift Card'),
'description': _('Sell Gift Cards, that can be used to purchase products'),
'icon': 'gift_card',
},
'ewallet': {
'title': _('eWallet'),
'description': _('Fill in your eWallet, and use it to pay future orders'),
'icon': 'ewallet',
},
}
return {
'promo_code': {
'title': _('Promo Code'),
'description': _('Get a code to receive 10% discount on specific products'),
'icon': 'promo_code',
},
'gift_card': {
'title': _('Gift Card'),
'description': _('Sell Gift Cards, that can be used to purchase products'),
'icon': 'gift_card',
},
'loyalty': {
'title': _('Loyalty Cards'),
'description': _('Win points with each purchases, and use points to get gifts'),
@@ -270,15 +415,15 @@ class LoyaltyProgram(models.Model):
'description': _('Send unique coupons that give access to rewards'),
'icon': 'coupons',
},
'buy_two_get_one': {
'buy_x_get_y': {
'title': _('2+1 Free'),
'description': _('Buy 2 products and get a third one for free'),
'icon': '2_plus_1',
},
'ewallet': {
'title': _('eWallet'),
'description': _('Fill in your eWallet, and use it to pay future orders'),
'icon': 'ewallet',
'next_order_coupons': {
'title': _('Next Order Coupons'),
'description': _('Send unique, single-use coupon code for the next purchase'),
'icon': 'coupons',
},
}
@@ -293,7 +438,11 @@ class LoyaltyProgram(models.Model):
if template_id not in template_values:
return False
program = self.create(template_values[template_id])
action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_action')
action = {}
if self.env.context.get('menu_type') == 'gift_ewallet':
action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_discount_loyalty_action')
else:
action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_program_gift_ewallet_action')
action['views'] = [[False, 'form']]
action['view_mode'] = 'form'
action['res_id'] = program.id
@@ -334,20 +483,19 @@ class LoyaltyProgram(models.Model):
**program_type_defaults['promotion'],
},
'promo_code': {
'name': _('Promo Code'),
'program_type': 'promotion',
'applies_on': 'current',
'trigger': 'with_code',
'rule_ids': [(0, 0, {
'mode': 'with_code',
'code': '10PERCENT_' + str(uuid4())[:4], # Require a random code in case a user creates multiple program using this template
})],
'reward_ids': [(0, 0, {
'discount_applicability': 'specific',
'discount_product_ids': product,
'discount_mode': 'percent',
'discount': 10,
})]
'name': _('Discount code'),
'program_type': 'promo_code',
**program_type_defaults['promo_code'],
},
'buy_x_get_y': {
'name': _('2+1 Free'),
'program_type': 'buy_x_get_y',
**program_type_defaults['buy_x_get_y'],
},
'next_order_coupons': {
'name': _('Next Order Coupons'),
'program_type': 'next_order_coupons',
**program_type_defaults['next_order_coupons'],
},
'fidelity': {
'name': _('Fidelity Cards'),
@@ -366,19 +514,4 @@ class LoyaltyProgram(models.Model):
'discount': 10,
})]
},
'buy_two_get_one': {
'name': _('2+1 Free'),
'program_type': 'promotion',
'applies_on': 'current',
'trigger': 'auto',
'rule_ids': [(0, 0, {
'reward_point_mode': 'unit',
'product_ids': product,
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_id': product and product.id or False,
'required_points': 2,
})]
},
}
+15 -2
View File
@@ -27,6 +27,7 @@ class LoyaltyReward(models.Model):
active = fields.Boolean(default=True)
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
program_type = fields.Selection(related="program_id.program_type")
# Stored for security rules
company_id = fields.Many2one(related='program_id.company_id', store=True)
currency_id = fields.Many2one(related='program_id.currency_id')
@@ -38,6 +39,7 @@ class LoyaltyReward(models.Model):
('discount', 'Discount')],
default='discount', required=True,
)
user_has_debug = fields.Boolean(compute='_compute_user_has_debug')
# Discount rewards
discount = fields.Float('Discount', default=10)
@@ -116,9 +118,15 @@ class LoyaltyReward(models.Model):
def _compute_description(self):
for reward in self:
reward_string = ""
if reward.reward_type == 'product':
if reward.program_type == 'gift_card':
reward_string = _("Gift Card")
elif reward.program_type == 'ewallet':
reward_string = _("eWallet")
elif reward.reward_type == 'product':
products = reward.reward_product_ids
if len(products) == 1:
if len(products) == 0:
reward_string = _('Free Product')
elif len(products) == 1:
reward_string = _('Free Product - %s', reward.reward_product_id.name)
else:
reward_string = _('Free Product - [%s]', ', '.join(products.mapped('name')))
@@ -157,6 +165,11 @@ class LoyaltyReward(models.Model):
reward.discount_applicability == 'order' and\
reward.discount_mode == 'percent'
@api.depends_context('uid')
@api.depends("reward_type")
def _compute_user_has_debug(self):
self.user_has_debug = self.user_has_groups('base.group_no_one')
def _create_missing_discount_line_products(self):
# Make sure we create the product that will be used for our discounts
rewards = self.filtered(lambda r: not r.discount_line_product_id)
+17 -2
View File
@@ -23,11 +23,13 @@ class LoyaltyRule(models.Model):
active = fields.Boolean(default=True)
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
program_type = fields.Selection(related="program_id.program_type")
# Stored for security rules
company_id = fields.Many2one(related='program_id.company_id', store=True)
currency_id = fields.Many2one(related='program_id.currency_id')
# Only for dev mode
user_has_debug = fields.Boolean(compute='_compute_user_has_debug')
product_domain = fields.Char(default="[]")
product_ids = fields.Many2many('product.product', string='Products')
@@ -51,8 +53,8 @@ class LoyaltyRule(models.Model):
mode = fields.Selection([
('auto', 'Automatic'),
('with_code', 'With a promotion code'),
], string="Application", default="auto")
code = fields.Char(string='Promotion Code', compute='_compute_code', store=True, readonly=False)
], string="Application", compute='_compute_mode', store=True, readonly=False)
code = fields.Char(string='Discount code', compute='_compute_code', store=True, readonly=False)
_sql_constraints = [
('reward_point_amount_positive', 'CHECK (reward_point_amount > 0)', 'Rule points reward must be strictly positive.'),
@@ -84,6 +86,19 @@ class LoyaltyRule(models.Model):
if rule.mode == 'auto':
rule.code = False
@api.depends('code')
def _compute_mode(self):
for rule in self:
if rule.code:
rule.mode = 'with_code'
else:
rule.mode = 'auto'
@api.depends_context('uid')
@api.depends("mode")
def _compute_user_has_debug(self):
self.user_has_debug = self.user_has_groups('base.group_no_one')
def _get_valid_product_domain(self):
self.ensure_one()
domain = []
@@ -0,0 +1,44 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { SelectionField } from "@web/views/fields/selection/selection_field";
/**
* The purpose of this field is to be able to define some values which should not be
* displayed on our selection field, this way we can have multiple views for the same model
* that uses different possible sets of values on the same selection field.
*/
export class FilterableSelectionField extends SelectionField {
/**
* @override
*/
get options() {
let options = super.options;
if (this.props.whitelisted_values) {
options = options.filter((option) => {
return option[0] === this.props.value || this.props.whitelisted_values.includes(option[0])
});
} else if (this.props.blacklisted_values) {
options = options.filter((option) => {
return option[0] === this.props.value || !this.props.blacklisted_values.includes(option[0]);
});
}
return options;
}
};
FilterableSelectionField.props = {
...SelectionField.props,
whitelisted_values: { type: Array, optional: true },
blacklisted_values: { type: Array, optional: true },
};
FilterableSelectionField.extractProps = ({ attrs }) => {
return {
...SelectionField.extractProps({ attrs }),
whitelisted_values: attrs.options.whitelisted_values,
blacklisted_values: attrs.options.blacklisted_values,
};
};
registry.category("fields").add("filterable_selection", FilterableSelectionField);
@@ -16,6 +16,10 @@ export class LoyaltyActionHelper extends Component {
this.loyaltyTemplateData = await this.orm.call(
"loyalty.program",
"get_program_templates",
[],
{
context: this.env.model.root.context,
},
);
});
}
@@ -36,7 +40,10 @@ LoyaltyActionHelper.template = "loyalty.LoyaltyActionHelper";
export class LoyaltyListRenderer extends ListRenderer {};
LoyaltyListRenderer.template = "loyalty.LoyaltyListRenderer";
LoyaltyListRenderer.components.LoyaltyActionHelper = LoyaltyActionHelper;
LoyaltyListRenderer.components = {
...LoyaltyListRenderer.components,
LoyaltyActionHelper,
};
export const LoyaltyListView = {
...listView,
+20 -5
View File
@@ -4,13 +4,22 @@
.o_kanban_renderer {
padding: 0px !important;
.o_kanban_record {
margin-right: 0px;
margin-left: 0px;
.o_kanban_tags .o_tag span {
// Remove the small ball before the tags
width: auto !important;
height: auto !important;
background-color: none !important;
}
}
}
.o_kanban_record .o_kanban_tags .o_tag span {
// Remove the small ball before the tags
width: auto !important;
height: auto !important;
background-color: none !important;
.o_loyalty_kanban_card_right {
text-align: center;
}
}
@@ -54,3 +63,9 @@
width: 100%;
}
}
.loyalty-program-list-view {
.o_view_nocontent {
top: 10% !important;
}
}
@@ -14,7 +14,7 @@
<t t-out="props.noContentHelp"/>
</div>
<div class="container">
<div class="row loyalty-templates-container">
<div class="row justify-content-center loyalty-templates-container">
<t t-foreach="Object.entries(loyaltyTemplateData)" t-as="data" t-key="data[0]">
<t t-set="loyalty_el_icon" t-value="data[1].icon"/>
<t t-set="loyalty_el_title" t-value="data[1].title"/>
@@ -0,0 +1,126 @@
/** @odoo-module **/
import { click, editSelect, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
// Note: the containsN always check for one more as there will be an invisible empty option every time.
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
program: {
fields: {
program_type: {
type: "selection",
selection: [
["coupon", "Coupons"],
["promotion", "Promotion"],
["gift_card", "gift_card"],
],
required: true,
}
},
records: [
{ id: 1, program_type: "coupon" },
{ id: 2, program_type: "gift_card" },
],
},
}
}
setupViewRegistries();
});
QUnit.module("Loyalty > FilterableSelectionField");
QUnit.test("FilterableSelectionField test whitelist", async (assert) => {
await makeView({
type: "form",
resModel: "program",
resId: 1,
serverData,
arch: `
<form>
<field name="program_type" widget="filterable_selection" options="{'whitelisted_values': ['coupon', 'promotion']}"/>
</form>`,
});
await click(target, ".o_form_button_edit");
assert.containsN(target, "select option", 3);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
);
});
QUnit.test("FilterableSelectionField test blacklist", async (assert) => {
await makeView({
type: "form",
resModel: "program",
resId: 1,
serverData,
arch: `
<form>
<field name="program_type" widget="filterable_selection" options="{'blacklisted_values': ['gift_card']}"/>
</form>`,
});
await click(target, ".o_form_button_edit");
assert.containsN(target, "select option", 3);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
);
});
QUnit.test("FilterableSelectionField test with invalid value", async (assert) => {
// The field should still display the current value in the list
await makeView({
type: "form",
resModel: "program",
resId: 2,
serverData,
arch: `
<form>
<field name="program_type" widget="filterable_selection" options="{'blacklisted_values': ['gift_card']}"/>
</form>`,
});
await click(target, ".o_form_button_edit");
assert.containsN(target, "select option", 4);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"gift_card\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
);
await editSelect(target, ".o_field_widget[name='program_type'] select", '"coupon"');
assert.containsN(target, "select option", 3);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
);
});
});
+23 -5
View File
@@ -7,10 +7,16 @@
<form>
<sheet>
<group>
<field name="code"/>
<field name="expiration_date"/>
<field name="partner_id"/>
<field name="points"/>
<group>
<field name="code"/>
<field name="expiration_date"/>
<field name="partner_id"/>
<label string="Balance" for="points"/>
<span class="d-inline-block">
<field name="points" class="w-auto oe_inline me-1"/>
<field name="point_name" no_label="1" class="d-inline"/>
</span>
</group>
</group>
</sheet>
<div class="oe_chatter">
@@ -26,7 +32,8 @@
<field name="arch" type="xml">
<tree string="Coupons" create="false" edit="false" delete="false">
<field name="code"/>
<field name="points" string="Balance"/>
<field name="create_date" optional="hide"/>
<field name="points_display" string="Balance"/>
<field name="expiration_date"/>
<field name="program_id"/>
<field name="partner_id"/>
@@ -35,6 +42,17 @@
</field>
</record>
<record id="loyalty_card_view_search" model="ir.ui.view">
<field name="name">loyalty.card.view.search</field>
<field name="model">loyalty.card</field>
<field name="arch" type="xml">
<search>
<filter name="active" string="Active" domain="['&amp;', ('points', '>', 0), '|', ('expiration_date', '>=', context_today().strftime('%Y-%m-%d 00:00:00')), ('expiration_date', '=', False)]"/>
<filter name="inactive" string="Inactive" domain="['|', ('points', '&lt;=', 0), ('expiration_date', '&lt;', context_today().strftime('%Y-%m-%d 23:59:59'))]"/>
</search>
</field>
</record>
<record id="loyalty_card_action" model="ir.actions.act_window">
<field name="name">Coupons</field>
<field name="res_model">loyalty.card</field>
+121 -39
View File
@@ -1,10 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- DISCOUNT & LOYALTY -->
<record id="loyalty_program_view_form" model="ir.ui.view">
<field name="name">loyalty.program.view.form</field>
<field name="model">loyalty.program</field>
<field name="arch" type="xml">
<form string="Coupons &amp; Loyalty">
<form string="Discount &amp; Loyalty">
<header>
<button name="%(loyalty_generate_wizard_action)d" string="Generate Coupons" class="btn-primary" type="action"
attrs="{'invisible': [('program_type', '!=', 'coupons')]}"/>
@@ -14,17 +16,19 @@
attrs="{'invisible': [('program_type', '!=', 'ewallet')]}" context="{'default_mode': 'selected'}"/>
</header>
<sheet>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" type="action" name="%(loyalty_card_action)d" icon="fa-tags">
<button class="oe_stat_button" type="object" name="action_open_loyalty_cards" icon="fa-tags">
<div class="o_form_field o_stat_info">
<span class="o_stat_value">
<field name="coupon_count"/>
</span>
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'coupons')]}">Coupons</span>
<span class="o_stat_text" attrs="{'invisible': [('program_type', 'not in', ('coupons', 'next_order_coupons'))]}">Coupons</span>
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'loyalty')]}">Loyalty Cards</span>
<span class="o_stat_text" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'buy_x_get_y'))]}">Promos</span>
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'promo_code')]}">Discount</span>
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}">Gift Cards</span>
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}">eWallets</span>
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'loyalty')]}">Loyalty Cards</span>
<span class="o_stat_text" attrs="{'invisible': [('program_type', '!=', 'promotion')]}">Promos</span>
</div>
</button>
</div>
@@ -33,69 +37,88 @@
<div class="oe_title">
<label for="name" string="Program Name"/>
<h1>
<field name="name" class="text-break" placeholder="e.g. 10% discount on laptops"/>
<field name="name" placeholder="e.g. 10% discount on laptops"/>
</h1>
</div>
<group>
<group>
<label for="program_type"/>
<div>
<field name="program_type" widget="selection"/>
<field name="program_type" widget="filterable_selection" attrs="{'readonly': [('coupon_count', '!=', 0)]}" options="{'blacklisted_values': ['gift_card', 'ewallet']}"/>
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'coupons')]}" colspan="2">
Generate &amp; share coupon code manually.
<br/>
Rewards are provided when the user provides a coupon code in the eCommerce, Point of Sale or regular orders.
<br/>
Add triggers for constraints on coupon usage.
Generate &amp; share coupon codes manually. It can be used in eCommerce, Point of Sale or regular orders to claim the Reward. You can define constraints on its usage through conditional rule.
<div groups="base.group_no_one">
When generating coupon, you can define a specific points value that can be exchanged for rewards.
</div>
</p>
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'loyalty')]}" colspan="2">
Once a sale order is validated, the customers get Loyalty Points that can be used in the current order, or accumulated in future orders.
When customers make an order, they accumulate points they can exchange for rewards on the current order or on a future one.
</p>
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'promotion')]}" colspan="2">
Set up conditional rules on the order that will give access to rewards for customers
<div groups="base.group_no_one">
Each rule can grant points to the customer he will be able to exchange against rewards
</div>
</p>
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'promo_code')]}" colspan="2">
Define Discount codes on conditional rules then share it with your customers for rewards.
</p>
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'buy_x_get_y')]}" colspan="2">
Grant 1 credit for each item bought then reward the customer with Y items in exchange of X credits.
</p>
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'next_order_coupons')]}" colspan="2">
Drive repeat purchases by sending a unique, single-use coupon code for the next purchase when a customer buys something in your store.
</p>
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}" colspan="2">
Gift Cards are created and sent by email when the customer orders a product defined in your triggers.
Gift Cards are created manually or automatically sent by email when the customer orders a gift card product.
<br/>
Then, Gift Cards can be used to pay orders.
</p>
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'promotion')]}" colspan="2">
Set up triggers based on a promotional code and/or products purchased, that give access to reward on the current order.
</p>
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}" colspan="2">
Personal eWallet are created when the customer orders a product defined in the triggers.
eWallets are created manually or automatically when the customer orders a eWallet product.
<br/>
Then, eWallets are proposed during the checkout, to pay orders.
</p>
</div>
<field name="company_id" groups="base.group_multi_company"/>
<field name="trigger_product_ids" string="Gift Card Products" widget="many2many_tags" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}"/>
<field name="trigger_product_ids" string="eWallet Products" widget="many2many_tags" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}"/>
<field name="mail_template_id" widget="many2one_tags" attrs="{'invisible': [('program_type', 'not in', ('gift_card', 'ewallet'))]}"/>
<field name="currency_id"/>
<field name="currency_symbol" invisible="1"/>
<field name="portal_point_name" attrs="{'invisible': [('program_type', 'in', ('loyalty', 'gift_card', 'ewallet'))]}" string="Points Unit" groups="base.group_no_one"/>
<field name="portal_point_name" attrs="{'invisible': ['|', ('program_type', 'in', ('gift_card', 'ewallet')), ('program_type', '!=', 'loyalty')]}" string="Points Unit"/>
<field name="portal_visible" invisible="1"/>
<field name="portal_visible" groups="base.group_no_one" string="Show points Unit" attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}"/>
<field name="trigger" invisible="1"/>
<field name="trigger" string="Program trigger" groups="base.group_no_one" widget="selection" readonly="1" force_save="1"/>
<field name="applies_on" invisible="1"/>
<field name="applies_on" string="Use points on" groups="base.group_no_one" widget="radio" readonly="1" force_save="1"/>
</group>
<group>
<field name="date_to"/>
<label for="limit_usage"/>
<span>
<field name="date_to" attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}"/>
<label for="limit_usage" attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}"/>
<span attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}">
<field name="limit_usage" class="oe_inline"/>
<span attrs="{'invisible': [('limit_usage', '=', False)]}"> to <field name="max_usage" class="oe_inline"/> usages</span>
</span>
<label for="portal_visible" string="Show points"/>
<span>
<field name="portal_visible" class="oe_inline"/>
<span attrs="{'invisible': [('portal_visible', '=', False)]}"> as <field name="portal_point_name" class="oe_inline"/></span>
</span>
<field name="applies_on" widget="radio" groups="base.group_no_one"/>
<field name="trigger" widget="radio" groups="base.group_no_one" attrs="{'invisible': [('applies_on', '!=', 'current')]}"/>
<field name="company_id" invisible="1"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="available_on" invisible="1"/>
<label class="o_form_label" for="available_on" string="Available On" invisible="1"/>
<div id="o_loyalty_program_availabilities" invisible="1"/>
<field name="portal_point_name" attrs="{'invisible': [('program_type', 'not in', ('gift_card', 'ewallet'))]}" string="Displayed as" groups="base.group_no_one"/>
</group>
</group>
<notebook>
<page string="Triggers &amp; Rewards" name="rules_rewards">
<notebook attrs="{'invisible': [('program_type', 'in', ('gift_card', 'ewallet'))]}">
<page string="Rules &amp; Rewards" name="rules_rewards">
<group>
<group>
<field name="rule_ids" colspan="2" mode="kanban" nolabel="1" add-label="Add a trigger"
<field name="rule_ids" colspan="2" mode="kanban" nolabel="1" add-label="Add a rule"
class="o_loyalty_kanban_inline" widget="loyalty_one2many" context="{'currency_symbol': currency_symbol}"/>
</group>
<group>
<field name="reward_ids" colspan="2" mode="kanban" nolabel="1" add-label="Add a reward"
class="o_loyalty_kanban_inline" widget="loyalty_one2many" context="{'currency_symbol': currency_symbol}"/>
class="o_loyalty_kanban_inline" widget="loyalty_one2many" context="{'currency_symbol': currency_symbol}"/>
</group>
</group>
</page>
@@ -112,11 +135,11 @@
<field name="name">loyalty.program.view.tree</field>
<field name="model">loyalty.program</field>
<field name="arch" type="xml">
<tree js_class="loyalty_program_list_view">
<tree js_class="loyalty_program_list_view" class="loyalty-program-list-view">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="program_type"/>
<field name="active"/>
<field name="coupon_count_display" string="Items"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
@@ -134,15 +157,74 @@
</field>
</record>
<record id="loyalty_program_action" model="ir.actions.act_window">
<field name="name">Promotions, Gift Card, Loyalty</field>
<record id="loyalty_program_discount_loyalty_action" model="ir.actions.act_window">
<field name="name">Discount &amp; Loyalty</field>
<field name="res_model">loyalty.program</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('program_type', 'not in', ('gift_card', 'ewallet'))]</field>
<field name="help" type="html">
<div class="o_loyalty_not_found container">
<h1>No loyalty program found.</h1>
<p class="lead fw-light">Create a new one from scratch, or use one of the templates below.</p>
<p class="lead">Create a new one from scratch, or use one of the templates below.</p>
</div>
</field>
</record>
<record id="action_loyalty_program_tree_discount_loyalty" model="ir.actions.act_window.view">
<field name="view_mode">tree</field>
<field name="sequence">1</field>
<field name="view_id" ref="loyalty_program_view_tree"/>
<field name="act_window_id" ref="loyalty_program_discount_loyalty_action"/>
</record>
<record id="action_loyalty_program_form_discount_loyalty" model="ir.actions.act_window.view">
<field name="view_mode">form</field>
<field name="sequence">2</field>
<field name="view_id" ref="loyalty_program_view_form"/>
<field name="act_window_id" ref="loyalty_program_discount_loyalty_action"/>
</record>
<!-- GIFT & EWALLET -->
<record id="loyalty_program_gift_ewallet_view_form" model="ir.ui.view">
<field name="name">loyalty.program.gift.ewallet.view.form</field>
<field name="model">loyalty.program</field>
<field name="inherit_id" ref="loyalty_program_view_form"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<form position="attributes">
<attribute name="string">Gift &amp; Ewallet</attribute>
</form>
<field name="program_type" position="attributes">
<attribute name="options">{'whitelisted_values': ['gift_card', 'ewallet']}</attribute>
</field>
</field>
</record>
<record id="loyalty_program_gift_ewallet_action" model="ir.actions.act_window">
<field name="name">Gift cards &amp; eWallet</field>
<field name="res_model">loyalty.program</field>
<field name="view_mode">tree,form</field>
<field name="context">{'menu_type': 'gift_ewallet', 'default_program_type': 'gift_card'}</field>
<field name="domain">[('program_type', 'in', ('gift_card', 'ewallet'))]</field>
<field name="help" type="html">
<div class="o_loyalty_not_found container">
<h1>No loyalty program found.</h1>
<p class="lead">Create a new one from scratch, or use one of the templates below.</p>
</div>
</field>
</record>
<record id="action_loyalty_program_tree_gift_card_ewallet" model="ir.actions.act_window.view">
<field name="view_mode">tree</field>
<field name="sequence">1</field>
<field name="view_id" ref="loyalty_program_view_tree"/>
<field name="act_window_id" ref="loyalty_program_gift_ewallet_action"/>
</record>
<record id="action_loyalty_program_form_gift_card_ewallet" model="ir.actions.act_window.view">
<field name="view_mode">form</field>
<field name="sequence">2</field>
<field name="view_id" ref="loyalty_program_gift_ewallet_view_form"/>
<field name="act_window_id" ref="loyalty_program_gift_ewallet_action"/>
</record>
</odoo>
+101 -48
View File
@@ -5,51 +5,55 @@
<field name="model">loyalty.reward</field>
<field name="arch" type="xml">
<form>
<field name="program_type" invisible="1"/>
<field name="user_has_debug" invisible="1"/>
<field name="multi_product" invisible="1"/>
<field name="reward_product_ids" invisible="1"/>
<field name="all_discount_product_ids" invisible="1"/>
<sheet>
<group>
<group>
<field name="reward_type" widget="radio"/>
</group>
<group id="discount_group" attrs="{'invisible': [('reward_type', '!=', 'discount')]}">
<label for="discount"/>
<div class="o_row">
<field name="discount" class="oe_edit_only col-2 oe_inline text-center pe-2"/>
<field name="discount_mode" class="oe_edit_only col-4 oe_inline pe-2"/>
<field name="discount" class="oe_read_only"/>
<field name="discount_mode" class="oe_read_only"/>
<group string="Reward" id="reward_type_group">
<field name="reward_type" widget="selection" attrs="{'readonly' : [('program_type', '=', 'buy_x_get_y')]}"/>
<label for="discount" attrs="{'invisible': [('reward_type', '!=', 'discount')]}"/>
<div class="d-flex flex-row" attrs="{'invisible': [('reward_type', '!=', 'discount')]}">
<field name="discount" class="oe_inline me-1"/>
<field name="discount_mode" no_label="1" class="w-auto me-1"/>
<span>on</span>
</div>
<!-- Align right -->
<field name="all_discount_product_ids" invisible="1"/>
<label for="discount_applicability" string=""/>
<field name="discount_applicability" nolabel="1" widget="radio"/>
<field name="discount_product_domain" groups="base.group_no_one" widget="domain" options="{'model': 'product.product', 'in_dialog': true}"/>
<field name="discount_product_ids" widget="many2many_tags"
attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
<field name="discount_product_category_id" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
<field name="discount_product_tag_id" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
<field name="discount_max_amount"/>
<label for="discount_applicability" string="" attrs="{'invisible': [('reward_type', '!=', 'discount')]}"/>
<field name="discount_applicability" nolabel="1" widget="radio" attrs="{'invisible': [('reward_type', '!=', 'discount')]}"/>
</group>
<group id="product_group" attrs="{'invisible': [('reward_type', '!=', 'product')]}">
<field name="multi_product" invisible="1"/>
<field name="reward_product_ids" invisible="1"/>
<group string="Among" attrs="{'invisible': [('reward_type', '!=', 'product')]}">
<field name="reward_product_qty" string="Quantity rewarded"/>
<field name="reward_product_id" attrs="{'required': [('reward_type', '=', 'product'), ('reward_product_ids', '=', [])]}"/>
<field name="reward_product_tag_id" attrs="{'required': [('reward_type', '=', 'product'), ('reward_product_ids', '=', [])]}"/>
<label for="reward_product_qty"/>
<div class="row m-0">
<field name="reward_product_qty" class="oe_inline"/>
<field name="reward_product_uom_id" class="oe_inline" attrs="{'invisible': [('multi_product', '=', True)]}"/>
</div>
</group>
<group string="Discount" attrs="{'invisible': [('reward_type', '!=', 'discount')]}">
<field name="discount_max_amount"/>
<field name="discount_product_domain" groups="base.group_no_one" widget="domain" options="{'model': 'product.product', 'in_dialog': true}" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
<field name="discount_product_ids" widget="many2many_tags" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
<field name="discount_product_category_id" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
<field name="discount_product_tag_id" attrs="{'invisible': [('discount_applicability', '!=', 'specific')]}"/>
</group>
</group>
<group string="Points" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'not in', ('loyalty', 'buy_x_get_y'))]}">
<group>
<field name="required_points"/>
<field name="clear_wallet"/>
<label for="required_points" string="In exchange of"/>
<div class="o_row">
<field name="required_points" class="oe_edit_only col-2 oe_inline text-center pe-2"/>
<field name="point_name" no_label="1"/>
<span attrs="{'invisible': [('clear_wallet', '!=', True)]}"> (or more)</span>
</div>
<label for="clear_wallet" string="Clear all promo point(s)" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'in', ('loyalty', 'buy_x_get_y'))]}"/>
<div class="o_row" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'in', ('loyalty', 'buy_x_get_y'))]}">
<field name="clear_wallet"/>
</div>
</group>
</group>
<group>
<group>
<field name="description"/>
</group>
<field name="description" string="Description on order"/>
</group>
</sheet>
</form>
@@ -70,6 +74,8 @@
<field name="discount_mode"/>
<field name="discount_applicability"/>
<field name="discount_product_domain"/>
<field name="discount_product_category_id"/>
<field name="discount_product_tag_id"/>
<field name="discount_max_amount"/>
<field name="discount_line_product_id"/>
<field name="reward_product_id"/>
@@ -80,27 +86,74 @@
<field name="required_points"/>
<field name="point_name"/>
<field name="clear_wallet"/>
<field name="program_type"/>
<field name="user_has_debug"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click_edit mx-0">
<field name="description"/>
<t t-if="record.reward_type.raw_value === 'discount' &amp;&amp; record.discount_applicability.raw_value === 'specific'">
<t t-if="record.discount_product_domain.raw_value &amp;&amp; record.discount_product_domain.raw_value !== '[]'" groups="base.group_no_one">
<a><i class="fa fa-search fa-fw" title="Product Domain"/> <field name="discount_product_domain"/></a>
<div class="oe_kanban_global_click_edit mx-0 d-flex flex-row">
<div class="o_loyalty_kanban_card_left mw-75 flex-grow-1" id="reward_info">
<t t-if="record.reward_type.raw_value === 'discount'">
<t t-if="record.discount">
<a><field name="discount"/><field name="discount_mode"/> discount <t t-if="record.discount_max_amount.raw_value > 0">( Max <field name="discount_max_amount"/> )</t></a>
</t>
<t t-if="record.discount_applicability.raw_value === 'specific'">
<br/>
<br/>
<span class="fw-bold text-decoration-underline">Applied to:</span>
<t t-if="record.discount_product_ids.raw_value.length > 0">
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/> <field name="discount_product_ids" widget="many2many_tags" class="d-inline"/></div>
</t>
<t t-if="record.discount_product_category_id.raw_value">
<div class="d-flex"><i class="fa fa-cubes fa-fw" title="Product Categories"/> <field name="discount_product_category_id" class="d-inline"/></div>
</t>
<t t-if="record.discount_product_tag_id.raw_value">
<div class="d-flex"><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="discount_product_tag_id" class="d-inline"/></div>
</t>
<t t-if="record.discount_product_domain.raw_value &amp;&amp; record.discount_product_domain.raw_value !== '[]'" groups="base.group_no_one">
<div class="d-flex"><i class="fa fa-search fa-fw" title="Product Domain"/> <field name="discount_product_domain" class="d-inline"/></div>
</t>
</t>
<t t-elif="record.discount_applicability.raw_value === 'cheapest'">
on the cheapest product
<br/>
</t>
<t t-elif="record.discount_applicability.raw_value === 'order'">
on your order
<br/>
</t>
</t>
<t t-if="record.discount_product_ids.raw_value.length > 0">
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/> <field name="discount_product_ids" widget="many2many_tags" class="d-inline"/></div>
</t>
<t t-if="record.discount_product_category_id.raw_value">
<a><i class="fa fa-cubes fa-fw" title="Product Categories"/> <field name="discount_product_category_id"/></a>
<t t-if="record.reward_type.raw_value === 'product'">
<a>Free product</a>
<br/>
</t>
<t t-if="record.discount_product_tag_id.raw_value">
<a><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="discount_product_tag_id"/></a>
<br/>
<t t-if="record.reward_product_id.raw_value">
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Product Domain"/><field name="reward_product_id"/> <t t-if="record.reward_product_qty.raw_value > 1"><span> x </span><field name="reward_product_qty"/></t></div>
</t>
<t t-if="record.reward_product_tag_id.raw_value">
<div class="d-flex"><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="reward_product_tag_id" class="d-inline"/></div>
</t>
</t>
</t>
<p class="m-0">With <field name="required_points"/> <field name="point_name"/><t t-if="record.clear_wallet.raw_value"> (or more)</t></p>
</div>
<div class="o_loyalty_kanban_card_right" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'not in', ('loyalty', 'buy_x_get_y'))]}">
<p class="text-muted">
<span class="fw-bold text-decoration-underline">In exchange of</span>
<br/>
<t t-if="record.clear_wallet.raw_value">
all <field name="point_name"/> (if at least <field name="required_points"/> <field name="point_name"/>)
</t>
<t t-else="">
<field name="required_points"/> <field name="point_name"/>
</t>
</p>
</div>
</div>
</t>
</templates>
+69 -54
View File
@@ -5,37 +5,40 @@
<field name="model">loyalty.rule</field>
<field name="arch" type="xml">
<form class="loyalty-rule-form">
<field name="program_type" invisible="1"/>
<field name="user_has_debug" invisible="1"/>
<sheet>
<group>
<group attrs="{'invisible': [('program_type', '!=', 'promo_code')]}">
<field name="code" attrs="{'required': [('program_type', '=', 'promo_code')]}"/>
</group>
</group>
<group>
<group>
<separator string="Conditions" colspan="2"/>
<field name="minimum_qty"/>
<label for="minimum_amount" attrs="{'invisible': [('program_type', '=', 'buy_x_get_y')]}"/>
<div class="d-flex flex-row">
<field name="minimum_amount" class="oe_inline me-1"/>
<span>tax</span>
<field name="minimum_amount_tax_mode" class="ms-1"/>
</div>
<separator string="Among" colspan="2"/>
<field name="product_domain" groups="base.group_no_one" widget="domain" options="{'model': 'product.product', 'in_dialog': true}"/>
<field name="product_ids" widget="many2many_tags"/>
<field name="product_category_id"/>
<field name="product_tag_id"/>
</group>
<group>
<label for="reward_point_amount"/>
<span>
<field name="reward_point_amount" class="oe_inline"/>
<field name="reward_point_name" class="oe_inline"/>
</span>
<label for="reward_point_mode" string=""/>
<field name="reward_point_mode" widget="radio" nolabel="1"/>
</group>
<group>
<field name="minimum_qty"/>
<label for="minimum_amount"/>
<div class="o_row">
<field name="minimum_amount" class="oe_inline pe-2"/>
<span>tax</span>
<field name="minimum_amount_tax_mode" class="oe_inline"/>
<group attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'not in', ('loyalty', 'buy_x_get_y'))]}">
<separator string="Point(s)" colspan="2"/>
<span colspan="2" attrs="{'invisible': [('program_type', '!=', 'coupons')]}">Grant the amount of coupon popints defined as the coupon value</span>
<label for="reward_point_amount" string="Grant" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'promo_code', 'next_order_coupons', 'loyalty', 'buy_x_get_y'))]}"/>
<div class="d-flex flex-row" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'promo_code', 'next_order_coupons', 'loyalty', 'buy_x_get_y'))]}">
<field name="reward_point_amount" class="oe_inline me-1"/>
<field name="reward_point_name" class="w-auto"/>
</div>
</group>
<group>
<field name="mode" widget="radio"/>
<field name="code"
attrs="{'invisible': [('mode', '!=', 'with_code')],
'required': [('mode', '=', 'with_code')]}"/>
<label for="reward_point_mode" string=""/>
<field name="reward_point_mode" widget="radio" nolabel="1" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'promo_code', 'next_order_coupons', 'loyalty', 'buy_x_get_y'))]}"/>
</group>
</group>
</sheet>
@@ -64,43 +67,55 @@
<field name="minimum_amount_tax_mode"/>
<field name="mode"/>
<field name="code"/>
<field name="program_type"/>
<field name="user_has_debug"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click_edit mx-0">
<t t-if="record.product_domain.raw_value &amp;&amp; record.product_domain.raw_value !== '[]'" groups="base.group_no_one">
<a><i class="fa fa-search fa-fw" title="Product Domain"/> <field name="product_domain"/></a>
</t>
<t t-if="record.product_ids.raw_value.length > 0">
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/> <field name="product_ids" widget="many2many_tags" class="d-inline"/></div>
</t>
<t t-if="record.product_category_id.raw_value">
<a><i class="fa fa-cubes fa-fw" title="Product Categories"/> <field name="product_category_id"/></a>
<div class="oe_kanban_global_click_edit mx-0 d-flex flex-row">
<div class="o_loyalty_kanban_card_left mw-75 flex-grow-1">
<t t-if="record.code.raw_value"><span>Discount code <field name="code"/></span><br/></t>
<t t-if="record.minimum_qty.raw_value > 0"><span>If minimum <field name="minimum_qty"/> item(s) bought</span><br/></t>
<t t-if="record.minimum_amount.raw_value > 0"><span>If minimum <field name="minimum_amount"/> spent<t t-if="record.minimum_amount_tax_mode.raw_value === 'excl'"> (tax excluded)</t></span><br/></t>
<br/>
</t>
<t t-if="record.product_tag_id.raw_value">
<a><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="product_tag_id"/></a>
<br/>
</t>
<t t-if="record.product_ids.raw_value.length === 0 &amp;&amp; !record.product_category_id.raw_value &amp;&amp; !record.product_tag_id.raw_value">
<a><i class="fa fa-cube fa-fw" title="Products"/> All Products</a>
<br/>
</t>
<a>
<t t-if="record.minimum_qty.raw_value > 1 || record.minimum_amount.raw_value">
<t t-set="qty" t-value="record.minimum_qty.raw_value"/>
<t t-set="amt" t-value="record.minimum_amount.raw_value"/>
<t t-set="tax_mode" t-value="record.minimum_amount_tax_mode.raw_value"/>
<span>If<t t-if="qty > 1"> minimum <field name="minimum_qty"/> PCE</t>
<t t-if="amt"><t t-if="qty > 1">and</t> minimum <field name="minimum_amount"/><t t-if="tax_mode === 'excl'"> (tax excluded)</t></t>
</span>
<t t-if="record.product_ids.raw_value.length != 0 || record.product_category_id.raw_value || record.product_tag_id.raw_value">
<span class="fw-bold text-decoration-underline">Among:</span>
<br/>
<t t-if="record.product_ids.raw_value.length > 0">
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/> <field name="product_ids" widget="many2many_tags" class="d-inline"/></div>
</t>
<t t-if="record.product_category_id.raw_value">
<div class="d-flex"><i class="fa fa-cubes fa-fw" title="Product Categories"/> <field name="product_category_id" class="d-inline"/></div>
</t>
<t t-if="record.product_tag_id.raw_value">
<div class="d-flex"><i class="fa fa-tags fa-fw" title="Product Tags"/> <field name="product_tag_id" class="d-inline"/></div>
</t>
<t t-if="record.product_ids.raw_value.length === 0 &amp;&amp; !record.product_category_id.raw_value &amp;&amp; !record.product_tag_id.raw_value">
<div class="d-flex"><i class="fa fa-cube fa-fw" title="Products"/><span>All Products</span></div>
</t>
<t t-if="record.product_domain.raw_value &amp;&amp; record.product_domain.raw_value !== '[]'" groups="base.group_no_one">
<div class="d-flex"><i class="fa fa-search fa-fw" title="Product Domain"/> <field name="product_domain" class="d-inline"/></div>
</t>
</t>
</a>
<t t-if="record.code.raw_value">
<a><i class="fa fa-pencil-square-o fa-fw" title="Code"/>With Code: <field name="code"/></a>
<br/>
</t>
<i class="fa fa-gift fa-fw" title="conditions"/> <span>Reward <field name="reward_point_amount"/> <field name="reward_point_name"/> <field name="reward_point_mode"/></span>
</div>
<div class="o_loyalty_kanban_card_right" attrs="{'invisible': [('user_has_debug', '=', False), ('program_type', 'not in', ('loyalty', 'buy_x_get_y'))]}">
<p class="text-muted" attrs="{'invisible': [('program_type', '!=', 'coupons')]}">
<span class="fw-bold text-decoration-underline">Grant</span>
<br/>
the value of the coupon
</p>
<p class="text-muted" attrs="{'invisible': [('program_type', 'not in', ('promotion', 'promo_code', 'next_order_coupons', 'loyalty', 'buy_x_get_y'))]}">
<span class="fw-bold text-decoration-underline">Grant</span>
<br/>
<field name="reward_point_amount"/>
<span> </span>
<field name="reward_point_name"/>
<span> </span>
<field name="reward_point_mode"/>
</p>
</div>
</div>
</t>
</templates>
@@ -26,6 +26,7 @@ class LoyaltyGenerateWizard(models.TransientModel):
points_granted = fields.Float('Grant', required=True, default=1)
points_name = fields.Char(related='program_id.portal_point_name', readonly=True)
valid_until = fields.Date()
will_send_mail = fields.Boolean(compute='_compute_will_send_mail')
def _get_partners(self):
self.ensure_one()
@@ -46,6 +47,11 @@ class LoyaltyGenerateWizard(models.TransientModel):
else:
wizard.coupon_qty = wizard.coupon_qty or 0
@api.depends("mode", "program_id")
def _compute_will_send_mail(self):
for wizard in self:
wizard.will_send_mail = wizard.mode == 'selected' and 'create' in wizard.program_id.mapped('communication_plan_ids.trigger')
def _get_coupon_values(self, partner):
self.ensure_one()
return {
@@ -4,26 +4,46 @@
<field name="name">loyalty.generate.wizard.view.form</field>
<field name="model">loyalty.generate.wizard</field>
<field name="arch" type="xml">
<form string="Generate Coupons">
<form string="Generate">
<sheet>
<group>
<field name="program_id" invisible="1"/>
<field name="program_type" invisible="1"/>
<field name="mode" widget="radio" attrs="{'invisible': [('program_type', '=', 'ewallet')]}"/>
<field name="customer_ids" widget="many2many_tags_avatar" attrs="{'invisible': [('mode', '=', 'anonymous')]}"/>
<field name="customer_tag_ids" widget="many2many_tags" attrs="{'invisible': [('mode', '=', 'anonymous')]}" options="{'color_field': 'color'}"/>
<separator/>
<field name="coupon_qty" attrs="{'readonly': [('mode', '=', 'selected')], 'required': [('mode', '=', 'anonymous')]}"/>
<label for="points_granted"/>
<span>
<field name="points_granted" class="oe_inline"/>
<field name="points_name" class="oe_inline"/>
</span>
<field name="valid_until"/>
<group>
<field name="program_id" invisible="1"/>
<field name="will_send_mail" invisible="1"/>
<field name="program_type" invisible="1"/>
<field name="mode" widget="radio" attrs="{'invisible': [('program_type', '=', 'ewallet')]}"/>
<field name="customer_ids" widget="many2many_tags" attrs="{'invisible': [('mode', '=', 'anonymous')]}"/>
<field name="customer_tag_ids" widget="many2many_tags" attrs="{'invisible': [('mode', '=', 'anonymous')]}" options="{'color_field': 'color'}"/>
<field name="coupon_qty" string="Quantity to generate" attrs="{'readonly': [('mode', '=', 'selected')], 'required': [('mode', '=', 'anonymous')]}"/>
<label string="Coupon value" for="points_granted" groups="base.group_no_one" attrs="{'invisible': [('program_type', '!=', 'coupons')]}"/>
<span class="d-inline-block" groups="base.group_no_one" attrs="{'invisible': [('program_type', '!=', 'coupons')]}">
<field name="points_granted" class="oe_inline"/>
<field name="points_name" class="oe_inline"/>
</span>
<label string="Gift Card value" for="points_granted" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}"/>
<span class="d-inline-block" attrs="{'invisible': [('program_type', '!=', 'gift_card')]}">
<field name="points_granted" class="w-auto oe_inline me-1"/>
<field name="points_name" class="d-inline" no_label="1"/>
</span>
<label string="eWallet value" for="points_granted" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}"/>
<span class="d-inline-block" attrs="{'invisible': [('program_type', '!=', 'ewallet')]}">
<field name="points_granted" class="w-auto oe_inline me-1"/>
<field name="points_name" class="d-inline" no_label="1"/>
</span>
<field name="valid_until"/>
</group>
</group>
</sheet>
<footer>
<button name="generate_coupons" type="object" string="Generate Coupons" class="btn-primary" data-hotkey="q"/>
<button name="generate_coupons" type="object" class="btn-primary" data-hotkey="q">
<span attrs="{'invisible': [('will_send_mail', '=', False)]}">
Generate and Send
</span>
<span attrs="{'invisible': [('will_send_mail', '=', True)]}">
Generate
</span>
<field name="program_type" nolabel="1"/>
</button>
<button special="cancel" data-hotkey="z" string="Cancel"/>
</footer>
</form>
@@ -31,7 +51,7 @@
</record>
<record id="loyalty_generate_wizard_action" model="ir.actions.act_window">
<field name="name">Generate Coupons</field>
<field name="name">Generate</field>
<field name="res_model">loyalty.generate.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
@@ -10,23 +10,26 @@ class LoyaltyProgram(models.Model):
def _program_type_default_values(self):
res = super()._program_type_default_values()
# Add a loyalty reward for free shipping
res['loyalty']['reward_ids'].append((0, 0, {
'reward_type': 'shipping',
'required_points': 100,
}))
if 'loyalty' in res:
res['loyalty']['reward_ids'].append((0, 0, {
'reward_type': 'shipping',
'required_points': 100,
}))
return res
@api.model
def get_program_templates(self):
# Override 'promotion' template to say free shipping
res = super().get_program_templates()
res['promotion']['description'] = _("Automatic promotion: free shipping on orders higher than $50")
if 'promotion' in res:
res['promotion']['description'] = _("Automatic promotion: free shipping on orders higher than $50")
return res
@api.model
def _get_template_values(self):
res = super()._get_template_values()
res['promotion']['reward_ids'] = [(5, 0, 0), (0, 0, {
'reward_type': 'shipping',
})]
if 'promotion' in res:
res['promotion']['reward_ids'] = [(5, 0, 0), (0, 0, {
'reward_type': 'shipping',
})]
return res
@@ -5,11 +5,26 @@
<field name="model">loyalty.reward</field>
<field name="inherit_id" ref="loyalty.loyalty_reward_view_form"/>
<field name="arch" type="xml">
<xpath expr="//group[@id='product_group']" position="after">
<group id="shipping" attrs="{'invisible': [('reward_type', '!=', 'shipping')]}">
<xpath expr="//group[@id='reward_type_group']" position="after">
<group id="shipping" string="Free shipping" attrs="{'invisible': [('reward_type', '!=', 'shipping')]}">
<field name="discount_max_amount"/>
</group>
</xpath>
</field>
</record>
<record id="loyalty_reward_view_kanban_inherit_loyalty_delivery" model="ir.ui.view">
<field name="name">loyalty.reward.view.kanban.inherit.loyalty.delivery</field>
<field name="model">loyalty.reward</field>
<field name="inherit_id" ref="loyalty.loyalty_reward_view_kanban"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='reward_info']" position="inside">
<t t-if="record.reward_type.raw_value === 'shipping'">
<a>Free shipping <t t-if="record.discount_max_amount.raw_value > 0">( Max <field name="discount_max_amount"/> )</t></a>
<br/><br/>
</t>
</xpath>
</field>
</record>
</odoo>
+6 -2
View File
@@ -4,9 +4,11 @@
<!-- 15% next order -->
<record id="15_pc_on_next_order" model="loyalty.program">
<field name="name">15% on next order</field>
<field name="program_type">promotion</field>
<field name="program_type">next_order_coupons</field>
<field name="trigger">auto</field>
<field name="applies_on">future</field>
<field name="portal_visible">True</field>
<field name="portal_point_name">Coupon point(s)</field>
</record>
<record id="15_pc_on_next_order_rule" model="loyalty.rule">
@@ -62,8 +64,10 @@
<record id="loyalty_program" model="loyalty.program">
<field name="name">Loyalty Program</field>
<field name="program_type">loyalty</field>
<field name="trigger">auto</field>
<field name="applies_on">both</field>
<field name="trigger">auto</field>
<field name="portal_visible">True</field>
<field name="portal_point_name">Loyalty Points</field>
</record>
<record id="loyalty_program_rule" model="loyalty.rule">
@@ -9,6 +9,7 @@ class LoyaltyProgram(models.Model):
pos_config_ids = fields.Many2many('pos.config', string="Point of Sales", readonly=True)
pos_order_count = fields.Integer("PoS Order Count", compute='_compute_pos_order_count')
pos_ok = fields.Boolean("Point of Sale", default=True)
def _compute_pos_order_count(self):
read_group_res = self.env['pos.order.line']._read_group(
+4 -4
View File
@@ -13,16 +13,16 @@ class PosConfig(models.Model):
use_coupon_programs = fields.Boolean('Coupons & Promotions',
help="Use coupon and promotion programs in this PoS configuration.")
coupon_program_ids = fields.Many2many(
'loyalty.program', string="Coupon Programs", domain=[('program_type', '=', 'coupons')],
'loyalty.program', string="Coupon Programs", domain=[('pos_ok', '=', True), ('program_type', '=', 'coupons')],
relation='pos_config_coupon_program_rel')
promo_program_ids = fields.Many2many(
'loyalty.program', string="Promotion Programs", domain=[('program_type', '=', 'promotion')],
'loyalty.program', string="Promotion Programs", domain=[('pos_ok', '=', True), ('program_type', 'in', ('promotion', 'buy_x_get_y', 'promo_code', 'next_order_coupons'))],
relation='pos_config_promo_program_rel')
loyalty_program_id = fields.Many2one('loyalty.program', "PoS Loyalty Programs", domain=[('program_type', '=', 'loyalty')], default=_default_loyalty_program)
loyalty_program_id = fields.Many2one('loyalty.program', "PoS Loyalty Programs", domain=[('pos_ok', '=', True), ('program_type', '=', 'loyalty')], default=_default_loyalty_program)
use_gift_card = fields.Boolean('Gift Cards')
gift_card_program_id = fields.Many2one('loyalty.program', "PoS Gift Card Program", domain=[('program_type', '=', 'gift_card')])
gift_card_program_id = fields.Many2one('loyalty.program', "PoS Gift Card Program", domain=[('pos_ok', '=', True), ('program_type', '=', 'gift_card')])
gift_card_settings = fields.Selection(
[
("create_set", "Generate a new barcode and set a price"),
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_program_view_form_inherit_pos_loyalty" model="ir.ui.view">
<field name="name">loyalty.program.view.form.inherit.pos.loyalty</field>
<field name="model">loyalty.program</field>
<field name="inherit_id" ref="loyalty.loyalty_program_view_form"/>
<field name="arch" type="xml">
<xpath expr="//label[@for='available_on']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="inside">
<span class="d-inline-block">
<field name="pos_ok" class="w-auto"/>
<label for="pos_ok"/>
</span>
</xpath>
</field>
</record>
</odoo>
@@ -1,11 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem
id="menu_coupon_type_config"
action="loyalty.loyalty_program_action"
name="Coupons &amp; Loyalty"
id="menu_discount_loyalty_type_config"
action="loyalty.loyalty_program_discount_loyalty_action"
name="Discount &amp; Loyalty"
parent="point_of_sale.pos_config_menu_catalog"
groups="point_of_sale.group_pos_manager"
sequence="91"
/>
<menuitem
id="menu_gift_ewallet_type_config"
action="loyalty.loyalty_program_gift_ewallet_action"
name="Gift cards &amp; eWallet"
parent="point_of_sale.pos_config_menu_catalog"
groups="point_of_sale.group_pos_manager"
sequence="92"
/>
</odoo>
@@ -103,14 +103,14 @@
</div>
<div class="col-12 col-lg-6 o_setting_box"
id="coupon_settings"
title="Boost your sales with two kinds of discount programs: promotions and coupon codes. Specific conditions can be set (products, customers, minimum purchase amount, period). Rewards can be discounts (% or amount) or free products.">
title="Boost your sales with multiple kinds of programs: Coupons, Promotions, Gift Card, Loyalty. Specific conditions can be set (products, customers, minimum purchase amount, period). Rewards can be discounts (% or amount) or free products.">
<div class="o_setting_left_pane">
<field name="module_sale_loyalty"/>
</div>
<div class="o_setting_right_pane">
<label for="module_sale_loyalty"/>
<label for="module_sale_loyalty" string="Discounts, Loyalty &amp; Gift Card"/>
<div class="text-muted" id="sale_coupon">
Manage promotion &amp; coupon programs
Manage Promotions, coupons, loyalty cards, Gift cards &amp; eWallet
</div>
</div>
</div>
@@ -7,6 +7,7 @@ class LoyaltyProgram(models.Model):
_inherit = 'loyalty.program'
order_count = fields.Integer(compute='_compute_order_count')
sale_ok = fields.Boolean("Sales", default=True)
def _compute_order_count(self):
# An order should count only once PER program but may appear in multiple programs
+14 -8
View File
@@ -325,7 +325,7 @@ class SaleOrder(models.Model):
'name': _(
'Discount: %(desc)s%(tax_str)s',
desc=reward.description,
tax_str=_(' - On product with the following taxes: %(taxes)s', taxes=", ".join(mapped_taxes[tax].mapped('name'))),
tax_str=len(discountable_per_tax) and any(t.name for t in mapped_taxes[tax]) and _(' - On product with the following taxes: %(taxes)s', taxes=", ".join(mapped_taxes[tax].mapped('name'))) or '',
),
'product_id': reward.discount_line_product_id.id,
'price_unit': -(price * discount_factor),
@@ -348,7 +348,7 @@ class SaleOrder(models.Model):
Returns the base domain that all programs have to comply to.
"""
self.ensure_one()
return [('active', '=', True),
return [('active', '=', True), ('sale_ok', '=', True),
('company_id', 'in', (self.company_id.id, False)),
'|', ('date_to', '=', False), ('date_to', '>=', fields.Date.context_today(self))]
@@ -357,7 +357,7 @@ class SaleOrder(models.Model):
Returns the base domain that all triggers have to comply to.
"""
self.ensure_one()
return [('active', '=', True),
return [('active', '=', True), ('program_id.sale_ok', '=', True),
('company_id', 'in', (self.company_id.id, False)),
'|', ('program_id.date_to', '=', False), ('program_id.date_to', '<=', fields.Date.context_today(self))]
@@ -621,9 +621,8 @@ class SaleOrder(models.Model):
lines_to_unlink = self.env['sale.order.line']
coupons_to_unlink = self.env['loyalty.card']
point_entries_to_unlink = self.env['sale.order.coupon.points']
# Remove any coupons that do not belong to the customer or are expired
# Remove any coupons that are expired
self.applied_coupon_ids = self.applied_coupon_ids.filtered(lambda c:
(not c.partner_id or c.partner_id == self.partner_id) and
(not c.expiration_date or c.expiration_date >= fields.Date.today())
)
point_ids_per_program = defaultdict(lambda: self.env['sale.order.coupon.points'])
@@ -723,7 +722,11 @@ class SaleOrder(models.Model):
if coupon not in all_coupons or points < reward.required_points or program not in domain_matching_programs:
# Reward is not applicable anymore, the reward lines will simply be removed at the end of this function
continue
values_list = self._get_reward_line_values(reward, coupon, product=reward_key[3])
try:
values_list = self._get_reward_line_values(reward, coupon, product=reward_key[3])
except UserError:
# It could happen that we have nothing to discount after changing the order.
values_list = []
reward_line_pool = self._write_vals_from_reward_vals(values_list, reward_line_pool, delete=False)
lines_to_unlink |= reward_line_pool
@@ -937,8 +940,11 @@ class SaleOrder(models.Model):
# No trigger was found from the code, try to find a coupon
if not program:
coupon = self.env['loyalty.card'].search([('partner_id', 'in', (False, self.partner_id.id)), ('code', '=', code)])
if not coupon or not coupon.program_id.active or not coupon.program_id.reward_ids:
coupon = self.env['loyalty.card'].search([('code', '=', code)])
if not coupon or\
not coupon.program_id.active or\
not coupon.program_id.reward_ids or\
not coupon.program_id.filtered_domain(self._get_program_domain()):
return {'error': _('This code is invalid (%s).', code), 'not_found': True}
elif coupon.expiration_date and coupon.expiration_date < fields.Date.today():
return {'error': _('This coupon is expired.')}
+108
View File
@@ -221,3 +221,111 @@ class TestSaleCouponCommon(TestSaleProductAttributeValueCommon):
if len(program.reward_ids) > 1 or len(coupons_per_program[program]) != 1 or not program.active:
continue
self._claim_reward(order, program, coupons_per_program[program])
class TestSaleCouponNumbersCommon(TestSaleCouponCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.largeCabinet = cls.env['product.product'].create({
'name': 'Large Cabinet',
'list_price': 320.0,
'taxes_id': False,
})
cls.conferenceChair = cls.env['product.product'].create({
'name': 'Conference Chair',
'list_price': 16.5,
'taxes_id': False,
})
cls.pedalBin = cls.env['product.product'].create({
'name': 'Pedal Bin',
'list_price': 47.0,
'taxes_id': False,
})
cls.drawerBlack = cls.env['product.product'].create({
'name': 'Drawer Black',
'list_price': 25.0,
'taxes_id': False,
})
cls.largeMeetingTable = cls.env['product.product'].create({
'name': 'Large Meeting Table',
'list_price': 40000.0,
'taxes_id': False,
})
cls.steve = cls.env['res.partner'].create({
'name': 'Steve Bucknor',
'email': 'steve.bucknor@example.com',
})
cls.empty_order = cls.env['sale.order'].create({
'partner_id': cls.steve.id
})
cls.p1 = cls.env['loyalty.program'].create({
'name': 'Code for 10% on orders',
'trigger': 'with_code',
'program_type': 'promotion',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'mode': 'with_code',
'code': 'test_10pc',
})],
'reward_ids': [(0, 0, {
'reward_type': 'discount',
'discount_mode': 'percent',
'discount': 10,
'discount_applicability': 'order',
'required_points': 1,
})],
})
cls.p2 = cls.env['loyalty.program'].create({
'name': 'Buy 3 cabinets, get one for free',
'trigger': 'auto',
'program_type': 'promotion',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'product_ids': cls.largeCabinet,
'reward_point_mode': 'unit',
'minimum_qty': 3,
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_id': cls.largeCabinet.id,
'reward_product_qty': 1,
'required_points': 3,
})],
})
cls.p3 = cls.env['loyalty.program'].create({
'name': 'Buy 1 drawer black, get a free Large Meeting Table',
'trigger': 'auto',
'program_type': 'promotion',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'product_ids': cls.drawerBlack,
'reward_point_mode': 'order',
'minimum_qty': 1,
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_id': cls.largeMeetingTable.id,
'reward_product_qty': 1,
'required_points': 1,
})],
})
cls.discount_coupon_program = cls.env['loyalty.program'].create({
'name': '$100 coupon',
'program_type': 'coupons',
'trigger': 'with_code',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'minimum_amount': 100,
})],
'reward_ids': [(0, 0, {
'reward_type': 'discount',
'discount_mode': 'per_point',
'discount': 100,
'discount_applicability': 'order',
'required_points': 1,
})],
})
cls.all_programs = cls.env['loyalty.program'].search([])
@@ -1,121 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponCommon
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponNumbersCommon
from odoo.exceptions import ValidationError
from odoo.tests import tagged
from odoo.tools.float_utils import float_compare
@tagged('post_install', '-at_install')
class TestSaleCouponProgramNumbers(TestSaleCouponCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.largeCabinet = cls.env['product.product'].create({
'name': 'Large Cabinet',
'list_price': 320.0,
'taxes_id': False,
})
cls.conferenceChair = cls.env['product.product'].create({
'name': 'Conference Chair',
'list_price': 16.5,
'taxes_id': False,
})
cls.pedalBin = cls.env['product.product'].create({
'name': 'Pedal Bin',
'list_price': 47.0,
'taxes_id': False,
})
cls.drawerBlack = cls.env['product.product'].create({
'name': 'Drawer Black',
'list_price': 25.0,
'taxes_id': False,
})
cls.largeMeetingTable = cls.env['product.product'].create({
'name': 'Large Meeting Table',
'list_price': 40000.0,
'taxes_id': False,
})
cls.steve = cls.env['res.partner'].create({
'name': 'Steve Bucknor',
'email': 'steve.bucknor@example.com',
})
cls.empty_order = cls.env['sale.order'].create({
'partner_id': cls.steve.id
})
cls.p1 = cls.env['loyalty.program'].create({
'name': 'Code for 10% on orders',
'trigger': 'with_code',
'program_type': 'promotion',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'mode': 'with_code',
'code': 'test_10pc',
})],
'reward_ids': [(0, 0, {
'reward_type': 'discount',
'discount_mode': 'percent',
'discount': 10,
'discount_applicability': 'order',
'required_points': 1,
})],
})
cls.p2 = cls.env['loyalty.program'].create({
'name': 'Buy 3 cabinets, get one for free',
'trigger': 'auto',
'program_type': 'promotion',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'product_ids': cls.largeCabinet,
'reward_point_mode': 'unit',
'minimum_qty': 3,
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_id': cls.largeCabinet.id,
'reward_product_qty': 1,
'required_points': 3,
})],
})
cls.p3 = cls.env['loyalty.program'].create({
'name': 'Buy 1 drawer black, get a free Large Meeting Table',
'trigger': 'auto',
'program_type': 'promotion',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'product_ids': cls.drawerBlack,
'reward_point_mode': 'order',
'minimum_qty': 1,
})],
'reward_ids': [(0, 0, {
'reward_type': 'product',
'reward_product_id': cls.largeMeetingTable.id,
'reward_product_qty': 1,
'required_points': 1,
})],
})
cls.discount_coupon_program = cls.env['loyalty.program'].create({
'name': '$100 coupon',
'program_type': 'coupons',
'trigger': 'with_code',
'applies_on': 'current',
'rule_ids': [(0, 0, {
'minimum_amount': 100,
})],
'reward_ids': [(0, 0, {
'reward_type': 'discount',
'discount_mode': 'per_point',
'discount': 100,
'discount_applicability': 'order',
'required_points': 1,
})],
})
cls.all_programs = cls.env['loyalty.program'].search([])
class TestSaleCouponProgramNumbers(TestSaleCouponNumbersCommon):
def test_program_numbers_free_and_paid_product_qty(self):
# These tests will focus on numbers (free product qty, SO total, reduction total..)
@@ -1,11 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_program_view_form_inherit_sale_loyalty" model="ir.ui.view">
<field name="name">loyalty.program.view.form.inherit.sale.loyalty</field>
<field name="model">loyalty.program</field>
<field name="inherit_id" ref="loyalty.loyalty_program_view_form"/>
<field name="arch" type="xml">
<xpath expr="//label[@for='available_on']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="inside">
<span class="d-inline-block">
<field name="sale_ok" class="w-auto"/>
<label for="sale_ok"/>
</span>
</xpath>
</field>
</record>
<menuitem
id="menu_loyalty_config"
action="loyalty.loyalty_program_action"
id="menu_discount_loyalty_type_config"
action="loyalty.loyalty_program_discount_loyalty_action"
name="Discount &amp; Loyalty"
parent="sale.product_menu_catalog"
name="Coupons &amp; Loyalty"
groups="sales_team.group_sale_manager"
sequence="5"
/>
<menuitem
id="menu_gift_ewallet_type_config"
action="loyalty.loyalty_program_gift_ewallet_action"
name="Gift cards &amp; eWallet"
parent="sale.product_menu_catalog"
groups="sales_team.group_sale_manager"
sequence="6"
/>
</odoo>
+2 -1
View File
@@ -2,7 +2,8 @@
<odoo>
<template id="sale_loyalty.used_gift_card">
<div class="text-muted d-md-block small" t-if="line.coupon_id">
<div class="text-muted d-md-block small"
t-if="line.coupon_id and line.order_id != line.coupon_id.order_id and not line.coupon_id.program_id.is_nominative">
<span>
Code:
<t t-esc="line.coupon_id.code[-4:].rjust(14, '&#8902;')"/>
@@ -33,7 +33,7 @@
<!-- No rewards -->
<button special="cancel" string="Discard" class="btn btn-primary"
attrs="{'invisible': [('reward_ids', '!=', [])]}" data-hotkey="z"/>
<button type="action" name="%(loyalty.loyalty_program_action)d" string="Coupons &amp; Loyalty" class="btn btn-secondary float-end"
<button type="action" name="%(loyalty.loyalty_program_discount_loyalty_action)d" string="Coupons &amp; Loyalty" class="btn btn-secondary float-end"
attrs="{'invisible': [('reward_ids', '!=', [])]}" groups="sales_team.group_sale_manager" data-hotkey="q"/>
</footer>
</form>
@@ -762,7 +762,7 @@ publicWidget.registry.WebsiteSale = publicWidget.Widget.extend(VariantMixin, car
* @param {Event} ev
*/
_onClickShowCoupon: function (ev) {
$(ev.currentTarget).hide();
$(".show_coupon").hide();
$('.coupon_form').removeClass('d-none');
},
/**
@@ -198,9 +198,9 @@
<field name="module_sale_loyalty" />
</div>
<div class="o_setting_right_pane">
<label for="module_sale_loyalty" string="Promotions, Coupons, Gift Card &amp; Loyalty Program"/>
<label for="module_sale_loyalty" string="Discounts, Loyalty &amp; Gift Card"/>
<div class="text-muted" id="website_sale_loyalty">
Manage promotion that will grant customers discounts or gifts
Manage Promotions, coupons, loyalty cards, Gift cards &amp; eWallet
</div>
</div>
</div>
@@ -36,7 +36,7 @@ odoo.define('website_sale_delivery.test', function (require) {
},
{
content: "click on 'Pay with gift card'",
trigger: '.js_show_gift_card',
trigger: '.show_coupon',
run: 'click'
},
{
@@ -46,7 +46,7 @@ odoo.define('website_sale_delivery.test', function (require) {
},
{
content: "click on 'Pay'",
trigger: "button[type='submit'].a-submit:contains(Pay)",
trigger: "a[role='button'].a-submit:contains(Apply)",
run: 'click'
},
{
@@ -14,7 +14,6 @@ Coupon & promotion programs can be edited in the Catalog menu of the Website app
'data': [
'security/ir.model.access.csv',
'wizard/sale_coupon_share_views.xml',
'wizard/loyalty_generate_wizard_views.xml',
'views/loyalty_card_views.xml',
'views/loyalty_program_views.xml',
'views/website_sale_templates.xml',
@@ -9,7 +9,7 @@ from werkzeug.urls import url_encode, url_parse
class WebsiteSale(main.WebsiteSale):
@http.route(['/shop/pricelist'])
@http.route()
def pricelist(self, promo, **post):
order = request.website.sale_get_order()
coupon_status = order._try_apply_code(promo)
@@ -47,6 +47,8 @@ class WebsiteSale(main.WebsiteSale):
url_parts = url_parse(r)
url_query = url_parts.decode_query()
url_query.pop('coupon_error', False) # trust only Odoo error message
url_query.pop('coupon_error_type', False)
code = code.strip()
request.session['pending_coupon_code'] = code
order = request.website.sale_get_order()
@@ -58,6 +60,7 @@ class WebsiteSale(main.WebsiteSale):
url_query['notify_coupon'] = code
else:
url_query['coupon_error'] = _("The coupon will be automatically applied when you add something in your cart.")
url_query['coupon_error_type'] = 'warning'
redirect = url_parts.replace(query=url_encode(url_query))
return request.redirect(redirect.to_url())
@@ -1,12 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo import fields, models
class LoyaltyProgram(models.Model):
_name = 'loyalty.program'
_inherit = ['loyalty.program', 'website.multi.mixin']
ecommerce_ok = fields.Boolean("Available on Website", default=True)
def action_program_share(self):
self.ensure_one()
return self.env['coupon.share'].create_share_action(program=self)
@@ -3,6 +3,7 @@ from collections import defaultdict
from datetime import timedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.http import request
@@ -12,10 +13,24 @@ class SaleOrder(models.Model):
def _get_program_domain(self):
res = super()._get_program_domain()
# Replace `sale_ok` leaf with `ecommerce_ok` if order is linked to a website
if self.website_id:
for idx, leaf in enumerate(res):
if leaf[0] != 'sale_ok':
continue
res[idx] = ('ecommerce_ok', '=', True)
break
return expression.AND([res, [('website_id', 'in', (self.website_id.id, False))]])
def _get_trigger_domain(self):
res = super()._get_trigger_domain()
# Replace `sale_ok` leaf with `ecommerce_ok` if order is linked to a website
if self.website_id:
for idx, leaf in enumerate(res):
if leaf[0] != 'program_id.sale_ok':
continue
res[idx] = ('program_id.ecommerce_ok', '=', True)
break
return expression.AND([res, [('program_id.website_id', 'in', (self.website_id.id, False))]])
def _try_pending_coupon(self):
@@ -59,9 +74,12 @@ class SaleOrder(models.Model):
coupon.program_id.is_nominative or\
(rewards.reward_type == 'product' and rewards.multi_product):
continue
res = self._apply_program_reward(rewards, coupon)
if 'error' not in res:
claimed_reward_count += 1
try:
res = self._apply_program_reward(rewards, coupon)
if 'error' not in res:
claimed_reward_count += 1
except UserError:
pass
return bool(claimed_reward_count)
@@ -20,6 +20,8 @@ const CouponToasterWidget = publicWidget.Widget.extend({
this.displayNotification(Object.assign({type: 'success'}, options));
} else if (this.$el.hasClass('coupon-error-message')) {
this.displayNotification(Object.assign({type: 'danger'}, options));
} else if (this.$el.hasClass('coupon-warning-message')) {
this.displayNotification(Object.assign({type: 'warning'}, options));
}
return this._super(...arguments);
@@ -2,21 +2,6 @@
import publicWidget from 'web.public.widget';
publicWidget.registry.WebsiteSaleGiftCard = publicWidget.Widget.extend({
selector: '.oe_website_sale_gift_card',
events: {
'click .js_show_gift_card': '_onShowGiftCardClick',
},
/**
* @private
* @param {Event} ev
*/
_onShowGiftCardClick: function (ev) {
$(ev.currentTarget).hide();
this.$('.gift_card_form').removeClass('d-none');
}
});
publicWidget.registry.WebsiteSaleGiftCardCopy = publicWidget.Widget.extend({
selector: '.o_purchased_gift_card',
/**
@@ -1,13 +1,14 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale_loyalty.tests.test_program_numbers import TestSaleCouponProgramNumbers
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponNumbersCommon
from odoo.addons.website.tools import MockRequest
from odoo.addons.website_sale_loyalty.controllers.main import WebsiteSale
from odoo.tests import tagged
from odoo.tests.common import HttpCase
@tagged('-at_install', 'post_install')
class TestSaleCouponApplyPending(TestSaleCouponProgramNumbers):
class TestSaleCouponApplyPending(HttpCase, TestSaleCouponNumbersCommon):
def setUp(self):
super().setUp()
@@ -1,13 +1,13 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.addons.sale_loyalty.tests.test_program_numbers import TestSaleCouponProgramNumbers
from odoo.addons.sale_loyalty.tests.common import TestSaleCouponNumbersCommon
from odoo.addons.website.tools import MockRequest
from odoo.exceptions import UserError
from odoo.tests import tagged
@tagged('-at_install', 'post_install')
class TestSaleCouponMultiwebsite(TestSaleCouponProgramNumbers):
class TestSaleCouponMultiwebsite(TestSaleCouponNumbersCommon):
@classmethod
def setUpClass(cls):
@@ -1,22 +1,43 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem
id="menu_promotion_type_config"
action="loyalty.loyalty_program_action"
name="Coupons &amp; Loyalty"
id="menu_discount_loyalty_type_config"
action="loyalty.loyalty_program_discount_loyalty_action"
name="Discount &amp; Loyalty"
parent="website_sale.menu_catalog"
groups="sales_team.group_sale_manager"
sequence="50"
/>
<menuitem
id="menu_gift_ewallet_type_config"
action="loyalty.loyalty_program_gift_ewallet_action"
name="Gift cards &amp; eWallet"
parent="website_sale.menu_catalog"
groups="sales_team.group_sale_manager"
sequence="51"
/>
<record id="loyalty_program_view_form_inherit_website_sale_loyalty" model="ir.ui.view">
<field name="name">loyalty.program.view.form.inherit.website.sale.loyalty</field>
<field name="model">loyalty.program</field>
<field name="inherit_id" ref="loyalty.loyalty_program_view_form"/>
<field name="inherit_id" ref="sale_loyalty.loyalty_program_view_form_inherit_sale_loyalty"/>
<field name="arch" type="xml">
<field name="currency_id" position="after">
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
</field>
<xpath expr="//label[@for='available_on']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="inside">
<span class="d-inline-block">
<field name="ecommerce_ok" class="w-auto"/>
<label for="ecommerce_ok" string="Website"/>
</span>
</xpath>
<xpath expr="//div[@id='o_loyalty_program_availabilities']" position="after">
<field name="website_id" attrs="{'invisible': [('ecommerce_ok', '=', False)]}" options="{'no_create': True}" groups="website.group_multi_website" placeholder="All websites"/>
</xpath>
</field>
</record>
@@ -25,11 +46,12 @@
<field name="model">loyalty.program</field>
<field name="inherit_id" ref="loyalty.loyalty_program_view_tree"/>
<field name="arch" type="xml">
<field name="name" position="after">
<field name="website_id" groups="website.group_multi_website"/>
<button name="action_program_share" string="Share" type="object" icon="fa-share-alt"/>
<field name="coupon_count_display" position="after">
<field name="website_id" options="{'no_create': True}" groups="website.group_multi_website"/>
</field>
<field name="company_id" position="after">
<button name="action_program_share" string="Share" type="object" icon="fa-share-alt" attrs="{ 'invisible' : [('program_type', '!=', 'promo_code')]}"/>
</field>
</field>
</record>
</odoo>
@@ -8,7 +8,7 @@
<xpath expr="//div[@id='website_sale_loyalty']" position="after">
<div class="content-group">
<div class="mt8" attrs="{'invisible': [('module_sale_loyalty', '=', False)]}">
<button name="%(loyalty.loyalty_program_action)d" icon="fa-arrow-right" type="action" string="Loyalty Programs" class="btn-link"/>
<button name="%(loyalty.loyalty_program_discount_loyalty_action)d" icon="fa-arrow-right" type="action" string="Loyalty Programs" class="btn-link"/>
</div>
</div>
</xpath>
@@ -1,18 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="pay_with_gift_card_form">
<form t-att-action="'/shop/pricelist?gift_card=1%s' % (redirect and '&amp;r=' + redirect or '')" class="mb-2" method="post" name="gift_card_code">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="input-group w-100">
<input name="promo" class="form-control" type="text" required="required" placeholder="Gift card code..."/>
<button href="#" type="submit" role="button" class="btn btn-secondary a-submit">Pay</button>
</div>
</form>
</template>
<template id="sale_coupon_result" inherit_id="website_sale.coupon_form">
<xpath expr="//form[@name='coupon_code']//input[@name='promo']" position="attributes">
<attribute name="placeholder">code...</attribute>
<attribute name="placeholder">Gift card or discount code...</attribute>
</xpath>
<xpath expr="//t[@name='code_not_available']" position="replace"/>
</template>
@@ -22,13 +12,6 @@
<tr t-if="not hide_promotions" class="oe_website_sale_gift_card">
<td colspan="3" class="text-center text-xl-end border-0">
<span class=''>
<t t-set='force_gift_card' t-value="request.params.get('gift_card_error')"/>
<t t-if="not force_gift_card">
<a href="#" class="js_show_gift_card">Use a gift card</a>
</t>
<div t-attf-class="gift_card_form #{not force_gift_card and 'd-none'}">
<t t-call="website_sale_loyalty.pay_with_gift_card_form"/>
</div>
<t t-if="request.params.get('code_not_available')">
<div class="alert alert-danger text-start mt16" role="alert">
Invalid or expired promo code.
@@ -52,25 +35,27 @@
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="reward" t-att-value="reward.id"/>
<div class="alert alert-success text-start mt16" role="alert">
<div class="row">
<div class="col-8">
<div class="d-flex flex-row">
<div class="flex-grow-1">
<strong t-esc="reward.description"/>
<div t-if="reward.program_id.portal_visible">
<t t-if="not reward.program_id.is_nominative"><span t-out="website_sale_order._get_real_points_for_coupon(coupon_reward[0])"/> <span t-out="reward.point_name"/></t>
<t t-else="">
<span>You have <t t-out="website_sale_order._get_real_points_for_coupon(coupon_reward[0])"/></span> <span t-out="reward.point_name"/>
<span t-if="reward.program_id.program_type == 'ewallet'"> in your ewallet</span>
<t t-if="reward.program_id.program_type != 'ewallet'">
<br/>
<span>Costs <t t-out="reward.required_points"/></span> <span t-out="reward.point_name"/>
</t>
</t>
</div>
</div>
<div class="col-4">
<div class="justify-content-end">
<a class="btn btn-primary a-submit" href="#" role="button">
<t t-if="reward.program_id.program_type == 'ewallet'">Pay with eWallet</t>
<t t-else="">Claim</t>
</a>
</div>
<div t-if="reward.program_id.portal_visible" class="col-12">
<t t-if="not reward.program_id.is_nominative"><span t-out="website_sale_order._get_real_points_for_coupon(coupon_reward[0])"/> <span t-out="reward.point_name"/></t>
<t t-else="">
<span>You have <t t-out="website_sale_order._get_real_points_for_coupon(coupon_reward[0])"/></span> <span t-out="reward.point_name"/>
<span t-if="reward.program_id.program_type == 'ewallet'"> in your ewallet</span>
<br/>
<span>Costs <t t-out="reward.required_points"/></span> <span t-out="reward.point_name"/>
</t>
</div>
</div>
</div>
</form>
@@ -94,7 +79,7 @@
<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">
<div t-attf-class="d-none coupon-message coupon-{{ request.params.get('coupon_error_type', '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>
@@ -126,6 +111,9 @@
<xpath expr="//t[@t-set='force_coupon']" position="replace">
<t t-set='force_coupon' t-value="website_sale_order.pricelist_id.code or request.params.get('code_not_available') or website_sale_order.get_promo_code_error(delete=False)"/>
</xpath>
<xpath expr="//a" position="replace">
<a href="#" class="show_coupon">Discount code or gift card</a>
</xpath>
</template>
<template id="cart_summary" name="Payment" inherit_id="website_sale.cart_summary">
@@ -135,23 +123,18 @@
</template>
<template id="cart_line_product_no_link" inherit_id="website_sale.cart_line_product_link">
<xpath expr="." position="replace">
<div>
<t t-if="line.is_reward_line">
<strong t-field="line.name"/>
</t>
<t t-else="">
<a t-att-href="line.product_id.website_url">
<strong t-field="line.name_short"/>
</a>
</t>
</div>
<xpath expr="//a" position="replace">
<t t-if="line.is_reward_line">
<strong t-field="line.name"/>
<t t-call="sale_loyalty.used_gift_card"/>
</t>
<t t-else="">$0</t>
</xpath>
</template>
<template id="cart_summary_inherit_website_gift_card_sale" inherit_id="website_sale.cart_summary">
<xpath expr="//td[hasclass('td-product_name')]/div/strong" position="after">
<t t-if="line.coupon_id and line.coupon_id.program_id.program_type == 'gift_card'" t-call="sale_loyalty.used_gift_card"/>
<t t-call="sale_loyalty.used_gift_card"/>
</xpath>
</template>
@@ -1,5 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import loyalty_generate_wizard
from . import sale_coupon_share
@@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class LoyaltyGenerateWizard(models.TransientModel):
_inherit = 'loyalty.generate.wizard'
website_id = fields.Many2one('website', 'Website')
def _get_coupon_values(self, partner):
res = super()._get_coupon_values(partner)
if self.website_id:
res.update(website_id=self.website_id.id)
return res
@@ -1,13 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_generate_wizard_view_form_inherit_website_sale_loyalty" model="ir.ui.view">
<field name="name">loyalty.generate.wizard.view.form.inherit.website.sale.loyalty</field>
<field name="model">loyalty.generate.wizard</field>
<field name="inherit_id" ref="loyalty.loyalty_generate_wizard_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='valid_until']" position="after">
<field name="website_id" groups="website.group_multi_website"/>
</xpath>
</field>
</record>
</odoo>
@@ -68,7 +68,7 @@ class SaleCouponShare(models.TransientModel):
def action_generate_short_link(self):
return {
'name': _('Share Coupon'),
'name': _('Share'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'coupon.share',
@@ -85,7 +85,7 @@ class SaleCouponShare(models.TransientModel):
raise UserError(_("Provide either a coupon or a program."))
return {
'name': _('Share Coupon'),
'name': _('Share') + f' {self.env["loyalty.program"]._program_items_name().get((program or coupon).program_type, "")}',
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'coupon.share',
@@ -7,20 +7,21 @@
<form string="Share Loyalty Card" edit="1" create="0">
<field name="website_id" invisible="1"/>
<field name="program_website_id" invisible="1"/>
<group attrs="{'invisible': [('website_id', '=', False)]}">
<group>
<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"/>
<field name="share_link" widget="CopyClipboardURL" no_label="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>
<field name="website_id" groups="website.group_multi_website" widget="selection"
attrs="{'invisible': [('program_website_id', '!=', False)]}"/>
<field name="redirect"/>
</group>
</group>
<footer>
<button string="Done" class="btn-primary" special="cancel" data-hotkey="z"/>