[ADD] sale_coupon: introduce coupon mechanism on sales order

Introduces 2 new concepts:

Promotion programs:
===================

Using promotion programs allows you to propose to your customers:
- Promotional offers limited in time or a limited order number.
- Public code printed in advertisements, magasines, etc.
- Specific offers based on a customers group or a product set.
- A combination of the preceeding points.

You may choose to apply the program reward immediately on the sales order or to propose the reward for a next order. In the second case a coupon will be generated at the order confirmation for the customer to use it later.

Coupon programs:
================

Using coupon programs allows you to propose to your customers:
- Free products or discounts if some conditions on products or purchased amount are met.
- Promotional offers limited in time.
- Generate coupons for a customers group.
- Specific offers based on a customers group or a product set.
- A combination of the preceeding points.

A coupon is applied by entering a valid code if the program rules are met and if the reward is already in the order lines.
This commit is contained in:
pgu-odoo
2016-12-20 14:46:00 +01:00
committed by Yannick Tivisse
commit 5260d22c4d
26 changed files with 1749 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import models
import wizard
+20
View File
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Sale Coupon",
'summary': """Allows to use discount coupons in sales orders""",
'description': """Integrate coupon mechanism in sales orders.""",
'category': 'Sales',
'version': '1.0',
'depends': ['sale'],
'data': [
'security/ir.model.access.csv',
'wizard/sale_coupon_apply_code_views.xml',
'wizard/sale_coupon_generate_views.xml',
'views/sale_order_views.xml',
'views/sale_coupon_views.xml',
'views/sale_coupon_program_views.xml',
'views/sale_config_settings_views.xml',
'data/sale_coupon_data.xml',
],
}
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="sale_coupon_created_coupon_email_template">
<div>
<h2>Congratulations!</h2>
Enter the following coupon code (<i><t t-esc="code"/></i>) during your next purchase to receive this discount: <b><t t-esc="reward_description"/></b>.
</div>
</template>
</odoo>
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import sale_config_settings
import sale_coupon
import sale_coupon_reward
import sale_coupon_rules
import sale_coupon_program
import sale_order
+10
View File
@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class SaleConfiguration(models.TransientModel):
_inherit = 'sale.config.settings'
module_sale_coupon = fields.Boolean("Manage coupons and promotional offers")
+76
View File
@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
class SaleCoupon(models.Model):
_name = 'sale.coupon'
_description = "Sales Coupon"
_rec_name = 'code'
@api.model
def _generate_code(self):
"""Generate a 20 char long pseudo-random string of digits
Used for barcode generation, UUID4 makes the chance of a collision
(unicity constraint) highly unlikely.
Using the int version is a longer string than hex but generates a more
compact barcode when using digits only (Code128C instead of Code128A).
Keep only the first 8 bytes as a 16 bytes barcode is not readable by all
barcode scanners.
"""
return str(int(uuid.uuid4().bytes[:8].encode('hex'), 16))
code = fields.Char(default=_generate_code, required=True, readonly=True)
expiration_date = fields.Date('Expiration Date', compute='_compute_expiration_date')
state = fields.Selection([
('reserved', 'Reserved'),
('new', 'Valid'),
('used', 'Consumed'),
('expired', 'Expired')
], required=True, default='new')
partner_id = fields.Many2one('res.partner', "For Customer")
program_id = fields.Many2one('sale.coupon.program', "Program")
order_id = fields.Many2one('sale.order', 'Order Reference', readonly=True,
help="The sales order from which coupon is generated")
discount_line_product_id = fields.Many2one('product.product', related='program_id.discount_line_product_id',
help='Product used in the sales order to apply the discount.')
_sql_constraints = [
('unique_coupon_code', 'unique(code)', 'The coupon code must be unique!'),
]
def _compute_expiration_date(self):
for coupon in self.filtered(lambda x: x.program_id.validity_duration > 0):
coupon.expiration_date = fields.Date.from_string(coupon.create_date) + relativedelta(days=coupon.program_id.validity_duration)
def _check_coupon_code(self, order):
message = {}
applicable_programs = order._get_applicable_programs()
amount_total = order.amount_untaxed + order.reward_amount
if self.program_id.rule_minimum_amount_tax_inclusion == 'tax_included':
amount_total += order.amount_tax
elif self.state in ('used', 'expired') or \
(self.expiration_date and self.expiration_date < order.date_order):
message = {'error': _('This coupon %s has been used or is expired.') % (self.code)}
elif self.state == 'reserved':
message = {'error': _('This coupon %s exists but the origin sales order is not validated yet.') % (self.code)}
elif self.program_id._compute_program_amount('rule_minimum_amount', order.currency_id) > amount_total:
message = {'error': _('A minimum of %s %s should be purchased to get the reward') % (self.program_id.rule_minimum_amount, self.program_id.currency_id.name)}
elif not self.program_id.active:
message = {'error': _('The coupon program for %s is in draft or closed state') % (self.code)}
elif self.partner_id and self.partner_id != order.partner_id:
message = {'error': _('Invalid partner.')}
elif self.program_id in order.applied_coupon_ids.mapped('program_id'):
message = {'error': _('A Coupon is already applied for the same reward')}
elif self.program_id._is_global_discount_program() and order._is_global_discount_already_applied():
message = {'error': _('Global discounts are not cumulable.')}
elif self.program_id.reward_type == 'product' and not order._is_reward_in_order_lines(self.program_id):
message = {'error': _('The reward products should be in the sales order lines to apply the discount.')}
else:
if self.program_id not in applicable_programs and self.program_id.promo_applicability == 'on_current_order':
message = {'error': _('At least one of the required conditions is not met to get the reward!')}
return message
+229
View File
@@ -0,0 +1,229 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class SaleCouponProgram(models.Model):
_name = 'sale.coupon.program'
_description = "Sales Coupon Program"
_inherits = {'sale.coupon.rule': 'rule_id', 'sale.coupon.reward': 'reward_id'}
_order = "sequence"
name = fields.Char(required=True, translate=True)
active = fields.Boolean('Active', default=True, help="A program is available for the customers when active")
rule_id = fields.Many2one('sale.coupon.rule', string="Applicability", ondelete='restrict', required=True)
reward_id = fields.Many2one('sale.coupon.reward', string="Reward", ondelete='restrict', required=True, copy=False)
sequence = fields.Integer(copy=False,
help="Coupon program will be applied based on given sequence if multiple programs are " +
"defined on same condition(For minimum amount)")
maximum_use_number = fields.Integer(help="Maximum number of sales orders in which reward can be provided")
program_type = fields.Selection([
('promotion_program', 'Promotional Program'),
('coupon_program', 'Coupon Program'),
],
help="""A promotional program can be either a limited promotional offer without code (applied automatically)
or with a code (displayed on a magazine for example) that may generate a discount on the current
order or create a coupon for a next order.
A coupon program generates coupons with a code that can be used to generate a discount on the current
order or create a coupon for a next order.""")
promo_code_usage = fields.Selection([
('no_code_needed', 'Automatically Applied'),
('code_needed', 'Use a code')],
help="Automatically Applied - No code is required, if the program rules are met, the reward is applied (Except the global discount or the free shipping rewards which are not cumulable)\n" +
"Use a code - If the program rules are met, a valid code is mandatory for the reward to be applied\n")
promo_code = fields.Char('Promotion Code', copy=False,
help="A promotion code is a code that is associated with a marketing discount. For example, a retailer might tell frequent customers to enter the promotion code 'THX001' to receive a 10%% discount on their whole order.")
promo_applicability = fields.Selection([
('on_current_order', 'Apply On Current Order'),
('on_next_order', 'Apply On Next Order')],
default='on_current_order', string="Applicability")
coupon_ids = fields.One2many('sale.coupon', 'program_id', string="Generated Coupons", copy=False)
coupon_count = fields.Integer(compute='_compute_coupon_count')
order_count = fields.Integer(compute='_compute_order_count')
company_id = fields.Many2one('res.company', string="Company", default=lambda self: self.env.user.company_id)
currency_id = fields.Many2one(string="Currency", related='company_id.currency_id')
validity_duration = fields.Integer(default=1,
help="Validity duration for a coupon after its generation")
_sql_constraints = [
('unique_promo_code', 'unique(promo_code)', 'The program code must be unique!'),
]
def _compute_order_count(self):
product_data = self.env['sale.order.line'].read_group([('product_id', 'in', self.mapped('discount_line_product_id').ids)], ['product_id'], ['product_id'])
mapped_data = dict([(m['product_id'][0], m['product_id_count']) for m in product_data])
for program in self:
program.order_count = mapped_data.get(program.discount_line_product_id.id, 0)
def _compute_coupon_count(self):
coupon_data = self.env['sale.coupon'].read_group([('program_id', 'in', self.ids)], ['program_id'], ['program_id'])
mapped_data = dict([(m['program_id'][0], m['program_id_count']) for m in coupon_data])
for program in self:
program.coupon_count = mapped_data.get(program.id, 0)
@api.model
def create(self, vals):
program = super(SaleCouponProgram, self).create(vals)
if 'discount_line_product_id' not in vals:
discount_line_product_id = self.env['product.product'].create({
'name': program.reward_id.name_get()[0][1],
'type': 'service',
'taxes_id': False,
'supplier_taxes_id': False,
'sale_ok': False,
'purchase_ok': False
})
program.write({'discount_line_product_id': discount_line_product_id.id})
return program
def write(self, vals):
res = super(SaleCouponProgram, self).write(vals)
reward_fields = [
'reward_type', 'reward_product_id', 'discount_type', 'discount_percentage',
'discount_apply_on', 'discount_specific_product_id', 'discount_fixed_amount'
]
if any(field in reward_fields for field in vals):
self.mapped('discount_line_product_id').write({'name': self[0].reward_id.name_get()[0][1]})
return res
def unlink(self):
for program in self.filtered(lambda x: x.active):
raise UserError(_('You can not delete a program in active state'))
return super(SaleCouponProgram, self).unlink()
def toggle_active(self):
super(SaleCouponProgram, self).toggle_active()
if not self.active:
coupons = self.filtered(lambda p: p.promo_code_usage == 'code_needed').mapped('coupon_ids')
coupons.filtered(lambda x: x.state != 'used').write({'state': 'expired'})
self.mapped('discount_line_product_id').write({'active': False})
def action_view_sales_orders(self):
self.ensure_one()
orders = self.env['sale.order.line'].search([('product_id', '=', self.discount_line_product_id.id)]).mapped('order_id')
return {
'name': _('Sales Orders'),
'view_mode': 'tree,form',
'res_model': 'sale.order',
'type': 'ir.actions.act_window',
'domain': [('id', 'in', orders.ids)]
}
def _is_global_discount_program(self):
self.ensure_one()
return self.promo_applicability == 'on_current_order' and \
self.reward_type == 'discount' and \
self.discount_type == 'percentage' and \
self.discount_apply_on == 'on_order'
def _check_promo_code(self, order, coupon_code):
message = {}
applicable_programs = order._get_applicable_programs()
amount_total = order.amount_untaxed + order.reward_amount
if self.rule_minimum_amount_tax_inclusion == 'tax_included':
amount_total += order.amount_tax
if self.maximum_use_number != 0 and self.order_count >= self.maximum_use_number:
message = {'error': _('Promo code %s has been expired.') % (coupon_code)}
elif self._compute_program_amount('rule_minimum_amount', order.currency_id) > amount_total:
message = {'error': _('A minimum of %s %s should be purchased to get the reward') % (self.rule_minimum_amount, self.currency_id.name)}
elif self.promo_code and self.promo_code == order.promo_code:
message = {'error': _('The promo code is already applied on this order')}
elif not self.promo_code and self in order.no_code_promo_program_ids:
message = {'error': _('The promotional offer is already applied on this order')}
elif not self.active:
message = {'error': _('Promo code is invalid')}
elif self.rule_date_from and self.rule_date_from > order.date_order or self.rule_date_to and order.date_order > self.rule_date_to:
message = {'error': _('Promo code is expired')}
elif order.promo_code:
message = {'error': _('Promotionals codes are not cumulable.')}
elif self._is_global_discount_program() and order.applied_coupon_ids.mapped('program_id').filtered(lambda program: program._is_global_discount_program()):
message = {'error': _('Global discounts are not cumulable.')}
elif self.promo_applicability == 'on_current_order' and self.reward_type == 'product' and not order._is_reward_in_order_lines(self):
message = {'error': _('The reward products should be in the sales order lines to apply the discount.')}
else:
if self not in applicable_programs:
message = {'error': _('At least one of the required conditions is not met to get the reward!')}
return message
def _compute_program_amount(self, field, currency_to):
self.ensure_one()
return self.currency_id.compute(getattr(self, field), currency_to)
@api.model
def _filter_on_mimimum_amount(self, order):
# To get actual total amount of SO without any reward or discount
discounted_amount = sum(order.order_line.filtered(lambda line: line.is_reward_line).mapped('price_total'))
untaxed_amount = order.amount_untaxed - discounted_amount
return self.filtered(lambda program:
program.rule_minimum_amount_tax_inclusion == 'tax_included' and
program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount + order.amount_tax or
program.rule_minimum_amount_tax_inclusion == 'tax_excluded' and
program._compute_program_amount('rule_minimum_amount', order.currency_id) <= untaxed_amount)
@api.model
def _filter_on_validity_dates(self, order):
return self.filtered(lambda program:
program.rule_date_from and program.rule_date_to and
program.rule_date_from <= order.date_order and program.rule_date_to >= order.date_order or
not program.rule_date_from or not program.rule_date_to)
@api.model
def _filter_promo_programs_with_code(self, order):
'''Filter Promo program with code with a different promo_code if a promo_code is already ordered'''
return self.filtered(lambda program: program.promo_code_usage == 'code_needed' and program.promo_code != order.promo_code)
def _filter_unexpired_programs(self, order):
return self.filtered(lambda program: program.maximum_use_number == 0 or program.order_count <= program.maximum_use_number)
def _filter_programs_on_partners(self, order):
return self.filtered(lambda program: program.rule_partner_ids and order.partner_id.id in program.rule_partner_ids.ids or not program.rule_partner_ids)
def _filter_programs_on_products(self, order):
"""
To get valid programs according to product list.
i.e Buy 1 imac + get 1 ipad mini free then check 1 imac is on cart or not
or Buy 1 coke + get 1 coke free then check 2 cokes are on cart or not
"""
order_lines = order.order_line - order._get_reward_lines()
products_qties = dict.fromkeys(order_lines.mapped('product_id').ids, 0)
for line in order_lines:
products_qties[line.product_id.id] += line.product_uom_qty
valid_programs = self.filtered(lambda program: not program.rule_product_ids)
for program in self - valid_programs:
ordered_rule_products_qty = sum(map(lambda product_id, qty: product_id in program.rule_product_ids.ids and qty, products_qties, products_qties.values()))
# Avoid program if 1 ordered foo on a program '1 foo, 1 free foo'
if program.reward_product_id in program.rule_product_ids and program.reward_type == 'product':
line = order.order_line.filtered(lambda line: line.product_id == program.reward_product_id)
ordered_rule_products_qty -= program.reward_product_quantity
# needed_quantity = program.rule_min_quantity if self.
if ordered_rule_products_qty >= program.rule_min_quantity:
valid_programs |= program
return valid_programs
def _filter_not_ordered_reward_programs(self, order):
"""
Returns the programs when the reward is actually in the order lines
"""
programs = self.env['sale.coupon.program']
for program in self:
if program.reward_type == 'product' and \
not order.order_line.filtered(lambda line: line.product_id == program.reward_product_id):
continue
elif program.reward_type == 'discount' and program.discount_apply_on == 'specific_product' and \
not order.order_line.filtered(lambda line: line.product_id == program.discount_specific_product_id):
continue
programs |= program
return programs
@api.model
def _filter_programs_from_common_rules(self, order):
programs = self._filter_on_mimimum_amount(order)
programs = programs and programs._filter_on_validity_dates(order)
programs = programs and programs._filter_unexpired_programs(order)
programs = programs and programs._filter_programs_on_partners(order)
programs = programs and programs._filter_programs_on_products(order)
programs = programs and programs._filter_not_ordered_reward_programs(order)
return programs
+77
View File
@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class SaleCouponReward(models.Model):
_name = 'sale.coupon.reward'
_description = "Sales Coupon Reward"
_rec_name = 'reward_description'
reward_description = fields.Char('Reward Description')
reward_type = fields.Selection([
('discount', 'Discount'),
('product', 'Free Product'),
], string='Reward', default='discount',
help="Discount - Reward will be provided as discount.\n" +
"Free Product - Free product will be provide as reward \n" +
"Free Shipping - Free shipping will be provided as reward (Need delivery module)")
# Product Reward
reward_product_id = fields.Many2one('product.product', string="Free Product",
help="Reward Product")
reward_product_quantity = fields.Integer(string="Quantity", default=1, help="Reward product quantity")
# Discount Reward
discount_type = fields.Selection([
('percentage', 'Percentage'),
('fixed_amount', 'Fixed Amount')], default="percentage",
help="Percentage - Entered percentage discount will be provided\n" +
"Amount - Entered fixed amount discount will be provided")
discount_percentage = fields.Float(string="Discount", default=1,
help='The discount in percentage, between 1 to 100')
discount_apply_on = fields.Selection([
('on_order', 'On Order'),
('cheapest_product', 'On Cheapest Product'),
('specific_product', 'On Specific Product')], default="on_order",
help="On Order - Discount on whole order\n" +
"Cheapest product - Discount on cheapest product of the order\n" +
"Specific product - Discount on selected specific product")
discount_specific_product_id = fields.Many2one('product.product', string="Product",
help="Product that will be discounted if the discount is applied on a specific product")
discount_max_amount = fields.Float(default=0,
help="Maximum amount of discount that can be provided")
discount_fixed_amount = fields.Float(string="Fixed Amount", help='The discount in fixed amount')
reward_product_uom_id = fields.Many2one(related='reward_product_id.product_tmpl_id.uom_id', string='Unit of Measure', readonly=True)
discount_line_product_id = fields.Many2one('product.product', string='Reward Line Product', copy=False,
help="Product used in the sales order to apply the discount. Each coupon program has its own reward product for reporting purpose")
@api.constrains('discount_percentage')
def _check_discount_percentage(self):
if self.filtered(lambda reward: reward.discount_type == 'percentage' and (reward.discount_percentage < 0 or reward.discount_percentage > 100)):
raise ValidationError(_('Discount percentage should be between 1-100'))
@api.multi
def name_get(self):
"""
Returns a complete description of the reward
"""
result = []
for reward in self:
reward_string = ""
if reward.reward_type == 'product':
reward_string = _("Free Product - %s" % (reward.reward_product_id.name))
elif reward.reward_type == 'discount':
if reward.discount_type == 'percentage':
reward_percentage = str(reward.discount_percentage)
if reward.discount_apply_on == 'on_order':
reward_string = _("%s %% discount on total amount" % (reward_percentage))
elif reward.discount_apply_on == 'specific_product':
reward_string = _("%s %% discount on %s" % (reward_percentage, reward.discount_specific_product_id.name))
elif reward.discount_apply_on == 'cheapest_product':
reward_string = _("%s %% discount on cheapest product" % (reward_percentage))
elif reward.discount_type == 'fixed_amount':
program = self.env['sale.coupon.program'].search([('reward_id', '=', reward.id)])
reward_string = _("%s %s discount on total amount" % (str(reward.discount_fixed_amount), program.currency_id.name))
result.append((reward.id, reward_string))
return result
+48
View File
@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.tools.safe_eval import safe_eval
class SaleCouponRule(models.Model):
_name = 'sale.coupon.rule'
_description = "Sales Coupon Rule"
rule_date_from = fields.Datetime(string="Start Date", help="Coupon program start date")
rule_date_to = fields.Datetime(string="End Date", help="Coupon program end date")
rule_partners_domain = fields.Char(string="Based on Customers", help="Coupon program will work for selected customers only")
rule_partner_ids = fields.Many2many('res.partner', 'rule_partner_rel', 'rule_id', 'partner_id',
string="Related Partners", compute='_compute_rule_partner_ids', store=True)
rule_products_domain = fields.Char(string="Based on Products", help="On Purchase of selected product, reward will be given")
rule_product_ids = fields.Many2many('product.product', 'rule_product_rel', 'rule_id', 'product_id',
string="Related Products", compute='_compute_rule_product_ids', store=True)
rule_min_quantity = fields.Integer(string="Minimum Quantity", default=1,
help="Minimum required product quantity to get the reward")
rule_minimum_amount = fields.Float(default=0.0, help="Minimum required amount to get the reward")
rule_minimum_amount_tax_inclusion = fields.Selection([
('tax_included', 'Tax Included'),
('tax_excluded', 'Tax Excluded')], default="tax_excluded")
@api.constrains('rule_date_to', 'rule_date_from')
def _check_rule_date_from(self):
if self.filtered(lambda applicability: applicability.rule_date_to < applicability.rule_date_from):
raise ValidationError(_('The start date must be before the end date'))
@api.constrains('rule_minimum_amount')
def _check_rule_minimum_amount(self):
if self.filtered(lambda applicability: applicability.rule_minimum_amount < 0):
raise ValidationError(_('Minimum purchased amount should be greater than 0'))
@api.depends('rule_partners_domain')
def _compute_rule_partner_ids(self):
for applicability in self.filtered(lambda x: x.rule_partners_domain):
domain = safe_eval(applicability.rule_partners_domain)
applicability.rule_partner_ids = domain and self.env['res.partner'].search(domain)
@api.depends('rule_products_domain')
def _compute_rule_product_ids(self):
for applicability in self.filtered(lambda x: x.rule_products_domain):
domain = safe_eval(applicability.rule_products_domain)
applicability.rule_product_ids = domain and self.env['product.product'].search(domain)
+288
View File
@@ -0,0 +1,288 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class SaleOrder(models.Model):
_inherit = "sale.order"
applied_coupon_ids = fields.One2many('sale.coupon', 'order_id', string="Applied Coupons", copy=False)
generated_coupon_ids = fields.One2many('sale.coupon', 'order_id', string="Offered Coupons", copy=False)
reward_amount = fields.Float(compute='_compute_reward_total')
no_code_promo_program_ids = fields.Many2many('sale.coupon.program', string="Applied Immediate Promo Programs",
domain=[('promo_code_usage', '=', 'no_code_needed')], copy=False)
code_promo_program_id = fields.Many2one('sale.coupon.program', string="Applied Promo Program",
domain=[('promo_code_usage', '=', 'code_needed')], copy=False)
promo_code = fields.Char(related='code_promo_program_id.promo_code', help="Applied program code")
@api.depends('order_line')
def _compute_reward_total(self):
for order in self:
order.reward_amount = sum([line.price_subtotal for line in order.order_line.filtered(lambda line: line.is_reward_line)])
def copy(self, default=None):
order = super(SaleOrder, self).copy(dict(default or {}, order_line=False))
for line in self.order_line.filtered(lambda line: not line.is_reward_line):
line.copy({'order_id': order.id})
return order
@api.model
def create(self, vals):
res = super(SaleOrder, self).create(vals)
res._create_new_no_code_promo_reward_lines()
res._update_existing_reward_lines()
return res
def write(self, vals):
res = super(SaleOrder, self).write(vals)
if 'order_line' in vals and self.env.context.get('sale_coupon_no_loop', True):
for order in self.with_context(sale_coupon_no_loop=False):
order._remove_invalid_reward_lines()
order._create_new_no_code_promo_reward_lines()
order._update_existing_reward_lines()
return res
def action_confirm(self):
res = super(SaleOrder, self).action_confirm()
self.generated_coupon_ids.write({'state': 'new'})
return res
def action_cancel(self):
res = super(SaleOrder, self).action_cancel()
self.generated_coupon_ids.write({'state': 'expired'})
self.applied_coupon_ids.write({'state': 'new'})
return res
def _get_reward_lines(self):
self.ensure_one()
reward_products = self.applied_coupon_ids.mapped('program_id').mapped('discount_line_product_id')
return self.order_line.filtered(lambda line: line.product_id in reward_products)
def _is_reward_in_order_lines(self, program):
self.ensure_one()
return self.order_line.filtered(lambda line:
line.product_id == program.reward_product_id and
line.product_uom_qty >= program.reward_product_quantity)
def _is_global_discount_already_applied(self):
applied_programs = self.no_code_promo_program_ids + \
self.code_promo_program_id + \
self.applied_coupon_ids.mapped('program_id')
return applied_programs.filtered(lambda program: program._is_global_discount_program())
def _get_reward_values_product(self, program):
price_unit = self.order_line.filtered(lambda line: program.reward_product_id == line.product_id)[0].price_unit
order_lines = (self.order_line - self._get_reward_lines()).filtered(lambda x: x.product_id in program.rule_product_ids)
max_product_qty = sum(order_lines.mapped('product_uom_qty')) or 1
line = self.order_line.filtered(lambda line: line.product_id == program.reward_product_id)[0]
# Remove needed quantity from reward quantity if same reward and rule product
if program.reward_product_id in program.rule_product_ids:
reward_product_qty = line.product_uom_qty // (program.rule_min_quantity + program.reward_product_quantity)
else:
reward_product_qty = line.product_uom_qty
reward_qty = min(int(int(max_product_qty / program.rule_min_quantity) * program.reward_product_quantity), reward_product_qty)
vals = {
'product_id': program.discount_line_product_id.id,
'price_unit': - price_unit,
'product_uom_qty': reward_qty,
'is_reward_line': True
}
if not program.reward_product_id:
vals.update({
'name': "Discount: %s" % (program.name),
'product_uom': program.discount_line_product_id.uom_id.id,
})
else:
vals.update({
'name': "Free Product - " + program.reward_product_id.name,
'product_uom': program.reward_product_id.uom_id.id,
})
return vals
def _get_order_lines_untaxed_amount(self):
""" Returns the untaxed sale order total amount without the rewards amount"""
return sum([x.price_subtotal for x in self.order_line.filtered(lambda x: not x.is_reward_line)])
def _get_reward_values_discount_fixed_amount(self, program):
total_amount = sum(self.order_line.filtered(lambda line: not line.is_reward_line).mapped('price_total'))
fixed_amount = program._compute_program_amount('discount_fixed_amount', self.currency_id)
if total_amount < fixed_amount:
return total_amount
else:
return fixed_amount
def _get_lines_unit_prices(self):
return [x.price_unit for x in self.order_line.filtered(lambda x: not x.is_reward_line)]
def _get_reward_values_discount_percentage(self, program):
discount_amount = 0
amount_untaxed = self._get_order_lines_untaxed_amount()
max_amount = program._compute_program_amount('discount_max_amount', self.currency_id)
if program.discount_apply_on == 'on_order':
discount_amount = amount_untaxed * (program.discount_percentage / 100)
if program.discount_apply_on == 'cheapest_product':
unit_prices = self._get_lines_unit_prices()
discount_amount = (min(unit_prices) * (program.discount_percentage) / 100)
if program.discount_apply_on == 'specific_product':
discount_amount = sum([(x.price_unit * x.product_uom_qty) * (program.discount_percentage / 100) for x in self.order_line.filtered(
lambda x: x.product_id == program.discount_specific_product_id)])
if program.discount_max_amount and discount_amount > max_amount:
discount_amount = max_amount
return discount_amount
def _get_reward_values_discount(self, program):
if program.discount_type == 'percentage':
discount_amount = self._get_reward_values_discount_percentage(program)
elif program.discount_type == 'fixed_amount':
discount_amount = self._get_reward_values_discount_fixed_amount(program)
return {
'name': "Discount: %s" % (program.name),
'product_id': program.discount_line_product_id.id,
'price_unit': - discount_amount,
'product_uom_qty': 1.0,
'product_uom': program.discount_line_product_id.uom_id.id,
'is_reward_line': True
}
def _get_reward_line_values(self, program):
self.ensure_one()
if program.reward_type == 'discount':
return self._get_reward_values_discount(program)
elif program.reward_type == 'product':
return self._get_reward_values_product(program)
def _create_reward_line(self, programs):
self.ensure_one()
for program in programs:
self.write({'order_line': [(0, False, self._get_reward_line_values(program))]})
def _create_reward_coupon(self, program):
coupon = self.env['sale.coupon'].create({
'program_id': program.id,
'state': 'reserved',
'order_id': self.id,
'discount_line_product_id': program.discount_line_product_id.id
})
self.generated_coupon_ids |= coupon
subject = '%s, a coupon has been generated from your order %s' % (self.partner_id.name, self.name)
body = self.env.ref('sale_coupon.sale_coupon_created_coupon_email_template').render({
'code': coupon.code,
'reward_description': coupon.program_id.discount_line_product_id.name
})
self.message_post(body=body, subject=subject, subtype='mail.mt_comment', partner_ids=[(4, self.partner_id.id)])
return coupon
def _get_applicable_programs(self):
"""
This method is used to return the valid applicable programs on given order.
param: order - The sale order for which method will get applicable programs.
"""
self.ensure_one()
programs = self.env['sale.coupon.program'].search([
('active', '=', True)
])._filter_programs_from_common_rules(self)
if self.promo_code:
programs._filter_promo_programs_with_code(self)
return programs
def _get_applicable_no_code_promo_program(self):
self.ensure_one()
programs = self.env['sale.coupon.program'].search([
('active', '=', True),
('promo_code_usage', '=', 'no_code_needed'),
# ('promo_applicability', '=', 'on_current_order')
])._filter_programs_from_common_rules(self)
return programs.sorted('sequence')
def _create_new_no_code_promo_reward_lines(self):
'''Apply new programs that are applicable'''
self.ensure_one()
order = self
programs = order._get_applicable_no_code_promo_program()
for program in programs:
error_status = program._check_promo_code(order, False)
if not error_status.get('error') and program.promo_applicability == 'on_next_order':
order._create_reward_coupon(program)
order.no_code_promo_program_ids |= program
elif not error_status.get('error') and program.discount_line_product_id.id not in self.order_line.mapped('product_id').ids:
self.write({'order_line': [(0, False, order._get_reward_line_values(program))]})
order.no_code_promo_program_ids |= program
def _update_existing_reward_lines(self):
'''Update values for already applied rewards'''
self.ensure_one()
order = self
applied_programs = order.no_code_promo_program_ids + \
order.applied_coupon_ids.mapped('program_id') + \
order.code_promo_program_id
for program in applied_programs:
values = order._get_reward_line_values(program)
line_id = order.order_line.filtered(lambda line: line.product_id == program.discount_line_product_id).id
# Remove reward line if price or qty equal to 0
if values['product_uom_qty'] and values['price_unit']:
order.write({'order_line': [(1, line_id, values)]})
else:
order.write({'order_line': [(2, line_id, False)]})
def _remove_invalid_reward_lines(self):
'''Unlink reward order lines that are not applicable anymore'''
invalid_lines = self.env['sale.order.line']
for order in self:
new_applicable_programs = order._get_applicable_no_code_promo_program() + order._get_applicable_programs()
old_applicable_programs = order.no_code_promo_program_ids + order.applied_coupon_ids.mapped('program_id') + order.code_promo_program_id
programs_to_remove = old_applicable_programs - new_applicable_programs
products_to_remove = (programs_to_remove).mapped('discount_line_product_id')
self.generated_coupon_ids.filtered(lambda coupon: coupon.program_id.discount_line_product_id.id in products_to_remove.ids).write({'state': 'expired'})
order.no_code_promo_program_ids -= programs_to_remove
order.code_promo_program_id -= programs_to_remove
order.applied_coupon_ids -= order.applied_coupon_ids.filtered(lambda coupon: coupon.program_id in programs_to_remove)
invalid_lines |= order.order_line.filtered(lambda line: line.product_id.id in products_to_remove.ids)
order.write({'order_line': [(2, line.id, False) for line in invalid_lines]})
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
is_reward_line = fields.Boolean('Is a program reward line')
@api.model
def create(self, vals):
"""
Override the sale order line creation to transform the line creation into a ORM request
This horrible hack is due to the fact that with the coupons mechanism, a line creation may
lead to the creation, the modification or the deletion of another line. If we override the
create, write and unlink methods on the sale.order.line model, we may have issues like
cache mismatch on computed fields and so on. Due to this reason, we override the write
method on the sale.order and do the actions according to the situation. A write is done on
the sales order if we create, write or unlink a sales order line with an ORM command. To avoid
having places where creating a sales order doens't activate the coupon mechanism, (It is the
case in website_sale or website_quote for example) we do this hack here where we create the line
on the order with an ORM command and return the newly created order line. I hope that you'll
find in your heart enough faith to forgive me.
PS: RCO told me it was ok
"""
order = self.env['sale.order'].browse(vals['order_id'])
if self.env.context.get('sale_coupon_no_loop', False):
order.with_context(sale_coupon_no_loop=True).write({'order_line': [(0, False, vals)]})
return order.order_line.sorted('creation_date', reverse=True)[0]
return super(SaleOrderLine, self).create(vals)
def unlink(self):
# Reactivate coupons related to unlinked reward line
for line in self.filtered(lambda line: line.is_reward_line):
coupons_to_reactivate = line.order_id.applied_coupon_ids.filtered(
lambda coupon: coupon.program_id.discount_line_product_id == line.product_id
)
coupons_to_reactivate.write({'state': 'new'})
line.order_id.applied_coupon_ids -= coupons_to_reactivate
# Remove the program from the order in the case it's still valid
related_program = self.env['sale.coupon.program'].search([('discount_line_product_id', '=', self.product_id.id)])
if related_program:
self.order_id.no_code_promo_program_ids -= related_program
self.order_id.code_promo_program_id -= related_program
res = super(SaleOrderLine, self.with_context(sale_coupon_no_loop=False)).unlink()
return res
+9
View File
@@ -0,0 +1,9 @@
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
access_program_salesman,salesman,model_sale_coupon_program,sales_team.group_sale_salesman,1,0,0,0
access_program_manager,program manager,model_sale_coupon_program,sales_team.group_sale_manager,1,1,1,1
access_applicability_salesman,salesman,model_sale_coupon_rule,sales_team.group_sale_salesman,1,0,0,0
access_applicability_manager,program manager,model_sale_coupon_rule,sales_team.group_sale_manager,1,1,1,0
access_coupon_salesman,salesman,model_sale_coupon,sales_team.group_sale_salesman,1,0,0,0
access_coupon_manager,program manager,model_sale_coupon,sales_team.group_sale_manager,1,1,1,0
access_reward_salesman,salesman,model_sale_coupon_reward,sales_team.group_sale_salesman,1,0,0,0
access_reward_manager,program manager,model_sale_coupon_reward,sales_team.group_sale_manager,1,1,1,0
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
2 access_program_salesman salesman model_sale_coupon_program sales_team.group_sale_salesman 1 0 0 0
3 access_program_manager program manager model_sale_coupon_program sales_team.group_sale_manager 1 1 1 1
4 access_applicability_salesman salesman model_sale_coupon_rule sales_team.group_sale_salesman 1 0 0 0
5 access_applicability_manager program manager model_sale_coupon_rule sales_team.group_sale_manager 1 1 1 0
6 access_coupon_salesman salesman model_sale_coupon sales_team.group_sale_salesman 1 0 0 0
7 access_coupon_manager program manager model_sale_coupon sales_team.group_sale_manager 1 1 1 0
8 access_reward_salesman salesman model_sale_coupon_reward sales_team.group_sale_salesman 1 0 0 0
9 access_reward_manager program manager model_sale_coupon_reward sales_team.group_sale_manager 1 1 1 0
+7
View File
@@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import test_program_without_code_operations
import test_program_with_code_operations
import test_program_rules
import test_program_rewards
+53
View File
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from openerp.tests import common
class TestSaleCouponCommon(common.TransactionCase):
def setUp(self):
super(TestSaleCouponCommon, self).setUp()
# create partner for sale order.
self.steve = self.env['res.partner'].create({
'name': 'Steve Bucknor',
'email': 'steve.bucknor@example.com',
})
self.empty_order = self.env['sale.order'].create({
'partner_id': self.steve.id
})
self.uom_unit = self.env.ref('product.product_uom_unit')
#products
self.product_A = self.env['product.product'].create({
'name': 'Product A',
'list_price': 100,
})
self.product_B = self.env['product.product'].create({
'name': 'Product B',
'list_price': 5,
})
# Immediate Program By A + B: get B free
# No Conditions
self.immediate_promotion_program = self.env['sale.coupon.program'].create({
'name': 'Buy A + 1 B, 1 B are free',
'promo_code_usage': 'no_code_needed',
'reward_type': 'product',
'reward_product_id': self.product_B.id,
'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id),
'active': True,
})
self.code_promotion_program = self.env['sale.coupon.program'].create({
'name': 'Buy 1 A + Enter code, 1 A is free',
'promo_code_usage': 'code_needed',
'reward_type': 'product',
'reward_product_id': self.product_A.id,
'rule_products_domain': "[('id', 'in', [%s])]" % (self.product_A.id),
'active': True,
})
+62
View File
@@ -0,0 +1,62 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from openerp.addons.sale_coupon.tests.common import TestSaleCouponCommon
class TestSaleCouponProgramRules(TestSaleCouponCommon):
# Test all the kind of reward a customer can receive.
# The product reward is already tested in the basic operations test
def test_program_rewards(self):
# Test case: Based on the partners domain
self.immediate_promotion_program.write({'reward_type': "discount"})
order = self.empty_order
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
}),
(0, False, {
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as the partner is correct, the discount is not created")
def test_program_rewards_discount(self):
# Test case 1: 1% discount on total order
program = self.env['sale.coupon.program'].create({
'name': 'Buy at least 1000 EUR, ',
'rule_minimum_amount': 1000.0,
'promo_code_usage': 'no_code_needed',
'reward_type': 'discount',
'discount_type': 'percentage',
'discount_percentage': 1.0,
'discount_apply_on': 'on_order',
'active': True,
})
order = self.env['sale.order'].create({
'partner_id': self.steve.id
})
self.env['sale.order.line'].create({
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 11.0,
'order_id': order.id,
})
self.env['sale.order.line'].create({
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
'order_id': order.id,
})
+155
View File
@@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from openerp.addons.sale_coupon.tests.common import TestSaleCouponCommon
from odoo.fields import Date
class TestProgramRules(TestSaleCouponCommon):
# Test all the validity rules to allow a customer to have a reward.
# The check based on the products is already done in the basic operations test
def test_program_rules_partner_based(self):
# Test case: Based on the partners domain
self.immediate_promotion_program.write({'rule_partners_domain': "[('id', 'in', [%s])]" % (self.steve.id)})
order = self.empty_order
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
}),
(0, False, {
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as the partner is correct, the discount is not created")
order = self.env['sale.order'].create({'partner_id': self.env.ref('base.res_partner_1').id})
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
}),
(0, False, {
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied, the discount is created")
def test_program_rules_minimum_purchased_amount(self):
# Test case: Based on the minimum purchased
self.immediate_promotion_program.write({
'rule_minimum_amount': 1000.0,
'rule_minimum_amount_tax_inclusion': 'tax_excluded'
})
order = self.empty_order
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
}),
(0, False, {
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied as the purchased amount is not enough")
order = self.env['sale.order'].create({'partner_id': self.env.ref('base.res_partner_1').id})
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
}),
(0, False, {
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as the purchased amount is enough")
def test_program_rules_validity_dates_and_uses(self):
# Test case: Based on the validity dates and the number of allowed uses
self.immediate_promotion_program.write({
'rule_date_from': Date.to_string((datetime.now() - timedelta(days=7))),
'rule_date_to': Date.to_string((datetime.now() - timedelta(days=2))),
'maximum_use_number': 1,
})
order = self.empty_order
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
}),
(0, False, {
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied we're not between the validity dates")
self.immediate_promotion_program.write({
'rule_date_from': Date.to_string((datetime.now() - timedelta(days=7))),
'rule_date_to': Date.to_string((datetime.now() + timedelta(days=2))),
})
order = self.env['sale.order'].create({'partner_id': self.env.ref('base.res_partner_1').id})
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
}),
(0, False, {
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied as we're between the validity dates")
order = self.env['sale.order'].create({'partner_id': self.env.ref('base.res_partner_1').id})
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 10.0,
}),
(0, False, {
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 2, "The promo offert shouldn't have been applied as the number of uses is exceeded")
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from openerp.addons.sale_coupon.tests.common import TestSaleCouponCommon
from odoo.exceptions import UserError
class TestProgramWithCodeOperations(TestSaleCouponCommon):
# Test the basic operation (apply_coupon) on an coupon program on which we should
# apply the reward when the code is correct or remove the reward automatically when the reward is
# not valid anymore.
def test_program_basic_operation_coupon_code(self):
# Test case: Generate a coupon for my customer, and add a reward then remove it automatically
self.code_promotion_program.reward_type = 'discount'
self.env['sale.coupon.generate'].with_context(active_id=self.code_promotion_program.id).create({
'generation_type': 'nbr_customer',
'partners_domain': "[('id', 'in', [%s])]" % (self.steve.id),
}).generate_coupon()
# Test the valid code on a wrong sales order
wrong_partner_order = self.env['sale.order'].create({
'partner_id': self.env.ref('base.res_partner_1').id,
})
with self.assertRaises(UserError):
self.env['sale.coupon.apply.code'].with_context(active_id=wrong_partner_order.id).create({
'coupon_code': self.code_promotion_program.coupon_ids.code
}).process_coupon()
# Test now on a valid sales order
order = self.empty_order
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
'coupon_code': self.code_promotion_program.coupon_ids.code
}).process_coupon()
self.assertEqual(len(order.order_line.ids), 2)
# Remove the product A from the sale order
order.write({'order_line': [(2, order.order_line[0].id, False)]})
self.assertEqual(len(order.order_line.ids), 0)
def test_on_next_order_reward_promo_program(self):
# Test case: Apply a program that generates a coupon, which is used afterward
self.immediate_promotion_program.write({
'promo_applicability': 'on_next_order',
'promo_code_usage': 'code_needed',
'promo_code': 'bleurk',
})
order = self.empty_order
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
'coupon_code': 'bleurk'
}).process_coupon()
# Ensure that the coupon is correctly generated
self.assertEqual(len(self.immediate_promotion_program.coupon_ids.ids), 1)
generated_coupon = self.immediate_promotion_program.coupon_ids[0]
order = self.env['sale.order'].create({
'partner_id': self.steve.id
})
order.write({'order_line': [
(0, False, {
'product_id': self.product_B.id,
'name': '1 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.env['sale.coupon.apply.code'].with_context(active_id=order.id).create({
'coupon_code': generated_coupon.code
})
# Ensure that the generated coupon is valid
self.assertEqual(len(order.order_line.ids), 2)
@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from openerp.addons.sale_coupon.tests.common import TestSaleCouponCommon
class TestProgramWithoutCodeOperations(TestSaleCouponCommon):
# Test some basic operation (create, write, unlink) on an immediate coupon program on which we should
# apply or remove the reward automatically, as there's no program code.
def test_immediate_program_basic_operation(self):
# 2 products A are needed
self.immediate_promotion_program.write({'rule_min_quantity': 2.0})
order = self.empty_order
# Test case 1 (1 A): Assert that no reward is given, as the product B is missing
order.write({'order_line': [
(0, False, {
'product_id': self.product_A.id,
'name': '1 Product A',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 1, "The promo offer shouldn't have been applied as the product B isn't in the order")
# Test case 2 (1 A 1 B): Assert that no reward is given, as the product B is not present in the correct quantity
order.write({'order_line': [
(0, False, {
'product_id': self.product_B.id,
'name': '2 Product B',
'product_uom': self.uom_unit.id,
'product_uom_qty': 1.0,
})
]})
self.assertEqual(len(order.order_line.ids), 2, "The promo offer shouldn't have been applied as 2 product A aren't in the order")
# Test case 3 (2 A 1 B): Assert that the reward is given as the product B is now in the order
order.write({'order_line': [(1, order.order_line[0].id, {'product_uom_qty': 2.0})]})
self.assertEqual(len(order.order_line.ids), 3, "The promo offert should have been applied, the discount is not created")
# Test case 4 (1 A 1 B): Assert that the reward is removed as we don't buy 2 products B anymore
order.write({'order_line': [(1, order.order_line[0].id, {'product_uom_qty': 1.0})]})
self.assertEqual(len(order.order_line.ids), 2, "The promo reward should have been removed as the rules are not matched anymore")
self.assertEqual(order.order_line[0].product_id.id, self.product_A.id, "The wrong line has been removed")
self.assertEqual(order.order_line[1].product_id.id, self.product_B.id, "The wrong line has been removed")
# Test case 5 (1 B): Assert that the reward is removed when the order is modified and doesn't match the rules anymore
order.write({'order_line': [
(1, order.order_line[0].id, {'product_uom_qty': 2.0}),
(2, order.order_line[0].id, False)
]})
self.assertEqual(len(order.order_line.ids), 1, "The promo reward should have been removed as the rules are not matched anymore")
self.assertEqual(order.order_line.product_id.id, self.product_B.id, "The wrong line has been removed")
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_config_settings_view_form" model="ir.ui.view">
<field name="name">sale settings</field>
<field name="model">sale.config.settings</field>
<field name="inherit_id" ref="sales_team.view_sale_config_settings"/>
<field name="arch" type="xml">
<xpath expr="//group[@name='quotations_sales']" position="inside">
<label for="id" string="Coupons"/>
<div>
<field name="module_sale_coupon" class="oe_inline" widget="upgrade_boolean"/>
<label for="module_sale_coupon"/>
<a href="https://www.odoo.com/fr_FR/" target="_blank"> More Info</a>
</div>
</xpath>
</field>
</record>
</odoo>
+284
View File
@@ -0,0 +1,284 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Common form view between the coupon programs and the promotion programs -->
<record id="sale_coupon_program_view_form_common" model="ir.ui.view">
<field name="name">sale.coupon.program.common.form</field>
<field name="model">sale.coupon.program</field>
<field name="arch" type="xml">
<form string="Coupon Program">
<sheet>
<div class="oe_button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options='{"terminology": "active"}'/>
</button>
<button class="oe_stat_button" type="object" icon="fa-usd" name="action_view_sales_orders">
<field name="order_count" string="Sales" widget="statinfo"/>
</button>
</div>
<div name="title" class="oe_left">
<label class="oe_edit_only" for="name" string="Program Name"/>
</div>
<group>
<group name="conditions" string="Conditions">
<field name="program_type" invisible="1"/>
<field name="rule_products_domain" placeholder="Select product" widget="char_domain" options="{'model': 'product.product'}"/>
<field name="rule_product_ids" widget="many2many_tags" invisible="1"/>
<label string="Quantity" for="rule_min_quantity" attrs="{'invisible': [('rule_products_domain', '=', False)]}"/>
<div attrs="{'invisible': [('rule_products_domain', '=',False)]}">
<field name="rule_min_quantity" class="oe_inline"/>
</div>
<label string="Minimum Purchase Of" for="rule_minimum_amount" />
<div name="rule_minimum_amount">
<field name="currency_id" invisible="1"/>
<field name="rule_minimum_amount" class="oe_inline" widget='monetary' options="{'currency_field': 'currency_id'}"/>
<field name="rule_minimum_amount_tax_inclusion" class="oe_inline"/>
</div>
<field name="company_id" placeholder="Select company" groups="base.group_multi_company"></field>
</group>
<group name="validity" string="Validity"/>
</group>
<group string="Rewards">
<group name='reward'>
<field name="reward_type" widget="radio"/>
<field name="discount_line_product_id" groups="base.group_no_one"/>
</group>
<group>
<field name="reward_product_id" attrs="{'invisible': [('reward_type', 'in', ('discount', 'free_shipping'))], 'required': [('reward_type', '=', 'product')]}" placeholder="Select reward product"/>
<label string="Quantity" for="reward_product_quantity" attrs="{'invisible': ['|', ('reward_type', 'in', ('discount', 'free_shipping')), ('reward_product_id', '=',False)]}"/>
<div attrs="{'invisible': ['|', ('reward_type', 'in', ('discount', 'free_shipping')),('reward_product_id', '=',False)]}">
<field name="reward_product_quantity" class="oe_inline"/>
<field name="reward_product_uom_id" class="oe_inline"/>
</div>
<label string="Apply Discount" for="discount_type" attrs="{'invisible': [('reward_type', 'in', ('product', 'free_shipping'))]}"/>
<div attrs="{'invisible': [('reward_type', 'in', ('product', 'free_shipping'))]}">
<field name="discount_type" class="oe_inline" attrs="{'required': [('reward_type','=','discount')]}"/>
<field name="discount_percentage" attrs="{'invisible': [('discount_type', '!=', 'percentage')],'required': [('discount_type', '=', 'percentage')]}" class="oe_inline"/>
<span attrs="{'invisible': [('discount_type', '!=', 'percentage')],'required': [('discount_type', '=', 'percentage')]}" class="oe_inline">%</span>
</div>
<label string="Fixed Amount" for="discount_fixed_amount" attrs="{'invisible': ['|',('discount_type', '!=', 'fixed_amount'), ('reward_type', '!=', 'discount')]}" />
<div attrs="{'invisible': ['|',('discount_type', '!=', 'fixed_amount'), ('reward_type', '!=', 'discount')]}">
<field name="discount_fixed_amount" class="oe_inline" attrs="{'required':[('discount_type', '=', 'fixed_amount')]}" widget='monetary' options="{'currency_field': 'currency_id'}"/>
</div>
<field name="discount_apply_on" attrs="{'invisible':
['|', ('reward_type', 'in', ('product', 'free_shipping')), ('discount_type', '!=', 'percentage')]}" widget="radio"/>
<field name="discount_specific_product_id" attrs="{'invisible': ['|', '|', ('discount_apply_on', '!=', 'specific_product'),('discount_type', '!=', 'percentage'), ('reward_type', 'in', ('product', 'free_shipping'))], 'required': [('reward_type', '=', 'discount'),('discount_apply_on', '=', 'specific_product'),('discount_type', '=', 'percentage')]}" placeholder="Select product"/>
<field name="discount_max_amount" attrs="{'invisible': ['|', ('reward_type', 'in', ('product', 'free_shipping')), ('discount_type', '!=', 'percentage')]}" widget='monetary' options="{'currency_field': 'currency_id'}"/>
<label for="discount_max_amount" string="Max Discount Amount" attrs="{'invisible': ['|', ('reward_type', 'in', ('product', 'free_shipping')), ('discount_type', '!=', 'percentage')]}"/>
<div attrs="{'invisible': ['|', ('reward_type', 'in', ('product', 'free_shipping')), ('discount_type', '!=', 'percentage')]}">
<field name="discount_max_amount" class="oe_inline" widget='monetary' options="{'currency_field': 'currency_id'}"/>
<span class="oe_grey">if 0, no limit</span>
</div>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Coupon Program -->
<record id="sale_coupon_program_view_form" model="ir.ui.view">
<field name="name">sale.coupon.program.form</field>
<field name="model">sale.coupon.program</field>
<field name="inherit_id" ref="sale_coupon_program_view_form_common"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//sheet" position="before">
<header>
<button type="action" name="%(sale_coupon.sale_coupon_generate_action)d"
string="Generate Coupon" attrs="{'invisible': [('active', '=', False)]}"/>
<button type="action" name="%(sale_coupon.sale_coupon_generate_action)d"
string="Generate Coupon" attrs="{'invisible': [('active', '=', True)]}" class="oe_highlight"/>
</header>
</xpath>
<xpath expr="//field[@name='order_count']/.." position="before">
<button class="oe_stat_button" type="action" icon="fa-xing" name="%(sale_coupon.sale_coupon_action)d">
<field name="coupon_count" string="Coupons" widget="statinfo"/>
</button>
</xpath>
<xpath expr="//div[@name='title']" position="inside">
<h1><field name="name" class="oe_title" placeholder="Coupon Program Name..." height="20px"/></h1>
</xpath>
<xpath expr="//group[@name='validity']" position="inside">
<label for="validity_duration" string="Validity Duration"/>
<div>
<field name="validity_duration" class="oe_inline"/>
<label string=" Days" class="oe_inline"/><span class="oe_grey">if 0, infinite use</span>
</div>
</xpath>
</field>
</record>
<record id="sale_coupon_program_view_tree" model="ir.ui.view">
<field name="name">sale.coupon.program.tree</field>
<field name="model">sale.coupon.program</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="active"/>
</tree>
</field>
</record>
<record id="sale_coupon_program_view_search" model="ir.ui.view">
<field name="name">sale.coupon.program.search</field>
<field name="model">sale.coupon.program</field>
<field name="arch" type="xml">
<search>
<filter string="Active" domain="[('active', '=', True)]" help="Opened Programs"/>
<filter string="Closed" domain="[('active', '=', False)]" help="Closed Programs"/>
<filter string="Expired" name="expired" domain="[('rule_date_to', '&lt;', datetime.datetime.now())]" help="Expired Programs"/>
</search>
</field>
</record>
<record id="sale_coupon_program_action_coupon_program" model="ir.actions.act_window">
<field name="name">Coupon Program</field>
<field name="res_model">sale.coupon.program</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="sale_coupon_program_view_search"/>
<field name="view_ids" eval="[
(5, 0, 0),
(0, 0, {'view_mode': 'tree'}),
(0, 0, {'view_mode': 'form', 'view_id': ref('sale_coupon_program_view_form')})]"/>
<field name="domain">[('program_type','=', 'coupon_program')]</field>
<field name="context">{
'default_program_type': 'coupon_program',
'promo_code_usage': 'code_needed',
'search_default_opened': 1
}</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click here to create a new coupon program.
</p>
<p>
Using coupon programs allows you to propose to your customers:<br/>
<ul>
<li>Free products or discounts if some conditions on products or purchased amount are met.</li>
<li>Promotional offers limited in time.</li>
<li>Generate coupons for a customers group.</li>
<li>Specific offers based on a customers group or a product set.</li>
<li>A combination of the preceeding points.</li>
</ul><br/>
A coupon is applied by entering a valid code if the program rules are met and if the
reward is already in the order lines.
</p>
</field>
</record>
<menuitem id="menu_coupon_type_config" action="sale_coupon_program_action_coupon_program" parent="sale.menu_sales_config" groups="sales_team.group_sale_manager" sequence="2"/>
<!-- Promotion Program -->
<record id="sale_coupon_program_view_promo_program_form" model="ir.ui.view">
<field name="name">sale.coupon.promotion.program.form</field>
<field name="model">sale.coupon.program</field>
<field name="inherit_id" ref="sale_coupon_program_view_form_common"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//div[@name='title']" position="inside">
<h1><field name="name" class="oe_title" placeholder="Promotion Program Name..." height="20px"/></h1>
</xpath>
<xpath expr="//field[@name='order_count']/.." position="before">
<button class="oe_stat_button" type="action" icon="fa-xing" name="%(sale_coupon.sale_coupon_action)d" attrs="{'invisible': [('promo_applicability', '=', 'on_current_order')]}">
<field name="coupon_count" string="Coupons" widget="statinfo"/>
</button>
</xpath>
<xpath expr="//group[@name='reward']" position="before">
<field name="sequence" invisible="1"/>
</xpath>
<xpath expr="//field[@name='program_type']" position="after">
<field name="rule_partners_domain" placeholder="Select customer" widget="char_domain" options="{'model': 'res.partner'}"/>
<field name="rule_partner_ids" widget="many2many_tags" invisible="1" domain="[('is_customer', '=', True)]"/>
</xpath>
<xpath expr="//div[@name='rule_minimum_amount']" position="after">
<field name="promo_code_usage" widget="radio"/>
<field name="promo_code" attrs="{'required': [('promo_code_usage', '=', 'code_needed')], 'invisible': [('promo_code_usage', '=', 'no_code_needed')]}"/>
</xpath>
<xpath expr="//group[@name='validity']" position="inside">
<label string="Apply on First" for="maximum_use_number" class="oe_inline"/>
<div>
<field name="maximum_use_number" class="oe_inline"/>
<span> Orders</span>
<span class="oe_grey"> if 0, infinite use</span>
</div>
<field name="rule_date_from" class="oe_inline"/>
<field name="rule_date_to" class="oe_inline"/>
</xpath>
<xpath expr="//group[@name='reward']" position="before">
<group>
<field name="promo_applicability" widget="radio"/>
</group>
<group>
<span class="oe_grey">
<b>Apply on Current Order -</b> Reward will be applied on current order.<br/>
<b>Apply on Next Order -</b> Generate a coupon for a next order.
</span>
</group>
</xpath>
</field>
</record>
<record id="sale_coupon_program_view_promo_program_tree" model="ir.ui.view">
<field name="name">sale.coupon.promotion.program.tree</field>
<field name="model">sale.coupon.program</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="promo_code_usage"/>
<field name="active"/>
</tree>
</field>
</record>
<record id="sale_coupon_program_view_promo_program_search" model="ir.ui.view">
<field name="name">sale.coupon.promotion.program.search</field>
<field name="model">sale.coupon.program</field>
<field name="arch" type="xml">
<search>
<filter string="Active" domain="[('active', '=', True)]" help="Opened Programs"/>
<filter string="Closed" domain="[('active', '=', False)]" help="Closed Programs"/>
</search>
</field>
</record>
<record id="sale_coupon_program_action_promo_program" model="ir.actions.act_window">
<field name="name">Promotion Program</field>
<field name="res_model">sale.coupon.program</field>
<field name="view_type">form</field>
<field name="view_mode">tree,form</field>
<field name="view_ids" eval="[
(5, 0, 0),
(0, 0, {'view_mode': 'tree'}),
(0, 0, {'view_mode': 'form', 'view_id': ref('sale_coupon_program_view_promo_program_form')})]"/>
<field name="search_view_id" ref="sale_coupon_program_view_promo_program_search"/>
<field name="domain">[('program_type', '=', 'promotion_program')]</field>
<field name="context">{
'default_program_type': 'promotion_program',
'default_promo_code_usage':
'no_code_needed',
'search_default_opened': 1
}</field>
<field name="help" type="html">
<p class="oe_view_nocontent_create">
Click here to create a new promotion program.
</p>
<p>
Using promotion programs allows you to propose to your customers:<br/>
<ul>
<li>Promotional offers limited in time or a limited order number.</li>
<li>Public code printed in advertisements, magasines, etc.</li>
<li>Specific offers based on a customers group or a product set.</li>
<li>A combination of the preceeding points.</li>
</ul><br/>
You may choose to apply the program reward immediatly on the sales order or to propose the reward for a next order. In the second case a coupon will be generated at the order confirmation for the customer to use it later.
</p>
</field>
</record>
<menuitem action="sale_coupon_program_action_promo_program" id="menu_promotion_type_config" parent="sale.menu_sales_config" groups="sales_team.group_sale_manager" sequence="1"/>
</odoo>
+47
View File
@@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_coupon_view_tree" model="ir.ui.view">
<field name="name">sale.coupon.tree</field>
<field name="model">sale.coupon</field>
<field name="arch" type="xml">
<tree string="Coupons" create="false" edit="false" delete="false">
<field name="code"/>
<field name="expiration_date"/>
<field name="program_id"/>
<field name="partner_id"/>
<field name="order_id"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="sale_coupon_action" model="ir.actions.act_window">
<field name="name">Coupons</field>
<field name="res_model">sale.coupon</field>
<field name="view_id" ref="sale_coupon_view_tree"/>
<field name="domain">[('program_id', '=', active_id)]</field>
<field name="context">{}</field>
</record>
<record id="sale_coupon_view_form" model="ir.ui.view">
<field name="name">sale.coupon.form</field>
<field name="model">sale.coupon</field>
<field name="arch" type="xml">
<form string="Coupons" create="false" edit="false" delete="false">
<header>
<field name="state" widget="statusbar" statusbar_visible="new,used,expired" context="{'state': state}"/>
</header>
<sheet>
<group>
<field name="code"/>
<field name="expiration_date"/>
<field name="partner_id"/>
<field name="order_id"/>
</group>
</sheet>
</form>
</field>
</record>
</odoo>
+38
View File
@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_order_view_form" model="ir.ui.view">
<field name="name">sale.order.form.inherit.coupon</field>
<field name="model">sale.order</field>
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//header/button[@name='action_cancel']" position="before">
<button name="%(sale_coupon.sale_coupon_apply_code_action)d" string="Apply Coupon" type="action" groups="base.group_user" states="draft"/>
</xpath>
<xpath expr="//page[@string='Other Information']">
<group groups="base.group_no_one">
<group string="Promotion Program">
<field name="promo_code" groups="base.group_no_one"/>
</group>
</group>
<group string="Offered Coupons">
<div>
<field name="generated_coupon_ids" groups="base.group_no_one" readonly="1"/>
</div>
</group>
<group string="Applied Coupons">
<div>
<field name="applied_coupon_ids" groups="base.group_no_one" options="{'no_create': True}"/>
</div>
</group>
</xpath>
</field>
</record>
<record id="sale_order_action" model="ir.actions.act_window">
<field name="name">Sales Order</field>
<field name="res_model">sale.order</field>
<field name="view_id" ref="sale.view_order_tree"/>
<field name="domain">[('state', '!=', 'cancel')]</field>
<field name="context">{}</field>
</record>
</odoo>
+5
View File
@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import sale_coupon_apply_code
import sale_coupon_generate
+50
View File
@@ -0,0 +1,50 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class SaleCouponApplyCode(models.TransientModel):
_name = 'sale.coupon.apply.code'
_rec_name = 'coupon_code'
coupon_code = fields.Char(string="Coupon", required=True)
@api.multi
def process_coupon(self):
"""
Apply the entered coupon code if valid, raise an UserError otherwise.
"""
sales_order = self.env['sale.order'].browse(self.env.context.get('active_id'))
error_status = self.apply_coupon(sales_order, self.coupon_code)
if error_status.get('error', False):
raise UserError(error_status.get('error', False))
def apply_coupon(self, order, coupon_code):
error_status = {}
program = self.env['sale.coupon.program'].search([('promo_code', '=', coupon_code)])
if program:
error_status = program._check_promo_code(order, coupon_code)
if not error_status:
if program.promo_applicability == 'on_next_order':
coupon = order._create_reward_coupon(program)
return {
'generated_coupon': {
'reward': coupon.program_id.discount_line_product_id.name,
'code': coupon.code,
}
}
else: # The program is applied on this order
order._create_reward_line(program)
order.code_promo_program_id = program
else:
coupon = self.env['sale.coupon'].search([('code', '=', coupon_code)], limit=1)
if coupon:
error_status = coupon._check_coupon_code(order)
if not error_status:
order._create_reward_line(coupon.program_id)
order.applied_coupon_ids += coupon
coupon.write({'state': 'used'})
else:
error_status = {'error': _('The code %s is invalid') % (coupon_code)}
return error_status
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_coupon_apply_code_view_form" model="ir.ui.view">
<field name="name">sale.coupon.apply.code.form</field>
<field name="model">sale.coupon.apply.code</field>
<field name="arch" type="xml">
<form string="Apply coupon">
<group>
<field name="coupon_code"/>
</group>
<footer>
<button name="process_coupon" string="Apply Coupon" type="object" class="oe_highlight"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="sale_coupon_apply_code_action" model="ir.actions.act_window">
<field name="name">Enter Coupon Code</field>
<field name="res_model">sale.coupon.apply.code</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="view_id" ref="sale_coupon_apply_code_view_form"/>
</record>
</odoo>
+47
View File
@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
from odoo.tools.safe_eval import safe_eval
class SaleCouponGenerate(models.TransientModel):
_name = 'sale.coupon.generate'
nbr_coupons = fields.Integer(string="Number of Coupons", help="Number of coupons", default=1)
generation_type = fields.Selection([
('nbr_coupon', 'Number of Coupons'),
('nbr_customer', 'Number of Selected Customers')
], default='nbr_coupon')
partners_domain = fields.Char(string="Customer")
partner_ids = fields.Many2many('res.partner', string="Related Partners", compute='_compute_partner_ids')
@api.multi
def generate_coupon(self):
"""Generates the number of coupons entered in wizard field nbr_coupons
"""
program = self.env['sale.coupon.program'].browse(self.env.context.get('active_id'))
vals = {'program_id': program.id}
if self.generation_type == 'nbr_coupon' and self.nbr_coupons > 0:
for count in range(0, self.nbr_coupons):
self.env['sale.coupon'].create(vals)
if self.generation_type == 'nbr_customer' and self.partners_domain:
for partner in self.partner_ids:
vals.update({'partner_id': partner.id})
coupon = self.env['sale.coupon'].create(vals)
subject = '%s, a coupon has been generated for you' % (partner.name)
body = self.env.ref('sale_coupon.sale_coupon_created_coupon_email_template').render({
'code': coupon.code,
'reward_description': coupon.program_id.discount_line_product_id.name
})
self.env['mail.mail'].create({
'subject': subject,
'body': body,
'email_from': self.env.user.email or '',
'email_to': partner.email,
})
@api.depends('partners_domain')
def _compute_partner_ids(self):
self.partner_ids = self.partners_domain and self.env['res.partner'].search(safe_eval(self.partners_domain))
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_coupon_generate_view_form" model="ir.ui.view">
<field name="name">sale.coupon.generate.form</field>
<field name="model">sale.coupon.generate</field>
<field name="arch" type="xml">
<form string="Generate Coupons">
<group>
<field name="generation_type" widget="radio"/>
<field name="partners_domain" attrs="{'invisible': [('generation_type', '!=', 'nbr_customer')]}" widget="char_domain" options="{'model': 'res.partner'}"/>
<field name="partner_ids" groups="base.group_no_one"/>
<field name="nbr_coupons" attrs="{'invisible': [('generation_type', '!=', 'nbr_coupon')]}"/>
</group>
<footer>
<button name="generate_coupon" type="object" string="Generate" class="oe_highlight"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="sale_coupon_generate_action" model="ir.actions.act_window">
<field name="name">Number of Coupons To Generate</field>
<field name="res_model">sale.coupon.generate</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="view_id" ref="sale_coupon_generate_view_form"/>
</record>
</odoo>