[MERGE] sale_coupon,point_of_sale: Merge Coupon, Loyalty Cards, eWallets models

This module is an unification of `coupon`, `gift_card` and an extraction
of the program models of `pos_loyalty`.

The goal of this module is to unify the creation of coupon, promotion,
gift_card, loyalty programs etc.. into a single base module and share
the same models.

Managers will be able to create programs based on rules and rewards.
Programs may apply on the current order (rules and rewards)
or on future orders, where the rules must match the first order to get a
reward on the second order
or even be nominative and accumulate points over multiple orders.

Every program is based on a point system. And each use of the program
will result in the creation of a coupon (except for nominative programs
which should be limited to one card per customer).

A rule may filter on quantity, money spent and products and give points
on the order, the amount of units paid or the amount of money spent.

A reward could be a free product, a discount or (with an additional
module) free shipping.
Discounts can be percentage based, fixed, or based on the amount of
points the card has.
They can also be filtered on products or tags, to discount specific
products.

A new feature is also being able to select communication plan for these
programs.
The manager can define a template to be sent upon the creation of a new
coupon/card or when reaching a certain amount of points.

TaskId-2675382

Relates to:
 - Community: https://github.com/odoo/odoo/pull/82250
 - Enterprise: https://github.com/odoo/enterprise/pull/23256
 - Upgrade: https://github.com/odoo/upgrade/pull/3234

Original PRs:
  - coupon: https://github.com/odoo/odoo/pull/38266
  - gift_card: https://github.com/odoo/odoo/pull/64195
  - pos_loyalty commit: https://github.com/odoo/enterprise/commit/376f7ca4870e995438cd91b9289ace753320e32e
  - sale_coupon: https://github.com/odoo/enterprise/pull/13
  - sale_gift_card: https://github.com/odoo/odoo/pull/64195
  - website_sale_gift_card: https://github.com/odoo/odoo/pull/64195
  - website_sale_coupon_delivery: https://github.com/odoo/odoo/issues/31857
  - pos_coupon pr: https://github.com/odoo/odoo/pull/38382
  - pos_gift_card pr: https://github.com/odoo/odoo/pull/75518
  - sale_coupon_taxcloud: https://github.com/odoo/enterprise/commit/d08e5083fe89df231766bf57606515677405e1ad
  - sale_coupon_taxcloud_delivery: https://github.com/odoo/enterprise/commit/010926e28c08226531b21466ee092fe503aff497
  - helpdesk_sale_coupon: https://github.com/odoo/enterprise/commit/4c81b37abb89ef748aa59cf193ed2f59ab974bc0

closes odoo/odoo#82250

Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
Odoo's Mergebot
2022-04-08 13:46:26 +02:00
committed by GitHub
521 changed files with 10151 additions and 112759 deletions
-25
View File
@@ -1,25 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Coupon",
'summary': "Use discount coupons in different sales channels.",
'description': """Integrate coupon mechanism in orders.""",
'category': 'Sales',
'version': '1.0',
'depends': ['account'],
'data': [
'wizard/coupon_generate_views.xml',
'security/ir.model.access.csv',
'security/coupon_security.xml',
'views/coupon_views.xml',
'views/coupon_program_views.xml',
'report/coupon_report.xml',
'report/coupon_report_templates.xml',
'data/expire_coupon_cron.xml',
],
'demo': [
'demo/coupon_demo.xml',
],
'installable': True,
'license': 'LGPL-3',
}
-16
View File
@@ -1,16 +0,0 @@
<?xml version="1.0" ?>
<odoo>
<data noupdate="1">
<record id="expire_coupon_cron" model="ir.cron">
<field name="name">Coupon: expire coupon based on date</field>
<field name="model_id" ref="coupon.model_coupon_coupon"/>
<field name="state">code</field>
<field name="code">model.cron_expire_coupon()</field>
<field name="active" eval="True"/>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
</record>
</data>
</odoo>
-60
View File
@@ -1,60 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="product_product_10_percent_discount" model="product.product">
<field name="name">10.0% discount on total amount</field>
<field name="type">service</field>
<field name="taxes_id" eval="False"/>
<field name="supplier_taxes_id" eval="False"/>
<field name="sale_ok" eval="False"/>
<field name="purchase_ok" eval="False"/>
<field name="default_code">10PERCENTDISC</field>
<field name="categ_id" ref="product.product_category_3"/>
</record>
<record id="10_percent_auto_applied" model="coupon.program">
<field name="name">Code for 10% on orders</field>
<field name="promo_code_usage">code_needed</field>
<field name="promo_code">10pc</field>
<field name="discount_apply_on">on_order</field>
<field name="discount_type">percentage</field>
<field name="discount_percentage">10.0</field>
<field name="program_type">promotion_program</field>
<field name="discount_line_product_id" ref="coupon.product_product_10_percent_discount"/>
<field name="validity_duration">0</field>
</record>
<record id="product_product_free_large_cabinet" model="product.product">
<field name="name">Free Product - Large Cabinet</field>
<field name="type">service</field>
<field name="taxes_id" eval="False"/>
<field name="supplier_taxes_id" eval="False"/>
<field name="sale_ok" eval="False"/>
<field name="purchase_ok" eval="False"/>
<field name="default_code">FREELARGECABINET</field>
<field name="categ_id" ref="product.product_category_3"/>
</record>
<record id="3_cabinets_plus_1_free" model="coupon.program">
<field name="name">Buy 3 large cabinets, get one for free</field>
<field name="promo_code_usage">no_code_needed</field>
<field name="discount_apply_on">on_order</field>
<field name="reward_type">product</field>
<field name="program_type">promotion_program</field>
<field name="reward_product_id" ref="product.product_product_6"></field>
<field name="rule_min_quantity">3</field>
<field name="rule_products_domain">[["name","ilike","large cabinet"]]</field>
<field name="discount_line_product_id" ref="coupon.product_product_free_large_cabinet"/>
<field name="validity_duration">0</field>
</record>
<record id="10_percent_coupon" model="coupon.program">
<field name="name">10% Discount</field>
<field name="promo_code_usage">code_needed</field>
<field name="discount_apply_on">on_order</field>
<field name="discount_type">percentage</field>
<field name="discount_percentage">10.0</field>
<field name="program_type">coupon_program</field>
</record>
</data>
</odoo>
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mail_compose_message
from . import coupon
from . import coupon_reward
from . import coupon_rules
from . import coupon_program
-117
View File
@@ -1,117 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import random
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from uuid import uuid4
class Coupon(models.Model):
_name = 'coupon.coupon'
_description = "Coupon"
_rec_name = 'code'
@api.model
def _generate_code(self):
"""Generate a 20 char long pseudo-random string of digits for barcode
generation.
A decimal serialisation is longer than a hexadecimal one *but* it
generates a more compact barcode (Code128C rather than Code128A).
Generate 8 bytes (64 bits) barcodes as 16 bytes barcodes are not
compatible with all scanners.
"""
return str(uuid4())[:22]
code = fields.Char(default=lambda self: self._generate_code(), required=True, readonly=True)
expiration_date = fields.Date('Expiration Date', compute='_compute_expiration_date')
state = fields.Selection([
('reserved', 'Pending'),
('new', 'Valid'),
('sent', 'Sent'),
('used', 'Used'),
('expired', 'Expired'),
('cancel', 'Cancelled')
], required=True, default='new')
partner_id = fields.Many2one('res.partner', "For Customer")
program_id = fields.Many2one('coupon.program', "Program")
discount_line_product_id = fields.Many2one('product.product', related='program_id.discount_line_product_id', readonly=False,
help='Product used in the sales order to apply the discount.')
_sql_constraints = [
('unique_coupon_code', 'unique(code)', 'The coupon code must be unique!'),
]
@api.depends('create_date', 'program_id.validity_duration')
def _compute_expiration_date(self):
self.expiration_date = 0
for coupon in self.filtered(lambda x: x.program_id.validity_duration > 0):
coupon.expiration_date = (coupon.create_date + relativedelta(days=coupon.program_id.validity_duration)).date()
def _get_default_template(self):
return False
def action_coupon_send(self):
""" Open a window to compose an email, with the edi invoice template
message loaded by default
"""
self.ensure_one()
default_template = self._get_default_template()
compose_form = self.env.ref('mail.email_compose_message_wizard_form', False)
ctx = dict(
default_model='coupon.coupon',
default_res_id=self.id,
default_use_template=bool(default_template),
default_template_id=default_template and default_template.id,
default_composition_mode='comment',
default_email_layout_xmlid='mail.mail_notification_light',
mark_coupon_as_sent=True,
force_email=True,
)
return {
'name': _('Compose Email'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'mail.compose.message',
'views': [(compose_form.id, 'form')],
'view_id': compose_form.id,
'target': 'new',
'context': ctx,
}
def action_coupon_cancel(self):
self.state = 'cancel'
def cron_expire_coupon(self):
self._cr.execute("""
SELECT C.id FROM COUPON_COUPON as C
INNER JOIN COUPON_PROGRAM as P ON C.program_id = P.id
WHERE C.STATE in ('reserved', 'new', 'sent')
AND P.validity_duration > 0
AND C.create_date + interval '1 day' * P.validity_duration < now()""")
expired_ids = [res[0] for res in self._cr.fetchall()]
self.browse(expired_ids).write({'state': 'expired'})
def _check_coupon_code(self, order_date, partner_id, **kwargs):
""" Check the validity of this single coupon.
:param order_date Date:
:param partner_id int | boolean:
"""
self.ensure_one()
message = {}
if self.state == 'used':
message = {'error': _('This coupon has already been used (%s).') % (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.state == 'cancel':
message = {'error': _('This coupon has been cancelled (%s).') % (self.code)}
elif self.state == 'expired' or (self.expiration_date and self.expiration_date < order_date):
message = {'error': _('This coupon is expired (%s).') % (self.code)}
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.id != partner_id:
message = {'error': _('Invalid partner.')}
return message
-175
View File
@@ -1,175 +0,0 @@
# -*- 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, ValidationError
import ast
class CouponProgram(models.Model):
_name = 'coupon.program'
_description = "Coupon Program"
_inherits = {'coupon.rule': 'rule_id', 'coupon.reward': 'reward_id'}
# We should apply 'discount' promotion first to avoid offering free product when we should not.
# Eg: If the discount lower the SO total below the required threshold
# Note: This is only revelant when programs have the same sequence (which they have by default)
_order = "sequence, reward_type"
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('coupon.rule', string="Coupon Rule", ondelete='restrict', required=True)
reward_id = fields.Many2one('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 cumulative)\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', 'Send a Coupon')],
default='on_current_order', string="Applicability")
coupon_ids = fields.One2many('coupon.coupon', 'program_id', string="Generated Coupons", copy=False)
coupon_count = fields.Integer(compute='_compute_coupon_count')
company_id = fields.Many2one('res.company', string="Company", required=True, default=lambda self: self.env.company)
currency_id = fields.Many2one(string="Currency", related='company_id.currency_id', readonly=True)
validity_duration = fields.Integer(default=30,
help="Validity duration for a coupon after its generation")
total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count")
@api.constrains('promo_code')
def _check_promo_code_constraint(self):
""" Program code must be unique """
for program in self.filtered(lambda p: p.promo_code):
domain = [('id', '!=', program.id), ('promo_code', '=', program.promo_code)]
if self.search(domain):
raise ValidationError(_('The program code must be unique!'))
@api.depends('coupon_ids')
def _compute_coupon_count(self):
coupon_data = self.env['coupon.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.onchange('promo_code_usage')
def _onchange_promo_code_usage(self):
if self.promo_code_usage == 'no_code_needed':
self.promo_code = False
@api.onchange('reward_product_id')
def _onchange_reward_product_id(self):
if self.reward_product_id:
self.reward_product_uom_id = self.reward_product_id.uom_id
@api.onchange('discount_type')
def _onchange_discount_type(self):
if self.discount_type == 'fixed_amount':
self.discount_apply_on = 'on_order'
@api.model_create_multi
def create(self, vals_list):
programs = super().create(vals_list)
for program in programs:
if program.discount_line_product_id:
continue
values = program._get_discount_product_values()
discount_line_product_id = self.env['product.product'].create(values)
program.write({'discount_line_product_id': discount_line_product_id.id})
return programs
def write(self, vals):
res = super(CouponProgram, self).write(vals)
reward_fields = [
'reward_type', 'reward_product_id', 'discount_type', 'discount_percentage',
'discount_apply_on', 'discount_specific_product_ids', '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.display_name})
return res
@api.ondelete(at_uninstall=False)
def _unlink_except_active(self):
if self.filtered('active'):
raise UserError(_('You can not delete a program in active state'))
def unlink(self):
# get reference to rule and reward
rule = self.rule_id
reward = self.reward_id
# unlink the program
super(CouponProgram, self).unlink()
# then unlink the rule and reward
rule.unlink()
reward.unlink()
return True
def toggle_active(self):
super(CouponProgram, self).toggle_active()
for program in self:
program.discount_line_product_id.active = program.active
coupons = self.filtered(lambda p: not p.active and p.promo_code_usage == 'code_needed').mapped('coupon_ids')
coupons.filtered(lambda x: x.state != 'used').write({'state': 'expired'})
def _compute_program_amount(self, field, currency_to):
self.ensure_one()
return self.currency_id._convert(self[field], currency_to, self.company_id, fields.Date.today())
def _is_valid_partner(self, partner):
if self.rule_partners_domain and self.rule_partners_domain != '[]':
domain = ast.literal_eval(self.rule_partners_domain) + [('id', '=', partner.id)]
return bool(self.env['res.partner'].search_count(domain))
else:
return True
def _get_valid_products(self, products):
"""Get valid products for the program.
:param products: records of product.product
:return: valid products recordset
"""
if self.rule_products_domain and self.rule_products_domain != "[]":
domain = ast.literal_eval(self.rule_products_domain)
return products.filtered_domain(domain)
return products
def _generate_coupons(self, partner_id):
'''Generate coupons that can be used in the next order for the given partner_id.'''
generated_coupons = self.env['coupon.coupon']
for program in self:
generated_coupons |= self.env['coupon.coupon'].create({
'program_id': program.id,
'partner_id': partner_id,
})
return generated_coupons
def _compute_total_order_count(self):
for program in self:
program.total_order_count = 0
def _get_discount_product_values(self):
return {
'name': self.reward_id.display_name,
'type': 'service',
'taxes_id': False,
'supplier_taxes_id': False,
'sale_ok': False,
'purchase_ok': False,
'lst_price': 0, #Do not set a high value to avoid issue with coupon code
}
-92
View File
@@ -1,92 +0,0 @@
# -*- 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 CouponReward(models.Model):
_name = 'coupon.reward'
_description = "Coupon Reward"
_rec_name = 'reward_description'
# VFE FIXME multi company
"""Rewards are not restricted to a company...
You could have a reward_product_id limited to a specific company A.
But still use this reward as reward of a program of company B...
"""
reward_description = fields.Char('Reward Description')
reward_type = fields.Selection([
('discount', 'Discount'),
('product', 'Free Product'),
], string='Reward Type', 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=10,
help='The discount in percentage, between 1 and 100')
discount_apply_on = fields.Selection([
('on_order', 'On Order'),
('cheapest_product', 'On Cheapest Product'),
('specific_products', 'On Specific Products')], default="on_order",
help="On Order - Discount on whole order\n" +
"Cheapest product - Discount on cheapest product of the order\n" +
"Specific products - Discount on selected specific products")
discount_specific_product_ids = fields.Many2many('product.product', string="Products",
help="Products that will be discounted if the discount is applied on specific products")
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'))
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_products':
if len(reward.discount_specific_product_ids) > 1:
reward_string = _("%s%% discount on products", reward_percentage)
else:
reward_string = _(
"%(percentage)s%% discount on %(product_name)s",
percentage=reward_percentage,
product_name=reward.discount_specific_product_ids.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['coupon.program'].search([('reward_id', '=', reward.id)])
reward_string = _(
"%(amount)s %(currency)s discount on total amount",
amount=reward.discount_fixed_amount,
currency=program.currency_id.name
)
result.append((reward.id, reward_string))
return result
-38
View File
@@ -1,38 +0,0 @@
# -*- 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 CouponRule(models.Model):
_name = 'coupon.rule'
_description = "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_products_domain = fields.Char(string="Based on Products", default=[['sale_ok', '=', True]], help="On Purchase of selected product, reward will be given")
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 any(applicability for applicability in self
if applicability.rule_date_to and applicability.rule_date_from
and 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.constrains('rule_min_quantity')
def _check_rule_min_quantity(self):
if not self.rule_min_quantity > 0:
raise ValidationError(_('Minimum quantity should be greater than 0'))
@@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class MailComposeMessage(models.TransientModel):
_inherit = 'mail.compose.message'
def _action_send_mail(self, **kwargs):
for wizard in self:
if self._context.get('mark_coupon_as_sent') and wizard.model == 'coupon.coupon' and wizard.partner_ids:
# Mark coupon as sent in sudo, as helpdesk users don't have the right to write on coupons
self.env[wizard.model].sudo().browse(wizard.res_id).state = 'sent'
return super()._action_send_mail(**kwargs)
-19
View File
@@ -1,19 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class CouponReport(models.AbstractModel):
_name = 'report.coupon.report_coupon'
_description = 'Sales Coupon Report'
@api.model
def _get_report_values(self, docids, data=None):
docs = self.env['coupon.coupon'].browse(docids)
return {
'doc_ids': docs.ids,
'doc_model': 'coupon.coupon',
'data': data,
'docs': docs,
}
-14
View File
@@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="report_coupon_code" model="ir.actions.report">
<field name="name">Coupon Code</field>
<field name="model">coupon.coupon</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">coupon.report_coupon_i18n</field>
<field name="report_file">coupon.report_coupon_i18n</field>
<field name="binding_model_id" ref="model_coupon_coupon"/>
<field name="binding_type">report</field>
</record>
</data>
</odoo>
@@ -1,101 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="report_coupon">
<t t-call="web.internal_layout">
<div class="card">
<div class="card-body">
<div class="page">
<div class="row text-center">
<div class="o_offer col-lg-12">
<h4 t-if="o.partner_id.name">
Congratulations
<t t-esc="o.partner_id.name"/>,
</h4>
<t t-set="text">on your next order</t>
<h4>Here is your reward from <t t-esc="o.program_id.company_id.name"/>.</h4>
<t t-if="o.program_id.reward_type == 'discount'">
<t t-set="trans_text">off %s</t>
<t t-set="text" t-value="trans_text % text"/>
<h1 t-if="o.program_id.discount_type == 'fixed_amount'" style="font-size: 55px; color: #875A7B">
<strong><span t-field="o.program_id.discount_fixed_amount" t-options='{"widget": "monetary", "display_currency": o.program_id.currency_id}'/></strong>
</h1>
<h1 t-if="o.program_id.discount_type == 'percentage'" style="font-size: 55px; color: #875A7B">
<strong><span t-field="o.program_id.discount_percentage"/> %</strong>
</h1>
<t t-if="o.program_id.discount_apply_on == 'specific_products'">
<t t-if="len(o.program_id.discount_specific_product_ids) > 1">
<t t-set="text">off on some products*</t>
<t t-set="display_specific_products" t-value="True"/>
</t>
<t t-else="">
<t t-set="trans_text">off on %s</t>
<t t-set="text" t-value="trans_text % o.program_id.discount_specific_product_ids.name"/>
</t>
</t>
<t t-if="o.program_id.discount_apply_on == 'cheapest_product'">
<t t-set="text">off on the cheapest product</t>
</t>
</t>
<t t-if="o.program_id.reward_type == 'product'">
<strong style="font-size: 55px; color: #875A7B">
<t t-esc="'get %s free %s' % (o.program_id.reward_product_quantity, o.program_id.reward_product_id.name)"/>
</strong>
</t>
<t t-if="o.program_id.reward_type == 'free_shipping'">
<strong style="font-size: 55px; color: #875A7B">get free shipping</strong>
</t>
<h1 class="font-weight-bold" style="font-size: 34px" t-esc="text"/>
<br/>
<h4 t-if="o.expiration_date">
Use this promo code before
<span t-field="o.expiration_date" t-options='{"format": "yyyy-MM-d"}'/>
</h4>
<h2 class="mt-4">
<strong class="bg-light" t-esc="o.code"></strong>
</h2>
<h4 t-if="o.program_id.rule_min_quantity > 1">
<span>Minimum purchase of</span>
<strong t-esc="o.program_id.rule_min_quantity"/> <span>products</span>
</h4>
<h4 t-if="o.program_id.rule_minimum_amount">
<span>Valid for purchase above</span>
<strong t-esc="o.program_id.rule_minimum_amount" t-options="{'widget': 'monetary', 'display_currency': o.program_id.currency_id}"/>
</h4>
<p t-if="display_specific_products">
<small>
*Valid for following products: <t t-esc="', '.join(o.program_id.discount_specific_product_ids.mapped('name'))"/>
</small>
</p>
<br/>
<div t-field="o.code" t-options="{'widget': 'barcode', 'width': 600, 'height': 100}"/>
<br/><br/>
<h4>Thank you,</h4>
<br/>
<div class="mt32">
<div class="text-center">
<img alt="Logo" t-att-src="'/logo?company=%d' % (o.program_id.company_id)" t-att-alt="'%s' % (o.program_id.company_id.name)" style="border:0px solid transparent; height: 50; width: 200px;" height="50"/>
</div>
</div>
<div>
<div class="text-center d-inline-block">
<span t-field="o.program_id.company_id.partner_id"
t-options='{"widget": "contact", "fields": ["address", "email"], "no_marker": True}'/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
<template id="report_coupon_i18n">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-set="o" t-value="o.with_context(lang=o.partner_id.lang or o.env.lang)"/>
<t t-call="coupon.report_coupon" t-lang="o.partner_id.lang or o.env.lang"/>
</t>
</t>
</template>
</odoo>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="sale_coupon_generate_rule" model="ir.rule">
<field name="name">Generate Sales Coupon Rule</field>
<field name="model_id" ref="model_coupon_generate_wizard"/>
<field name="domain_force">[('create_uid', '=', user.id)]</field>
</record>
</odoo>
@@ -1,7 +0,0 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_coupon_coupon,access_coupon_coupon,model_coupon_coupon,base.group_user,0,0,0,0
access_coupon_reward,access_coupon_reward,model_coupon_reward,base.group_user,0,0,0,0
access_coupon_rule,access_coupon_rule,model_coupon_rule,base.group_user,0,0,0,0
access_coupon_program,access_coupon_program,model_coupon_program,base.group_user,0,0,0,0
access_coupon_generate_wizard,access_coupon_generate_wizard,model_coupon_generate_wizard,base.group_user,0,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_coupon_coupon access_coupon_coupon model_coupon_coupon base.group_user 0 0 0 0
3 access_coupon_reward access_coupon_reward model_coupon_reward base.group_user 0 0 0 0
4 access_coupon_rule access_coupon_rule model_coupon_rule base.group_user 0 0 0 0
5 access_coupon_program access_coupon_program model_coupon_program base.group_user 0 0 0 0
6 access_coupon_generate_wizard access_coupon_generate_wizard model_coupon_generate_wizard base.group_user 0 0 0 0
@@ -1,286 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Common form view between the coupon programs and the promotion programs -->
<record id="coupon_program_view_form_common" model="ir.ui.view">
<field name="name">coupon.program.common.form</field>
<field name="model">coupon.program</field>
<field name="arch" type="xml">
<form string="Coupon Program">
<sheet>
<div class="oe_button_box" name="button_box" />
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div name="title" class="oe_left mw-100">
</div>
<group>
<group name="conditions" string="Conditions">
<field name="active" invisible="1"/>
<field name="program_type" invisible="1"/>
<field name="rule_products_domain" placeholder="Select product" widget="domain" options="{'model': 'product.product', 'in_dialog': true}"/>
<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" class="o_row">
<field name="currency_id" invisible="1"/>
<field name="rule_minimum_amount" widget='monetary' options="{'currency_field': 'currency_id'}"/>
<field name="rule_minimum_amount_tax_inclusion" required="1"/>
</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" string="Reward" widget="radio"/>
<field name="discount_line_product_id" attrs="{'invisible': [('discount_line_product_id', '=', False)]}" readonly="True"/>
</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_ids" widget='many2many_tags' attrs="{'invisible': ['|', '|', ('discount_apply_on', '!=', 'specific_products'),('discount_type', '!=', 'percentage'), ('reward_type', 'in', ('product', 'free_shipping'))], 'required': [('reward_type', '=', 'discount'),('discount_apply_on', '=', 'specific_products'),('discount_type', '=', 'percentage')]}" placeholder="Select products"/>
<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="coupon_program_view_coupon_program_form" model="ir.ui.view">
<field name="name">coupon.program.form</field>
<field name="model">coupon.program</field>
<field name="inherit_id" ref="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="%(coupon.coupon_generate_action)d"
string="Generate Coupon" attrs="{'invisible': [('active', '=', False)]}"/>
<button type="action" name="%(coupon.coupon_generate_action)d"
string="Generate Coupon" attrs="{'invisible': [('active', '=', True)]}" class="oe_highlight"/>
</header>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button" type="action" icon="fa-ticket" name="%(coupon.coupon_action)d">
<field name="coupon_count" string="Coupons" widget="statinfo"/>
</button>
</xpath>
<xpath expr="//div[@name='title']" position="inside">
<label for="name" string="Program Name"/>
<h1><field name="name" class="oe_title" 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"/>
<span class="o_form_label oe_inline"> Days</span> <span class="oe_grey">if 0, infinite use</span>
</div>
</xpath>
</field>
</record>
<record id="coupon_program_view_tree" model="ir.ui.view">
<field name="name">coupon.program.tree</field>
<field name="model">coupon.program</field>
<field name="arch" type="xml">
<tree sample="1">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="active"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="coupon_program_view_search" model="ir.ui.view">
<field name="name">coupon.program.search</field>
<field name="model">coupon.program</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<filter string="Expired" name="expired" domain="[('rule_date_to', '&lt;', datetime.datetime.now())]" help="Expired Programs"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<record id="view_coupon_program_kanban" model="ir.ui.view">
<field name="name">coupon.program.kanban</field>
<field name="model">coupon.program</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile" sample="1">
<field name="name" />
<field name="active"/>
<templates>
<t t-name="kanban-box">
<div t-attf-class="oe_kanban_card oe_kanban_global_click">
<div class="text-center oe_kanban_details">
<strong><span><field name="name"/></span></strong>
</div>
<hr class="mt4 mb4"/>
<div class="row">
<div class="col-4 text-center coupon-count-label"><strong>Coupons</strong></div>
<div class="col-4 text-center active-label"><strong>Active</strong></div>
<div class="col-4 text-center coupon-count-value"><field name="coupon_count"/></div>
<div class="col-4 text-center active-value">
<field name="active" widget="boolean"/>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="coupon_program_action_coupon_program" model="ir.actions.act_window">
<field name="name">Coupon Programs</field>
<field name="res_model">coupon.program</field>
<field name="view_mode">tree,kanban,form</field>
<field name="search_view_id" ref="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('coupon_program_view_coupon_program_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="o_view_nocontent_smiling_face">
Create a new coupon program
</p><p>
Generate and share coupon codes with your customers to get discounts or free products.
</p>
</field>
</record>
<!-- Promotion Program -->
<record id="coupon_program_view_promo_program_form" model="ir.ui.view">
<field name="name">coupon.promotion.program.form</field>
<field name="model">coupon.program</field>
<field name="inherit_id" ref="coupon_program_view_form_common"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<xpath expr="//div[@name='title']" position="inside">
<label for="name" string="Program Name"/>
<h1><field name="name" class="oe_title o_text_overflow" placeholder="e.g. 10% Discount" height="20px"/></h1>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button class="oe_stat_button" type="action" icon="fa-ticket" name="%(coupon.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="domain" options="{'model': 'res.partner', 'in_dialog': 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"/>
<label string="Coupon Validity" for="validity_duration" class="oe_inline" attrs="{'invisible': [('promo_applicability', '=', 'on_current_order')]}"/>
<div attrs="{'invisible': [('promo_applicability', '=', 'on_current_order')]}">
<field name="validity_duration" class="oe_inline"/>
<span> Days</span>
<span class="oe_grey"> if 0, coupon doesn't expire</span>
</div>
</xpath>
<xpath expr="//group[@name='reward']" position="before">
<group>
<field name="promo_applicability" widget="radio"/>
</group>
</xpath>
</field>
</record>
<record id="coupon_program_view_promo_program_tree" model="ir.ui.view">
<field name="name">coupon.promotion.program.tree</field>
<field name="model">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="coupon_program_view_promo_program_search" model="ir.ui.view">
<field name="name">coupon.promotion.program.search</field>
<field name="model">coupon.program</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<record id="coupon_program_action_promo_program" model="ir.actions.act_window">
<field name="name">Promotion Programs</field>
<field name="res_model">coupon.program</field>
<field name="view_mode">tree,kanban,form</field>
<field name="view_ids" eval="[
(5, 0, 0),
(0, 0, {'view_mode': 'tree'}),
(0, 0, {'view_mode': 'form', 'view_id': ref('coupon_program_view_promo_program_form')})]"/>
<field name="search_view_id" ref="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',
'default_validity_duration': 0,
'search_default_opened': 1
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create a new promotion program
</p><p>
Build up promotion programs to attract more customers with discounts, free products, free delivery, etc.
You can share promotion codes or grant the promotions automatically if some conditions are met.
</p>
</field>
</record>
</odoo>
-49
View File
@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="coupon_view_tree" model="ir.ui.view">
<field name="name">coupon.coupon.tree</field>
<field name="model">coupon.coupon</field>
<field name="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"/>
<button name="action_coupon_send" string="Send" type="object" icon="fa-paper-plane-o" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
<button name="action_coupon_cancel" string="Cancel" type="object" icon="fa-times" attrs="{'invisible': [('state', '!=', 'new')]}"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="coupon_action" model="ir.actions.act_window">
<field name="name">Coupons</field>
<field name="res_model">coupon.coupon</field>
<field name="view_id" ref="coupon_view_tree"/>
<field name="domain">[('program_id', '=', active_id)]</field>
<field name="context">{}</field>
</record>
<record id="coupon_view_form" model="ir.ui.view">
<field name="name">coupon.coupon.form</field>
<field name="model">coupon.coupon</field>
<field name="arch" type="xml">
<form string="Coupons" create="false" edit="false" delete="false">
<header>
<button name="action_coupon_send" type="object" string="Send by Email" class="oe_highlight" attrs="{'invisible': [('state', 'not in', ['new', 'sent'])]}"/>
<button name="action_coupon_cancel" type="object" string="Cancel" class="oe_highlight" attrs="{'invisible': [('state', '!=', 'new')]}"/>
<field name="state" widget="statusbar" statusbar_visible="new,sent,used,expired" context="{'state': state}"/>
</header>
<sheet>
<group>
<field name="code"/>
<field name="expiration_date"/>
<field name="partner_id"/>
</group>
</sheet>
</form>
</field>
</record>
</odoo>
-4
View File
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import coupon_generate
-48
View File
@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import _, api, fields, models
import ast
from odoo.osv import expression
class CouponGenerate(models.TransientModel):
_name = 'coupon.generate.wizard'
_description = 'Generate Coupon'
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", default='[]')
has_partner_email = fields.Boolean(compute='_compute_has_partner_email')
template_id = fields.Many2one('mail.template', 'Use template', domain="[('model', '=', 'coupon.coupon')]")
def generate_coupon(self):
"""Generates the number of coupons entered in wizard field nbr_coupons
"""
program = self.env['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['coupon.coupon'].create(vals)
if self.generation_type == 'nbr_customer' and self.partners_domain:
for partner in self.env['res.partner'].search(ast.literal_eval(self.partners_domain)):
vals.update({'partner_id': partner.id, 'state': 'sent' if partner.email else 'new'})
coupon = self.env['coupon.coupon'].create(vals)
context = dict(lang=partner.lang)
subject = _('%s, a coupon has been generated for you') % (partner.name)
del context
if self.template_id:
email_values = {'email_from': self.env.user.email or '', 'subject': subject}
self.template_id.send_mail(coupon.id, email_values=email_values, email_layout_xmlid='mail.mail_notification_light')
@api.depends('partners_domain')
def _compute_has_partner_email(self):
for record in self:
domain = expression.AND([ast.literal_eval(record.partners_domain), [('email', '=', False)]])
record.has_partner_email = self.env['res.partner'].search_count(domain) == 0
@@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="coupon_generate_view_form" model="ir.ui.view">
<field name="name">coupon.generate.wizard.form</field>
<field name="model">coupon.generate.wizard</field>
<field name="arch" type="xml">
<form string="Generate Coupons">
<group>
<field name="has_partner_email" invisible="1"/>
<field name="generation_type" widget="radio"/>
<field name="partners_domain" attrs="{'invisible': [('generation_type', '!=', 'nbr_customer')]}" widget="domain" options="{'model': 'res.partner'}"/>
<field name="nbr_coupons" attrs="{'invisible': [('generation_type', '!=', 'nbr_coupon')]}"/>
<field name="template_id"
options="{'no_create': True, 'no_edit': True}"
context="{'default_model': 'coupon.coupon'}"
attrs="{'invisible': [('generation_type', '!=', 'nbr_customer')]}" />
<div class="text-muted" attrs="{'invisible': [('generation_type', '!=', 'nbr_customer')]}">
Specify a mail template to send the generated coupons as email.
</div>
</group>
<div role="alert" class="alert alert-warning" attrs="{'invisible': ['|', ('generation_type', '!=', 'nbr_customer'), ('has_partner_email', '=', True)]}">
Some selected customers do not have an email address and will not receive the coupon.
</div>
<footer>
<button name="generate_coupon" type="object" string="Generate" class="oe_highlight" data-hotkey="q"/>
<button special="cancel" data-hotkey="z" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="coupon_generate_action" model="ir.actions.act_window">
<field name="name">Number of Coupons To Generate</field>
<field name="res_model">coupon.generate.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="view_id" ref="coupon_generate_view_form"/>
</record>
</odoo>
-17
View File
@@ -1,17 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': "Gift Card",
'summary': "Use gift card",
'description': """Integrate gift card mechanism""",
'category': 'Sales/Sales',
'version': '1.0',
'depends': ['product'],
'data': [
'data/gift_card_data.xml',
'views/views.xml',
'security/ir.model.access.csv',
],
'license': 'LGPL-3',
}
-23
View File
@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- the product used to deduce on sale order -->
<record id="pay_with_gift_card_product" model="product.product">
<field name="name">Gift Card</field>
<field name="list_price">0</field>
<field name="detailed_type">service</field>
<field name="purchase_ok" eval="False"/>
<field name="sale_ok" eval="False"/>
<field name="image_1920" type="base64" file="gift_card/static/img/gift_card.png"/>
</record>
<!-- the product to sell to generate gift cards automatically -->
<record id="gift_card_product_50" model="product.product">
<field name="name">Gift Card</field>
<field name="list_price">50</field>
<field name="detailed_type">gift</field>
<field name="purchase_ok" eval="False"/>
<field name="image_1920" type="base64" file="gift_card/static/img/gift_card.png"/>
</record>
</data>
</odoo>
-179
View File
@@ -1,179 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * gift_card
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server saas~14.5\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2021-09-14 10:28+0000\n"
"PO-Revision-Date: 2021-09-14 10:28+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: gift_card
#: model:ir.model.fields,help:gift_card.field_product_product__detailed_type
#: model:ir.model.fields,help:gift_card.field_product_template__detailed_type
msgid ""
"A storable product is a product for which you manage stock. The Inventory app has to be installed.\n"
"A consumable product is a product for which stock is not managed.\n"
"A service is a non-material product you provide."
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__balance
msgid "Balance"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__code
msgid "Code"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__company_id
msgid "Company"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__create_uid
msgid "Created by"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__create_date
msgid "Created on"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__currency_id
msgid "Currency"
msgstr ""
#. module: gift_card
#: code:addons/gift_card/models/product.py:0
#, python-format
msgid "Deleting the Gift Card Pay product is not allowed."
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__display_name
msgid "Display Name"
msgstr ""
#. module: gift_card
#: model:ir.model.fields.selection,name:gift_card.selection__gift_card__state__expired
msgid "Expired"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__expired_date
msgid "Expired Date"
msgstr ""
#. module: gift_card
#: code:addons/gift_card/models/gift_card.py:0
#, python-format
msgid "Gift #%s"
msgstr ""
#. module: gift_card
#: model:ir.model,name:gift_card.model_gift_card
#: model:ir.model.fields.selection,name:gift_card.selection__product_template__detailed_type__gift
#: model:product.product,name:gift_card.gift_card_product_50
#: model:product.product,name:gift_card.pay_with_gift_card_product
#: model:product.template,name:gift_card.gift_card_product_50_product_template
#: model:product.template,name:gift_card.pay_with_gift_card_product_product_template
#: model_terms:ir.ui.view,arch_db:gift_card.gift_card_view_search
#: model_terms:ir.ui.view,arch_db:gift_card.gift_card_view_tree
msgid "Gift Card"
msgstr ""
#. module: gift_card
#: model:ir.actions.act_window,name:gift_card.gift_card_action
msgid "Gift Cards"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__id
msgid "ID"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,help:gift_card.field_gift_card__partner_id
msgid "If empty, all users can use it"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__initial_amount
msgid "Initial Amount"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card____last_update
msgid "Last Modified on"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__write_uid
msgid "Last Updated by"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__write_date
msgid "Last Updated on"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__name
msgid "Name"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__partner_id
msgid "Partner"
msgstr ""
#. module: gift_card
#: model:ir.model,name:gift_card.model_product_template
msgid "Product Template"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_product_product__detailed_type
#: model:ir.model.fields,field_description:gift_card.field_product_template__detailed_type
msgid "Product Type"
msgstr ""
#. module: gift_card
#: model:ir.model.fields,field_description:gift_card.field_gift_card__state
msgid "State"
msgstr ""
#. module: gift_card
#: model:ir.model.constraint,message:gift_card.constraint_gift_card_unique_gift_card_code
msgid "The gift card code must be unique."
msgstr ""
#. module: gift_card
#: model:ir.model.constraint,message:gift_card.constraint_gift_card_check_amount
msgid "The initial amount must be positive."
msgstr ""
#. module: gift_card
#: model:product.product,uom_name:gift_card.gift_card_product_50
#: model:product.product,uom_name:gift_card.pay_with_gift_card_product
#: model:product.template,uom_name:gift_card.gift_card_product_50_product_template
#: model:product.template,uom_name:gift_card.pay_with_gift_card_product_product_template
msgid "Units"
msgstr ""
#. module: gift_card
#: model:ir.model.fields.selection,name:gift_card.selection__gift_card__state__valid
#: model_terms:ir.ui.view,arch_db:gift_card.gift_card_view_search
msgid "Valid"
msgstr ""
-58
View File
@@ -1,58 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from uuid import uuid4
class GiftCard(models.Model):
_name = "gift.card"
_description = "Gift Card"
_order = 'id desc'
_check_company_auto = True
@api.model
def _generate_code(self):
return '044' + str(uuid4())[4:-8][3:]
name = fields.Char(compute='_compute_name')
code = fields.Char(default=lambda x: x._generate_code(), required=True, readonly=True, copy=False)
partner_id = fields.Many2one('res.partner', help="If empty, all users can use it")
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
currency_id = fields.Many2one('res.currency', readonly=True, related='company_id.currency_id')
initial_amount = fields.Monetary(required=True, currency_field='currency_id')
balance = fields.Monetary(compute="_compute_balance") # in company currency
expired_date = fields.Date(default=lambda self: fields.Date.add(fields.Date.today(), years=1))
state = fields.Selection(
selection=[('valid', 'Valid'), ('expired', 'Expired')],
default='valid',
copy=False
)
_sql_constraints = [
('unique_gift_card_code', 'UNIQUE(code)', 'The gift card code must be unique.'),
('check_amount', 'CHECK(initial_amount >= 0)', 'The initial amount must be positive.')
]
def _compute_name(self):
for record in self:
record.name = _("Gift #%s", record.id)
@api.autovacuum
def _gc_mark_expired_gift_card(self):
self.env['gift.card'].search([
'&', ('state', '=', 'valid'), ('expired_date', '<', fields.Date.today())
]).write({'state': 'expired'})
def balance_converted(self, currency_id=False):
# helper to convert the current balance in the currency provided
return self.currency_id._convert(self.balance, currency_id, self.env.company, fields.Date.today())
def can_be_used(self):
# expired state are computed once a day, so can be not synchro
return self.state == 'valid' and self.balance > 0 and self.expired_date >= fields.Date.today()
@api.depends("initial_amount")
def _compute_balance(self):
for record in self:
record.balance = record.initial_amount
-23
View File
@@ -1,23 +0,0 @@
# -*- 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 ProductTemplate(models.Model):
_inherit = "product.template"
detailed_type = fields.Selection(selection_add=[
('gift', 'Gift Card'),
], ondelete={'gift': 'set service'})
def _detailed_type_mapping(self):
type_mapping = super()._detailed_type_mapping()
type_mapping['gift'] = 'service'
return type_mapping
@api.ondelete(at_uninstall=False)
def _unlink_gift_card_product(self):
if self.env.ref('gift_card.pay_with_gift_card_product').product_tmpl_id in self:
raise UserError(_('Deleting the Gift Card Pay product is not allowed.'))
@@ -1,2 +0,0 @@
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
access_gift_card_all,Gift Card Program All,model_gift_card,,0,0,0,0
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
2 access_gift_card_all Gift Card Program All model_gift_card 0 0 0 0
-62
View File
@@ -1,62 +0,0 @@
<?xml version="1.0"?>
<odoo>
<!-- Actions -->
<record id="gift_card_action" model="ir.actions.act_window">
<field name="name">Gift Cards</field>
<field name="res_model">gift.card</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_valid': True}</field>
</record>
<!-- view mode -->
<record id="gift_card_view_tree" model="ir.ui.view">
<field name="name">gift.card.tree</field>
<field name="model">gift.card</field>
<field name="arch" type="xml">
<tree string="Gift Card">
<field name="code"/>
<field name="balance"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="gift_card_view_form" model="ir.ui.view">
<field name="name">gift.card.form</field>
<field name="model">gift.card</field>
<field name="arch" type="xml">
<form>
<header>
<field name="state" widget="statusbar"/>
</header>
<sheet>
<group>
<group>
<field name="create_date"/>
<field name="expired_date"/>
<field name="code"/>
</group>
<group name="gift_card">
<field name="currency_id" attrs="{'invisible': True}"/>
<field name="initial_amount"/>
<field name="balance"/>
<field name="partner_id"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Searching -->
<record id="gift_card_view_search" model="ir.ui.view">
<field name="name">gift.card.search</field>
<field name="model">gift.card</field>
<field name="arch" type="xml">
<search string="Gift Card">
<field name="code"/>
<filter name="valid" string="Valid" domain="[('state', '=', 'valid')]" />
</search>
</field>
</record>
</odoo>
+39
View File
@@ -0,0 +1,39 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Coupons & Loyalty',
'summary': "Use discounts, gift card, eWallets and loyalty programs in different sales channels",
'category': 'Sales',
'version': '1.0',
'depends': ['product'],
'data': [
'security/ir.model.access.csv',
'security/loyalty_security.xml',
'report/loyalty_report_templates.xml',
'report/loyalty_report.xml',
'data/loyalty_data.xml',
'data/mail_template_data.xml',
'wizard/loyalty_generate_wizard_views.xml',
'views/loyalty_card_views.xml',
'views/loyalty_mail_views.xml',
'views/loyalty_program_views.xml',
'views/loyalty_reward_views.xml',
'views/loyalty_rule_views.xml',
],
'demo': [
'data/loyalty_demo.xml',
],
'assets': {
'web.assets_backend': [
'loyalty/static/src/js/loyalty_control_panel_widget.js',
'loyalty/static/src/js/loyalty_views.js',
'loyalty/static/src/js/loyalty_list_view.js',
'loyalty/static/src/scss/loyalty.scss',
],
'web.assets_qweb': [
'loyalty/static/src/xml/loyalty_templates.xml',
],
},
'installable': True,
'license': 'LGPL-3',
}
+18
View File
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Basic product for gift card program -->
<record id="gift_card_product_50" model="product.product">
<field name="name">Gift Card</field>
<field name="list_price">50</field>
<field name="detailed_type">service</field>
<field name="purchase_ok" eval="False"/>
<field name="image_1920" type="base64" file="loyalty/static/img/gift_card.png"/>
</record>
<!-- Basic product for eWallet programs -->
<record id="ewallet_product_50" model="product.product">
<field name="name">Top-up eWallet</field>
<field name="list_price">50</field>
<field name="detailed_type">service</field>
<field name="purchase_ok" eval="False"/>
</record>
</odoo>
+97
View File
@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- 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="trigger">with_code</field>
</record>
<record id="10_percent_with_code_rule" model="loyalty.rule">
<field name="mode">with_code</field>
<field name="code">10pc</field>
<field name="program_id" ref="loyalty.10_percent_with_code"/>
</record>
<record id="10_percent_with_code_reward" model="loyalty.reward">
<field name="reward_type">discount</field>
<field name="discount">10</field>
<field name="discount_mode">percent</field>
<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="trigger">auto</field>
</record>
<record id="3_cabinets_plus_1_free_rule" model="loyalty.rule">
<field name="minimum_qty">3</field>
<field name="reward_point_mode">unit</field>
<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 id="3_cabinets_plus_1_free_reward" model="loyalty.reward">
<field name="reward_type">product</field>
<field name="reward_product_id" ref="product.product_product_6"/>
<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="trigger">with_code</field>
</record>
<record id="10_percent_coupon_rule" model="loyalty.rule">
<field name="program_id" ref="loyalty.10_percent_coupon"/>
</record>
<record id="10_percent_coupon_reward" model="loyalty.reward">
<field name="reward_type">discount</field>
<field name="discount">10</field>
<field name="discount_mode">percent</field>
<field name="discount_applicability">order</field>
<field name="program_id" ref="loyalty.10_percent_coupon"/>
</record>
<record id="10_percent_coupon_communication" model="loyalty.mail">
<field name="trigger">create</field>
<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>
</record>
<record id="gift_card_program_rule" model="loyalty.rule">
<field name="reward_point_amount">1</field>
<field name="reward_point_mode">money</field>
<field name="reward_point_split">True</field>
<field name="product_ids" eval="[(4, ref('loyalty.gift_card_product_50'))]"/>
<field name="program_id" ref="loyalty.gift_card_program"/>
</record>
<record id="gift_card_program_reward" model="loyalty.reward">
<field name="reward_type">discount</field>
<field name="discount_mode">per_point</field>
<field name="discount">1</field>
<field name="discount_applicability">order</field>
<field name="required_points">1</field>
<field name="program_id" ref="loyalty.gift_card_program"/>
</record>
</odoo>
@@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="mail_template_gift_card" model="mail.template">
<field name="name">Gift Card: Send by Email</field>
<field name="model_id" ref="model_loyalty_card"/>
<field name="subject">Your Gift Card at {{ object.company_id.name }}</field>
<field name="partner_to">{{ object._get_mail_partner().id }}</field>
<field name="body_html" type="html">
<div style="margin:0px; font-size:24px; font-family:arial, 'helvetica neue', helvetica, sans-serif; line-height:36px; color:#333333; text-align: center">
Here is your gift card!
</div>
<div style="padding-top:20px; padding-bottom:20px">
<img src="/loyalty/static/img/gift_card.png" style="display:block; border:0; outline:none; text-decoration:none; margin:auto;" width="300"/>
</div>
<div style="padding:0; margin:0px; padding-top:35px; padding-bottom:35px; text-align:center;">
<h3 style="margin:0px; line-height:48px; font-family:arial, 'helvetica neue', helvetica, sans-serif; font-size:40px; font-style:normal; font-weight:normal; color:#333333; text-align:center">
<strong t-out="format_amount(object.points, object.currency_id) or ''">$ 150.00</strong></h3>
</div>
<div style="padding:0; margin:0px; padding-top:35px; padding-bottom:35px; background-color:#efefef; text-align:center;">
<p style="margin:0px; font-size:14px;font-family:arial, 'helvetica neue', helvetica, sans-serif; line-height:21px; color:#333333">
<strong>Gift Card Code</strong>
</p>
<p style="margin:0px; font-size:25px;font-family:arial, 'helvetica neue', helvetica, sans-serif; line-height:38px; color:#A9A9A9" t-out="object.code or ''">4f10-15d6-41b7-b04c-7b3e</p>
</div>
<div t-if="object.expiration_date" style="padding:0; margin:0px; padding-top:10px; padding-bottom:10px; text-align:center;">
<h3 style="margin:0px; line-height:17px; font-family:arial, 'helvetica neue', helvetica, sans-serif; font-size:14px; font-style:normal; font-weight:normal; color:#A9A9A9; text-align:center">Card expires <t t-out="format_date(object.expiration_date) or ''">05/05/2021</t></h3>
</div>
<div style="padding:20px; margin:0px; text-align:center;">
<span style="background-color:#999999; display:inline-block; width:auto; border-radius:5px;">
<a t-attf-href="{{ object.get_base_url() }}/shop" target="_blank" style="text-decoration:none; font-family:arial, 'helvetica neue', helvetica, sans-serif; font-size:22px; color:#FFFFFF; border-style:solid; border-color:#999999; border-width:20px 30px; display:inline-block; background-color:#999999; border-radius:5px; font-weight:bold; font-style:normal; line-height:26px; width:auto; text-align:center">Use it right now!</a>
</span>
</div>
</field>
<field name="report_template" ref="loyalty.report_gift_card"/>
<field name="report_name">Your Gift Card</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="mail_template_loyalty_card" model="mail.template">
<field name="name">[Loyalty] Coupon: Send by Email</field>
<field name="model_id" ref="loyalty.model_loyalty_card"/>
<field name="subject">Your reward coupon from {{ object.program_id.company_id.name }} </field>
<field name="email_from">{{ object.program_id.company_id.email }}</field>
<field name="partner_to">{{ object._get_mail_partner().id }}</field>
<field name="lang">{{ object._get_mail_partner().lang }}</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; margin:0px auto;"><tbody>
<tr>
<td valign="top" style="text-align: center; font-size: 14px;">
<t t-if="object._get_mail_partner().name">
Congratulations <t t-out="object._get_mail_partner().name or ''">Brandon Freeman</t>,<br />
</t>
Here is your reward from <t t-out="object.program_id.company_id.name or ''">YourCompany</t>.<br />
<t t-foreach="object.program_id.reward_ids" t-as="reward">
<t t-if="reward.required_points &lt;= object.points">
<span style="font-size: 50px; color: #875A7B; font-weight: bold;" t-esc="reward.description">Reward Description</span>
<br/>
</t>
</t>
</td>
</tr>
<tr style="margin-top: 16px">
<td valign="top" style="text-align: center; font-size: 14px;">
Use this promo code
<t t-if="object.expiration_date">
before <t t-out="object.expiration_date or ''">2021-06-16</t>
</t>
<p style="margin-top: 16px;">
<strong style="padding: 16px 8px 16px 8px; border-radius: 3px; background-color: #F1F1F1;" t-out="object.code or ''">15637502648479132902</strong>
</p>
<t t-foreach="object.program_id.rule_ids" t-as="rule">
<t t-if="rule.minimum_qty not in [0, 1]">
<span style="font-size: 14px;">
Minimum purchase of <t t-out="rule.minimum_qty or ''">10</t> products
</span><br />
</t>
<t t-if="rule.minimum_amount != 0.00">
<span style="font-size: 14px;">
Valid for purchase above <t t-out="rule.company_id.currency_id.symbol or ''">$</t><t t-out="'%0.2f' % float(rule.minimum_amount) or ''">10.00</t>
</span><br />
</t>
</t>
<br/>
Thank you,
<t t-if="object._get_signature()">
<br />
<t t-out="object._get_signature() or ''">--<br/>Mitchell Admin</t>
</t>
</td>
</tr>
</tbody></table>
</field>
<field name="report_template" ref="loyalty.report_loyalty_card"/>
<field name="report_name">Your Coupon Code</field>
<field name="auto_delete" eval="True"/>
</record>
</odoo>
+9
View File
@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import loyalty_card
from . import loyalty_mail
from . import loyalty_reward
from . import loyalty_rule
from . import loyalty_program
from . import product_product
+154
View File
@@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from uuid import uuid4
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class LoyaltyCard(models.Model):
_name = 'loyalty.card'
_inherit = ['mail.thread']
_description = 'Loyalty Coupon'
_rec_name = 'code'
@api.model
def _generate_code(self):
"""
Barcode identifiable codes.
"""
return '044' + str(uuid4())[4:-8][3:]
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')
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)
code = fields.Char(default=lambda self: self._generate_code(), required=True, readonly=True, index=True)
expiration_date = fields.Date()
use_count = fields.Integer(compute='_compute_use_count')
_sql_constraints = [
('card_code_unique', 'UNIQUE(code)', 'A coupon/loyalty card must have a unique code.')
]
@api.constrains('code')
def _contrains_code(self):
# Prevent a coupon from having the same code a program
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.'))
# Meant to be overriden
def _compute_use_count(self):
self.use_count = 0
def _get_default_template(self):
self.ensure_one()
return self.program_id.communication_plan_ids.filtered(lambda m: m.trigger == 'create').mail_template_id[:1]
def _get_mail_partner(self):
self.ensure_one()
return self.partner_id
def _get_signature(self):
"""To be overriden"""
self.ensure_one()
return None
def action_coupon_send(self):
""" Open a window to compose an email, with the default template returned by `_get_default_template`
message loaded by default
"""
self.ensure_one()
default_template = self._get_default_template()
compose_form = self.env.ref('mail.email_compose_message_wizard_form', False)
ctx = dict(
default_model='loyalty.card',
default_res_id=self.id,
default_use_template=bool(default_template),
default_template_id=default_template and default_template.id,
default_composition_mode='comment',
default_email_layout_xmlid='mail.mail_notification_light',
mark_coupon_as_sent=True,
force_email=True,
)
return {
'name': _('Compose Email'),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'mail.compose.message',
'views': [(compose_form.id, 'form')],
'view_id': compose_form.id,
'target': 'new',
'context': ctx,
}
def _send_creation_communication(self):
"""
Sends the 'At Creation' communication plan if it exist for the given coupons.
"""
if self.env.context.get('loyalty_no_mail', False):
return
# Ideally one per program, but multiple is supported
create_comm_per_program = dict()
for program in self.program_id:
create_comm_per_program[program] = program.communication_plan_ids.filtered(lambda c: c.trigger == 'create')
for coupon in self:
if not create_comm_per_program[coupon.program_id] or not coupon._get_mail_partner():
continue
for comm in create_comm_per_program[coupon.program_id]:
comm.mail_template_id.send_mail(res_id=coupon.id, email_layout_xmlid='mail.mail_notification_light')
def _send_points_reach_communication(self, points_changes):
"""
Send the 'When Reaching' communicaton plans for the given coupons.
If a coupons passes multiple milestones we will only send the one with the highest target.
"""
if self.env.context.get('loyalty_no_mail', False):
return
milestones_per_program = dict()
for program in self.program_id:
milestones_per_program[program] = program.communication_plan_ids\
.filtered(lambda c: c.trigger == 'points_reach')\
.sorted('points', reverse=True)
for coupon in self:
if not coupon._get_mail_partner():
continue
coupon_change = points_changes[coupon]
# Do nothing if coupon lost points or did not change
if not milestones_per_program[coupon.program_id] or\
not coupon.partner_id or\
coupon_change['old'] >= coupon_change['new']:
continue
this_milestone = False
for milestone in milestones_per_program[coupon.program_id]:
if coupon_change['old'] < milestone.points and milestone.points <= coupon_change['new']:
this_milestone = milestone
break
if not this_milestone:
continue
this_milestone.mail_template_id.send_mail(res_id=coupon.id, email_layout_xmlid='mail.mail_notification_light')
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res._send_creation_communication()
return res
def write(self, vals):
if not self.env.context.get('loyalty_no_mail', False) and 'points' in vals:
points_before = {coupon: coupon.points for coupon in self}
res = super().write(vals)
if not self.env.context.get('loyalty_no_mail', False) and 'points' in vals:
points_changes = {coupon: {'old': points_before[coupon], 'new': coupon.points} for coupon in self}
self._send_points_reach_communication(points_changes)
return res
+20
View File
@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
# Allow promo programs to send mails upon certain triggers
# Like : 'At creation' and 'When reaching X points'
class LoyaltyMail(models.Model):
_name = 'loyalty.mail'
_description = 'Loyalty Communication'
active = fields.Boolean(default=True)
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
trigger = fields.Selection([
('create', 'At Creation'),
('points_reach', 'When Reaching')], string='When', required=True
)
points = fields.Float()
mail_template_id = fields.Many2one('mail.template', string="Email Template", required=True, domain=[('model', '=', 'loyalty.card')])
+383
View File
@@ -0,0 +1,383 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from uuid import uuid4
class LoyaltyProgram(models.Model):
_name = 'loyalty.program'
_description = 'Loyalty Program'
_order = 'sequence'
_rec_name = 'name'
name = fields.Char('Program Name', required=True, translate=True)
active = fields.Boolean(default=True)
sequence = fields.Integer(copy=False)
company_id = fields.Many2one('res.company', 'Company', default=lambda self: self.env.company)
currency_id = fields.Many2one('res.currency', 'Currency', compute='_compute_currency_id',
readonly=False, required=True, store=True, precompute=True)
currency_symbol = fields.Char(related='currency_id.symbol')
total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count")
rule_ids = fields.One2many('loyalty.rule', 'program_id', 'Triggers', 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)
coupon_ids = fields.One2many('loyalty.card', 'program_id')
coupon_count = fields.Integer(compute='_compute_coupon_count')
program_type = fields.Selection([
('coupons', 'Coupons'),
('gift_card', 'Gift Card'),
('loyalty', 'Loyalty Cards'),
('promotion', 'Promotions'),
('ewallet', 'eWallet')], default='promotion', required=True,
)
date_to = fields.Date(string='Validity')
limit_usage = fields.Boolean(string='Limit Usage')
max_usage = fields.Integer()
# Dictates when the points can be used:
# current: if the order gives enough points on that order, the reward may directly be claimed, points lost otherwise
# future: if the order gives enough points on that order, a coupon is generated for a next order
# both: points are accumulated on the coupon to claim rewards, the reward may directly be claimed
applies_on = fields.Selection([
('current', 'Current order'),
('future', 'Future orders'),
('both', 'Current & Future orders')], default='current', required=True,
compute='_compute_from_program_type', readonly=False, store=True,
)
trigger = fields.Selection([
('auto', 'Automatic'),
('with_code', 'Use a code')],
compute='_compute_from_program_type', readonly=False, store=True,
help="""
Automatic: Customers will be eligible for a reward automatically in their cart.
Use a code: Customers will be eligible for a reward if they enter a code.
"""
)
portal_visible = fields.Boolean(default=False,
help="""
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)
is_nominative = fields.Boolean(compute='_compute_is_nominative')
_sql_constraints = [
('check_max_usage', 'CHECK (limit_usage = False OR max_usage > 0)',
'Max usage must be strictly positive if a limit is used.'),
]
@api.constrains('reward_ids')
def _constrains_reward_ids(self):
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('company_id')
def _compute_currency_id(self):
for program in self:
program.currency_id = program.company_id.currency_id or program.currency_id
@api.depends('coupon_ids')
def _compute_coupon_count(self):
read_group_data = self.env['loyalty.card']._read_group([('program_id', 'in', self.ids)], ['program_id'], ['program_id'])
count_per_program = {r['program_id'][0]: r['program_id_count'] for r in read_group_data}
for program in self:
program.coupon_count = count_per_program.get(program.id, 0)
@api.depends('program_type', 'applies_on')
def _compute_is_nominative(self):
for program in self:
program.is_nominative = program.applies_on == 'both' or\
(program.program_type == 'ewallet' and program.applies_on == 'future')
@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.
return {
'coupons': {
'applies_on': 'current',
'trigger': 'with_code',
'portal_visible': False,
'portal_point_name': _('Points'),
# Coupons don't use rules by default
'rule_ids': [(5, 0, 0)],
'reward_ids': [(5, 0, 0), (0, 0, {
'required_points': 1,
'discount': 10,
})],
'communication_plan_ids': [(5, 0, 0), (0, 0, {
'trigger': 'create',
'mail_template_id': (self.env.ref('loyalty.mail_template_loyalty_card', raise_if_not_found=False) or self.env['mail.template']).id,
})],
},
'promotion': {
'applies_on': 'current',
'trigger': 'auto',
'portal_visible': False,
'portal_point_name': _('Points'),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_amount': 1,
'reward_point_mode': 'order',
'minimum_amount': 50,
})],
'reward_ids': [(5, 0, 0), (0, 0, {
'required_points': 1,
'discount': 10,
})],
'communication_plan_ids': [(5, 0, 0)],
},
'gift_card': {
'applies_on': 'future',
'trigger': 'auto',
'portal_visible': True,
'portal_point_name': self.env.company.currency_id.symbol,
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_amount': 1,
'reward_point_mode': 'money',
'reward_point_split': True,
'product_ids': self.env.ref('loyalty.gift_card_product_50', raise_if_not_found=False),
})],
'reward_ids': [(5, 0, 0), (0, 0, {
'reward_type': 'discount',
'discount_mode': 'per_point',
'discount': 1,
'discount_applicability': 'order',
'required_points': 1,
'description': _('Pay With Gift Card'),
})],
'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,
})],
},
'loyalty': {
'applies_on': 'both',
'trigger': 'auto',
'portal_visible': True,
'portal_point_name': _('Loyalty Points'),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_mode': 'money',
})],
'reward_ids': [(5, 0, 0), (0, 0, {
'discount': 5,
'required_points': 200,
})],
'communication_plan_ids': [(5, 0, 0)],
},
'ewallet': {
'applies_on': 'future',
'trigger': 'auto',
'portal_visible': True,
'portal_point_name': self.env.company.currency_id.symbol,
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_amount': '1',
'reward_point_mode': 'money',
'product_ids': self.env.ref('loyalty.ewallet_product_50'),
})],
'reward_ids': [(5, 0, 0), (0, 0, {
'reward_type': 'discount',
'discount_mode': 'per_point',
'discount': 1,
'discount_applicability': 'order',
'required_points': 1,
})],
'communication_plan_ids': [(5, 0, 0)],
},
}
@api.depends('program_type')
def _compute_from_program_type(self):
program_type_defaults = self._program_type_default_values()
grouped_programs = defaultdict(lambda: self.env['loyalty.program'])
for program in self:
grouped_programs[program.program_type] |= program
for program_type, programs in grouped_programs.items():
if program_type in program_type_defaults:
programs.write(program_type_defaults[program_type])
def _get_valid_products(self, products):
'''
Returns a dict containing the products that match per rule of the program
'''
rule_products = dict()
for rule in self.rule_ids:
domain = rule._get_valid_product_domain()
if domain:
rule_products[rule] = products.filtered_domain(domain)
else:
rule_products[rule] = products
return rule_products
@api.ondelete(at_uninstall=False)
def _unlink_except_active(self):
if any(program.active for program in self):
raise UserError(_('You can not delete a program in an active state'))
def toggle_active(self):
super().toggle_active()
# Propagate active state to children
for program in self:
program.rule_ids.active = program.active
program.reward_ids.active = program.active
program.communication_plan_ids.active = program.active
program.reward_ids.discount_line_product_id.active = program.active
@api.model
def get_program_templates(self):
'''
Returns the templates to be used for promotional programs.
'''
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'),
'icon': 'loyalty_cards',
},
'fidelity': {
'title': _('Fidelity Cards'),
'description': _('Buy 10 products, and get 10$ discount on the 11th one'),
'icon': 'fidelity_cards',
},
'promotion': {
'title': _('Promotional Program'),
'description': _('Automatic promotion: 10% discount on orders higher than $50'),
'icon': 'promotional_program',
},
'coupons': {
'title': _('Coupons'),
'description': _('Send unique coupons that give access to rewards'),
'icon': 'coupons',
},
'buy_two_get_one': {
'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',
},
}
@api.model
def create_from_template(self, template_id):
'''
Creates the program from the template id defined in `get_program_templates`.
Returns an action leading to that new record.
'''
template_values = self._get_template_values()
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['view_type'] = 'form'
action['res_id'] = program.id
return action
@api.model
def _get_template_values(self):
'''
Returns the values to create a program using the template keys defined above.
'''
program_type_defaults = self._program_type_default_values()
# For programs that require a product get the first sellable.
product = self.env['product.product'].search([('sale_ok', '=', True)], limit=1)
return {
'gift_card': {
'name': _('Gift Card'),
'program_type': 'gift_card',
**program_type_defaults['gift_card']
},
'ewallet': {
'name': _('eWallet'),
'program_type': 'ewallet',
**program_type_defaults['ewallet'],
},
'loyalty': {
'name': _('Loyalty Cards'),
'program_type': 'loyalty',
**program_type_defaults['loyalty'],
},
'coupons': {
'name': _('Coupons'),
'program_type': 'coupons',
**program_type_defaults['coupons'],
},
'promotion': {
'name': _('Promotional Program'),
'program_type': 'promotion',
**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,
})]
},
'fidelity': {
'name': _('Fidelity Cards'),
'program_type': 'loyalty',
'applies_on': 'both',
'trigger': 'auto',
'rule_ids': [(0, 0, {
'reward_point_mode': 'unit',
'product_ids': product,
})],
'reward_ids': [(0, 0, {
'discount_mode': 'per_order',
'required_points': 11,
'discount_applicability': 'specific',
'discount_product_ids': product,
'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,
})]
},
}
+196
View File
@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
from odoo import _, api, fields, models
from odoo.osv import expression
class LoyaltyReward(models.Model):
_name = 'loyalty.reward'
_description = 'Loyalty Reward'
_rec_name = 'description'
_order = 'required_points asc'
def _get_discount_mode_select(self):
# The value is provided in the loyalty program's view since we may not have a program_id yet
# and makes sure to display the currency related to the program instead of the company's.
symbol = self.env.context.get('currency_symbol', self.env.company.currency_id.symbol)
return [
('percent', '%'),
('per_point', _('%s per point', symbol)),
('per_order', _('%s per order', symbol))
]
def name_get(self):
return [(reward.id, '%s - %s' % (reward.program_id.name, reward.description)) for reward in self]
active = fields.Boolean(default=True)
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
# Stored for security rules
company_id = fields.Many2one(related='program_id.company_id', store=True)
currency_id = fields.Many2one(related='program_id.currency_id')
description = fields.Char(compute='_compute_description', readonly=False, store=True, translate=True)
reward_type = fields.Selection([
('product', 'Free Product'),
('discount', 'Discount')],
default='discount', required=True,
)
# Discount rewards
discount = fields.Float('Discount', default=10)
discount_mode = fields.Selection(selection=_get_discount_mode_select, required=True, default='percent')
discount_applicability = fields.Selection([
('order', 'Order'),
('cheapest', 'Cheapest Product'),
('specific', 'Specific Products')], default='order',
)
discount_product_domain = fields.Char(default="[]")
discount_product_ids = fields.Many2many('product.product', string="Discounted Products")
discount_product_category_id = fields.Many2one('product.category', string="Discounted Prod. Categories")
discount_product_tag_id = fields.Many2one('product.tag', string="Discounted Prod. Tag")
all_discount_product_ids = fields.Many2many('product.product', compute='_compute_all_discount_product_ids')
discount_max_amount = fields.Monetary('Max Discount', 'currency_id',
help="This is the max amount this reward may discount, leave to 0 for no limit.")
discount_line_product_id = fields.Many2one('product.product', copy=False, ondelete='restrict',
help="Product used in the sales order to apply the discount. Each reward has its own product for reporting purpose")
is_global_discount = fields.Boolean(compute='_compute_is_global_discount')
# Product rewards
reward_product_id = fields.Many2one('product.product', string='Product')
reward_product_tag_id = fields.Many2one('product.tag', string='Product Tag')
multi_product = fields.Boolean(compute='_compute_multi_product')
reward_product_ids = fields.Many2many(
'product.product', string="Reward Products", compute='_compute_multi_product',
help="These are the products that can be claimed with this rule.")
reward_product_qty = fields.Integer(default=1)
reward_product_uom_id = fields.Many2one('uom.uom', compute='_compute_reward_product_uom_id')
required_points = fields.Float('Points needed', default=1)
point_name = fields.Char(related='program_id.portal_point_name', readonly=True)
clear_wallet = fields.Boolean(default=False)
_sql_constraints = [
('required_points_positive', 'CHECK (required_points > 0)',
'The required points for a reward must be strictly positive.'),
('product_qty_positive', "CHECK (reward_type != 'product' OR reward_product_qty > 0)",
'The reward product quantity must be strictly positive.'),
('discount_positive', "CHECK (reward_type != 'discount' OR discount > 0)",
'The discount must be strictly positive.'),
]
@api.depends('reward_product_id.product_tmpl_id.uom_id', 'reward_product_tag_id')
def _compute_reward_product_uom_id(self):
for reward in self:
reward.reward_product_uom_id = reward.reward_product_ids.product_tmpl_id.uom_id[:1]
def _get_discount_product_domain(self):
self.ensure_one()
domain = []
if self.discount_product_ids:
domain = [('id', 'in', self.discount_product_ids.ids)]
if self.discount_product_category_id:
domain = expression.OR([domain, [('categ_id', 'child_of', self.discount_product_category_id.id)]])
if self.discount_product_tag_id:
domain = expression.OR([domain, [('all_product_tag_ids', 'in', self.discount_product_tag_id.id)]])
if self.discount_product_domain and self.discount_product_domain != '[]':
domain = expression.AND([domain, ast.literal_eval(self.discount_product_domain)])
return domain
@api.depends('discount_product_ids', 'discount_product_category_id', 'discount_product_tag_id', 'discount_product_domain')
def _compute_all_discount_product_ids(self):
for reward in self:
reward.all_discount_product_ids = self.env['product.product'].search(reward._get_discount_product_domain())
@api.depends('reward_product_id', 'reward_product_tag_id', 'reward_type')
def _compute_multi_product(self):
for reward in self:
products = reward.reward_product_id + reward.reward_product_tag_id.product_ids
reward.multi_product = reward.reward_type == 'product' and len(products) > 1
reward.reward_product_ids = reward.reward_type == 'product' and products or self.env['product.product']
@api.depends('reward_type', 'reward_product_id', 'discount_mode',
'discount', 'currency_id', 'discount_applicability', 'all_discount_product_ids')
def _compute_description(self):
for reward in self:
reward_string = ""
if reward.reward_type == 'product':
products = reward.reward_product_ids
if len(products) == 1:
reward_string = _('Free Product - %s', reward.reward_product_id.name)
else:
reward_string = _('Free Product - [%s]', ', '.join(products.mapped('name')))
elif reward.reward_type == 'discount':
format_string = '%(amount)g %(symbol)s'
if reward.currency_id.position == 'before':
format_string = '%(symbol)s %(amount)g'
formatted_amount = format_string % {'amount': reward.discount, 'symbol': reward.currency_id.symbol}
if reward.discount_mode == 'percent':
reward_string = _('%g%% on ', reward.discount)
elif reward.discount_mode == 'per_point':
reward_string = _('%s per point on ', formatted_amount)
elif reward.discount_mode == 'per_order':
reward_string = _('%s per order on ', formatted_amount)
if reward.discount_applicability == 'order':
reward_string += _('your order')
elif reward.discount_applicability == 'cheapest':
reward_string += _('the cheapest product')
elif reward.discount_applicability == 'specific':
if len(reward.all_discount_product_ids) == 1:
reward_string += reward.all_discount_product_ids.name
else:
reward_string += _('specific products')
if reward.discount_max_amount:
format_string = '%(amount)g %(symbol)s'
if reward.currency_id.position == 'before':
format_string = '%(symbol)s %(amount)g'
formatted_amount = format_string % {'amount': reward.discount_max_amount, 'symbol': reward.currency_id.symbol}
reward_string += _(' (Max %s)', formatted_amount)
reward.description = reward_string
@api.depends('reward_type', 'discount_applicability', 'discount_mode')
def _compute_is_global_discount(self):
for reward in self:
reward.is_global_discount = reward.reward_type == 'discount' and\
reward.discount_applicability == 'order' and\
reward.discount_mode == 'percent'
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)
products = self.env['product.product'].create(rewards._get_discount_product_values())
for reward, product in zip(rewards, products):
reward.discount_line_product_id = product
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res._create_missing_discount_line_products()
return res
def write(self, vals):
res = super().write(vals)
if 'description' in vals:
self._create_missing_discount_line_products()
# Keep the name of our discount product up to date
for reward in self:
reward.discount_line_product_id.write({'name': reward.description})
return res
def unlink(self):
programs = self.program_id
res = super().unlink()
# Not guaranteed to trigger the constraint
programs._constrains_reward_ids()
return res
def _get_discount_product_values(self):
return [{
'name': reward.description,
'type': 'service',
'sale_ok': False,
'purchase_ok': False,
'lst_price': 0,
} for reward in self]
+107
View File
@@ -0,0 +1,107 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.osv import expression
class LoyaltyRule(models.Model):
_name = 'loyalty.rule'
_description = 'Loyalty Rule'
def _get_reward_point_mode_selection(self):
# The value is provided in the loyalty program's view since we may not have a program_id yet
# and makes sure to display the currency related to the program instead of the company's.
symbol = self.env.context.get('currency_symbol', self.env.company.currency_id.symbol)
return [
('order', _('per order')),
('money', _('per %s spent', symbol)),
('unit', _('per unit paid')),
]
active = fields.Boolean(default=True)
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
# 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
product_domain = fields.Char(default="[]")
product_ids = fields.Many2many('product.product', string='Products')
product_category_id = fields.Many2one('product.category', string='Categories')
product_tag_id = fields.Many2one('product.tag', string='Product Tag')
reward_point_amount = fields.Float(default=1, string="Reward")
# Only used for program_id.applies_on == 'future'
reward_point_split = fields.Boolean(string='Split per unit', default=False,
help="Whether to separate reward coupons per matched unit, only applies to 'future' programs and trigger mode per money spent or unit paid..")
reward_point_name = fields.Char(related='program_id.portal_point_name', readonly=True)
reward_point_mode = fields.Selection(selection=_get_reward_point_mode_selection, required=True, default='order')
minimum_qty = fields.Integer('Minimum Quantity', default=1)
minimum_amount = fields.Monetary('Minimum Purchase', 'currency_id')
minimum_amount_tax_mode = fields.Selection([
('incl', 'Included'),
('excl', 'Excluded')], default='incl', required=True,
)
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)
_sql_constraints = [
('reward_point_amount_positive', 'CHECK (reward_point_amount > 0)', 'Rule points reward must be strictly positive.'),
]
@api.constrains('reward_point_split')
def _constraint_trigger_multi(self):
# Prevent setting trigger multi in case of nominative programs, it does not make sense to allow this
for rule in self:
if rule.reward_point_split and (rule.program_id.applies_on == 'both' or rule.program_id.program_type == 'ewallet'):
raise ValidationError(_('Split per unit is not allowed for Loyalty and eWallet programs.'))
@api.constrains('code')
def _constrains_code(self):
mapped_codes = self.filtered('code').mapped('code')
# Program code must be unique
if len(mapped_codes) != len(set(mapped_codes)) or\
self.env['loyalty.rule'].search_count(
[('mode', '=', 'with_code'), ('code', 'in', mapped_codes), ('id', 'not in', self.ids)]):
raise ValidationError(_('The promo code must be unique.'))
# Prevent coupons and programs from sharing a code
if self.env['loyalty.card'].search_count([('code', 'in', mapped_codes)]):
raise ValidationError(_('A coupon with the same code was found.'))
@api.depends('mode')
def _compute_code(self):
# Reset code when mode is set to auto
for rule in self:
if rule.mode == 'auto':
rule.code = False
def _get_valid_product_domain(self):
self.ensure_one()
domain = []
if self.product_ids:
domain = [('id', 'in', self.product_ids.ids)]
if self.product_category_id:
domain = expression.OR([domain, [('categ_id', 'child_of', self.product_category_id.id)]])
if self.product_tag_id:
domain = expression.OR([domain, [('all_product_tag_ids', 'in', self.product_tag_id.id)]])
if self.product_domain and self.product_domain != '[]':
domain = expression.AND([domain, ast.literal_eval(self.product_domain)])
return domain
def _get_valid_products(self):
self.ensure_one()
return self.env['product.product'].search(self._get_valid_product_domain())
def _compute_amount(self, currency_to):
self.ensure_one()
return self.currency_id._convert(
self.minimum_amount, currency_to, self.company_id, fields.Date.today())
+16
View File
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, models
from odoo.exceptions import ValidationError
class ProductProduct(models.Model):
_inherit = 'product.product'
def write(self, vals):
if not vals.get('active', True) and any(product.active for product in self):
# Prevent archiving products used for giving rewards
rewards = self.env['loyalty.reward'].search([('discount_line_product_id', 'in', self.ids)])
if rewards:
raise ValidationError(_("This product may not be archived. It is being used for an active promotion program."))
return super().write(vals)
+24
View File
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="report_loyalty_card" model="ir.actions.report">
<field name="name">Coupon Code</field>
<field name="model">loyalty.card</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">loyalty.loyalty_report_i18n</field>
<field name="report_file">loyalty.loyalty_report_i18n</field>
<field name="binding_model_id" ref="model_loyalty_card"/>
<field name="binding_type">report</field>
</record>
<record id="report_gift_card" model="ir.actions.report">
<field name="name">Gift Card</field>
<field name="model">loyalty.card</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">loyalty.gift_card_report_i18n</field>
<field name="report_file">loyalty.gift_card_report_i18n</field>
<field name="binding_model_id" ref="model_loyalty_card"/>
<field name="binding_type">report</field>
</record>
</data>
</odoo>
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="loyalty_report">
<t t-call="web.internal_layout">
<div class="card">
<div class="card-body">
<div class="page">
<div class="row text-center">
<div class="o_offer col-lg-12">
<h4 t-if="o._get_mail_partner().name">
Congratulations
<t t-esc="o._get_mail_partner().name"/>,
</h4>
<t t-set="text">on your next order</t>
<h4>Here is your reward from <t t-esc="o.program_id.company_id.name"/>.</h4>
<t t-foreach="range(len(o.program_id.reward_ids))" t-as="reward_idx">
<t t-set="reward" t-value="o.program_id.reward_ids[reward_idx]"/>
<strong><t t-esc="reward.description"/></strong>
<br/>
<t t-if="reward_idx &lt; (len(o.program_id.reward_ids) - 1)">
<span class="text-center">OR</span>
<br/>
</t>
</t>
<h1 class="font-weight-bold" style="font-size: 34px" t-esc="text"/>
<br/>
<h4 t-if="o.expiration_date">
Use this promo code before
<span t-field="o.expiration_date" t-options='{"format": "yyyy-MM-d"}'/>
</h4>
<h2 class="mt-4">
<strong class="bg-light" t-esc="o.code"></strong>
</h2>
<t t-set="rule" t-value="o.program_id.rule_ids[:1]"/>
<h4 t-if="rule.minimum_qty > 1">
<span>Minimum purchase of</span>
<strong t-esc="rule.minimum_qty"/> <span>products</span>
</h4>
<h4 t-if="rule.minimum_amount">
<span>Valid for purchase above</span>
<strong t-esc="rule.minimum_amount" t-options="{'widget': 'monetary', 'display_currency': rule.currency_id}"/>
</h4>
<br/>
<div t-field="o.code" t-options="{'widget': 'barcode', 'width': 600, 'height': 100}"/>
<br/><br/>
<h4>Thank you,</h4>
<br/>
<div class="mt32">
<div class="text-center">
<img alt="Logo" t-att-src="'/logo?company=%d' % (o.program_id.company_id)" t-att-alt="'%s' % (o.program_id.company_id.name)" style="border:0px solid transparent; height: 50; width: 200px;" height="50"/>
</div>
</div>
<div>
<div class="text-center d-inline-block">
<span t-field="o.program_id.company_id.partner_id"
t-options='{"widget": "contact", "fields": ["address", "email"], "no_marker": True}'/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</t>
</template>
<template id="loyalty_report_i18n">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-set="o" t-value="o.with_context(lang=o._get_mail_partner().lang or o.env.lang)"/>
<t t-call="loyalty.loyalty_report" t-lang="o._get_mail_partner().lang or o.env.lang"/>
</t>
</t>
</template>
<template id="gift_card_report">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-call="web.external_layout">
<div style="margin:0px; font-size:24px; font-family:arial, 'helvetica neue', helvetica, sans-serif; line-height:36px; color:#333333; text-align: center">
Here is your gift card!
</div>
<div style="padding-top:20px; padding-bottom:20px">
<img src="/loyalty/static/img/gift_card.png" style="display:block; border:0; outline:none; text-decoration:none; margin:auto;" width="300"/>
</div>
<div style="padding:0; margin:0px; padding-top:35px; padding-bottom:35px; text-align:center;">
<h3 style="margin:0px; line-height:48px; font-family:arial, 'helvetica neue', helvetica, sans-serif; font-size:40px; font-style:normal; font-weight:normal; color:#333333; text-align:center">
<strong><span t-esc="o.points" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/></strong>
</h3>
</div>
<div style="padding:0; margin:0px; padding-top:35px; padding-bottom:35px; background-color:#efefef; text-align:center;">
<p style="margin:0px; font-size:14px;font-family:arial, 'helvetica neue', helvetica, sans-serif; line-height:21px; color:#333333">
<strong>Gift Card Code</strong>
</p>
<p style="margin:0px; font-size:25px;font-family:arial, 'helvetica neue', helvetica, sans-serif; line-height:38px; color:#A9A9A9">
<span t-field="o.code"/>
</p>
</div>
<div style="padding:0; margin:0px; padding-top:10px; padding-bottom:10px; text-align:center;">
<h3 style="margin:0px; line-height:17px; font-family:arial, 'helvetica neue', helvetica, sans-serif; font-size:14px; font-style:normal; font-weight:normal; color:#A9A9A9; text-align:center">
Card expires <span t-field="o.expiration_date"/>
</h3>
</div>
<div style="padding:0; margin:0px; padding-top:10px; padding-bottom:10px; text-align:center;">
<img t-att-src="'/report/barcode/Code128/'+o.code" style="width:200px;height:50px" alt="Barcode"/>
</div>
</t>
</t>
</t>
</template>
<template id="gift_card_report_i18n">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-set="o" t-value="o.with_context(lang=o._get_mail_partner().lang or o.env.lang)"/>
<t t-call="loyalty.gift_card_report" t-lang="o._get_mail_partner().lang or o.env.lang"/>
</t>
</t>
</template>
</odoo>
@@ -0,0 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_loyalty_card,access_loyalty_card,model_loyalty_card,base.group_user,0,0,0,0
access_loyalty_mail,access_loyalty_mail,model_loyalty_mail,base.group_user,0,0,0,0
access_loyalty_program,access_loyalty_program,model_loyalty_program,base.group_user,0,0,0,0
access_loyalty_reward,access_loyalty_reward,model_loyalty_reward,base.group_user,0,0,0,0
access_loyalty_rule,access_loyalty_rule,model_loyalty_rule,base.group_user,0,0,0,0
access_loyalty_generate_wizard,access_loyalty_generate_wizard,model_loyalty_generate_wizard,base.group_user,0,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_loyalty_card access_loyalty_card model_loyalty_card base.group_user 0 0 0 0
3 access_loyalty_mail access_loyalty_mail model_loyalty_mail base.group_user 0 0 0 0
4 access_loyalty_program access_loyalty_program model_loyalty_program base.group_user 0 0 0 0
5 access_loyalty_reward access_loyalty_reward model_loyalty_reward base.group_user 0 0 0 0
6 access_loyalty_rule access_loyalty_rule model_loyalty_rule base.group_user 0 0 0 0
7 access_loyalty_generate_wizard access_loyalty_generate_wizard model_loyalty_generate_wizard base.group_user 0 0 0 0
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<data noupdate="1">
<record id="sale_loyalty_program_company_rule" model="ir.rule">
<field name="name">Loyalty program multi company rule</field>
<field name="model_id" ref="model_loyalty_program"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="sale_loyalty_card_company_rule" model="ir.rule">
<field name="name">Loyalty card multi company rule</field>
<field name="model_id" ref="model_loyalty_card"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="sale_loyalty_rule_company_rule" model="ir.rule">
<field name="name">Loyalty rule multi company rule</field>
<field name="model_id" ref="model_loyalty_rule"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
<record id="sale_loyalty_reward_company_rule" model="ir.rule">
<field name="name">Loyalty reward multi company rule</field>
<field name="model_id" ref="model_loyalty_reward"/>
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
</record>
</data>
</odoo>
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="a"/><g id="b"/><g id="c"/><g id="d"/><g id="e"/><g id="f"/><g id="g"><g><polyline points="32.77 50.17 32.77 30.11 49.81 20.27 50.29 19.74 32.72 9.6 15.15 19.74 15.21 20.13 32.31 30.01 41.24 25.08 24.14 15.2 15.15 19.74 15.15 40.03 32.72 50.17 50.29 40.03 50.29 19.74" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><polyline points="67.34 50.36 67.34 30.29 84.38 20.46 84.85 19.93 67.28 9.78 49.71 19.93 49.77 20.32 66.87 30.19 75.8 25.26 58.7 15.39 49.71 19.93 49.71 40.21 67.28 50.36 84.85 40.21 84.85 19.93" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><polyline points="50.28 90.4 50.28 70.34 67.32 60.5 67.79 59.97 50.23 49.83 32.66 59.97 32.71 60.37 49.82 70.24 58.75 65.31 41.64 55.44 32.66 59.97 32.66 80.26 50.23 90.4 67.79 80.26 67.79 59.97" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/></g></g><g id="h"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="a"/><g id="b"/><g id="c"/><g id="d"/><g id="e"/><g id="f"><g><g><line x1="19.65" y1="42.53" x2="22.48" y2="45.36" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><line x1="28.86" y1="51.74" x2="51.19" y2="74.07" style="fill:none; stroke:#7c6576; stroke-dasharray:0 0 9.02 9.02; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><line x1="54.38" y1="77.26" x2="57.21" y2="80.09" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/></g><path d="M68.28,13.26c-4.89,5.04-4.85,13.09,.13,18.08s13.03,5.02,18.08,.13h.01l4.98,4.97c2.82,2.82,2.82,7.4,0,10.23l-44.8,44.8c-2.82,2.82-7.4,2.82-10.23,0l-4.85-4.85c4.89-5.04,4.85-13.09-.13-18.08s-13.03-5.02-18.08-.13l-4.85-4.85c-2.82-2.82-2.82-7.4,0-10.23L53.33,8.53c2.82-2.82,7.4-2.82,10.23,0l4.72,4.72" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/></g></g><g id="g"/><g id="h"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="a"/><g id="b"/><g id="c"/><g id="d"/><g id="e"/><g id="f"/><g id="g"/><g id="h"><g><g><path d="M94.82,38.97l-3.66-3.65h0l-2.05-2.06-2.83,2.83,2.05,2.05,3.66,3.66c.72,.72,1.12,1.62,1.24,2.55,.17,1.32-.23,2.69-1.24,3.7l-43.4,43.4c-1.01,1.01-2.39,1.41-3.7,1.24-.93-.12-1.84-.52-2.55-1.24l-3.66-3.66-21.51-21.51c-.83-.83-1.29-1.95-1.29-3.12s.46-2.29,1.29-3.13L60.57,16.63c.83-.83,1.95-1.29,3.13-1.29s2.29,.46,3.12,1.29l5.71,5.71,2.83-2.83-12.55-12.55c-1.65-1.65-3.85-2.56-6.19-2.56s-4.54,.91-6.19,2.56L7.49,49.9c-3.41,3.41-3.41,8.96,0,12.38l28.35,28.35h.01l3.65,3.66c1.64,1.64,3.8,2.46,5.95,2.46s4.31-.82,5.95-2.46l43.4-43.4c3.28-3.28,3.28-8.62,0-11.91ZM12.1,61.22l-1.78-1.78c-1.85-1.85-1.85-4.87,0-6.72L53.26,9.79c.93-.93,2.14-1.39,3.36-1.39s2.43,.46,3.36,1.39l1.78,1.78c-1.51,.35-2.89,1.11-4.02,2.23L14.34,57.21c-1.12,1.12-1.88,2.51-2.23,4.02Z" style="fill:#7c6576;"/><path d="M63.42,37.56c-.2,.48-.29,1-.29,1.51s.1,1.03,.29,1.51c.2,.48,.49,.93,.88,1.33l2.42,2.42c.3,.3,.64,.52,1,.71,.58,.3,1.2,.47,1.84,.47,.77,0,1.54-.22,2.2-.66,.22-.15,.43-.32,.63-.51l11.06-11.06,2.83-2.83,.77-.77c.39-.39,.68-.84,.88-1.33,.39-.96,.39-2.05,0-3.02-.2-.48-.49-.93-.88-1.33l-2.42-2.42c-.2-.2-.41-.37-.63-.51-.17-.11-.34-.21-.52-.29-.53-.25-1.11-.37-1.69-.37-1.03,0-2.05,.39-2.83,1.17l-.77,.77-2.83,2.83-11.06,11.06c-.39,.39-.68,.84-.88,1.33Zm4.15-.13c1-1,2.63-1,3.63,0,.48,.48,.75,1.13,.75,1.81,0,.69-.27,1.33-.75,1.82-.5,.5-1.16,.75-1.81,.75s-1.31-.25-1.81-.75c-1-1.01-1-2.63,0-3.63Z" style="fill:#7c6576;"/></g><g><path d="M50.19,83.55c-.51,0-1.02-.2-1.41-.59-1.48-1.49-3.91-1.49-5.39,0-.78,.78-2.05,.78-2.83,0s-.78-2.05,0-2.83c3.05-3.05,8-3.05,11.05,0,.78,.78,.78,2.05,0,2.83-.39,.39-.9,.59-1.41,.59Z" style="fill:#7c6576;"/><path d="M56.51,77.56c-.51,0-1.02-.2-1.41-.59-2.36-2.36-5.5-3.67-8.85-3.67s-6.48,1.3-8.85,3.67c-.78,.78-2.05,.78-2.83,0-.78-.78-.78-2.05,0-2.83,3.12-3.12,7.26-4.84,11.67-4.84s8.56,1.72,11.68,4.84c.78,.78,.78,2.05,0,2.83-.39,.39-.9,.59-1.41,.59Z" style="fill:#7c6576;"/><path d="M62.41,71.53c-.51,0-1.02-.2-1.41-.59-3.96-3.96-9.23-6.14-14.83-6.14s-10.87,2.18-14.83,6.14c-.78,.78-2.05,.78-2.83,0s-.78-2.05,0-2.83c4.72-4.72,10.99-7.31,17.66-7.31s12.94,2.6,17.66,7.31c.78,.78,.78,2.05,0,2.83-.39,.39-.9,.59-1.41,.59Z" style="fill:#7c6576;"/></g></g></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="a"/><g id="b"/><g id="c"/><g id="d"><g><rect x="11.09" y="23.03" width="77.83" height="53.94" rx="7.23" ry="7.23" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><g><path d="M31,34.36l1.17,2.37c.12,.24,.35,.41,.61,.45l2.62,.38c.67,.1,.93,.92,.45,1.39l-1.89,1.85c-.19,.19-.28,.46-.23,.72l.45,2.61c.11,.67-.58,1.17-1.18,.86l-2.34-1.23c-.24-.12-.52-.12-.76,0l-2.34,1.23c-.6,.31-1.3-.19-1.18-.86l.45-2.61c.05-.26-.04-.53-.23-.72l-1.89-1.85c-.48-.47-.22-1.29,.45-1.39l2.62-.38c.27-.04,.49-.21,.61-.45l1.17-2.37c.3-.61,1.16-.61,1.46,0Z" style="fill:#7c6576; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><path d="M51.01,34.36l1.17,2.37c.12,.24,.35,.41,.61,.45l2.62,.38c.67,.1,.93,.92,.45,1.39l-1.89,1.85c-.19,.19-.28,.46-.23,.72l.45,2.61c.11,.67-.58,1.17-1.18,.86l-2.34-1.23c-.24-.12-.52-.12-.76,0l-2.34,1.23c-.6,.31-1.3-.19-1.18-.86l.45-2.61c.05-.26-.04-.53-.23-.72l-1.89-1.85c-.48-.47-.22-1.29,.45-1.39l2.62-.38c.27-.04,.49-.21,.61-.45l1.17-2.37c.3-.61,1.16-.61,1.46,0Z" style="fill:#7c6576; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><path d="M31,54.37l1.17,2.37c.12,.24,.35,.41,.61,.45l2.62,.38c.67,.1,.93,.92,.45,1.39l-1.89,1.85c-.19,.19-.28,.46-.23,.72l.45,2.61c.11,.67-.58,1.17-1.18,.86l-2.34-1.23c-.24-.12-.52-.12-.76,0l-2.34,1.23c-.6,.31-1.3-.19-1.18-.86l.45-2.61c.05-.26-.04-.53-.23-.72l-1.89-1.85c-.48-.47-.22-1.29,.45-1.39l2.62-.38c.27-.04,.49-.21,.61-.45l1.17-2.37c.3-.61,1.16-.61,1.46,0Z" style="fill:#7c6576; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><path d="M51.01,54.37l1.17,2.37c.12,.24,.35,.41,.61,.45l2.62,.38c.67,.1,.93,.92,.45,1.39l-1.89,1.85c-.19,.19-.28,.46-.23,.72l.45,2.61c.11,.67-.58,1.17-1.18,.86l-2.34-1.23c-.24-.12-.52-.12-.76,0l-2.34,1.23c-.6,.31-1.3-.19-1.18-.86l.45-2.61c.05-.26-.04-.53-.23-.72l-1.89-1.85c-.48-.47-.22-1.29,.45-1.39l2.62-.38c.27-.04,.49-.21,.61-.45l1.17-2.37c.3-.61,1.16-.61,1.46,0Z" style="fill:#7c6576; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><path d="M71.03,34.36l1.17,2.37c.12,.24,.35,.41,.61,.45l2.62,.38c.67,.1,.93,.92,.45,1.39l-1.89,1.85c-.19,.19-.28,.46-.23,.72l.45,2.61c.11,.67-.58,1.17-1.18,.86l-2.34-1.23c-.24-.12-.52-.12-.76,0l-2.34,1.23c-.6,.31-1.3-.19-1.18-.86l.45-2.61c.05-.26-.04-.53-.23-.72l-1.89-1.85c-.48-.47-.22-1.29,.45-1.39l2.62-.38c.27-.04,.49-.21,.61-.45l1.17-2.37c.3-.61,1.16-.61,1.46,0Z" style="fill:#7c6576; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/></g></g></g><g id="e"/><g id="f"/><g id="g"/><g id="h"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="a"/><g id="b"><path d="M81.68,21.03H18.32c-5.09,0-9.23,4.14-9.23,9.23v39.47c0,5.09,4.14,9.23,9.23,9.23h63.36c5.09,0,9.23-4.14,9.23-9.23V30.26c0-5.09-4.14-9.23-9.23-9.23Zm-35.04,53.94l40.28-29.08v5.41l-32.78,23.67h-7.49Zm-2.96-30.58l-.46-.16-3.37-1.14-.61-.21c.92-1.19,1.35-2.63,1.28-4.06-.02-.35-.06-.7-.13-1.05-.19-.85-.57-1.66-1.13-2.38l4.86-1.36c.07,.25,.19,.49,.36,.7,.01,.02,.03,.04,.04,.06,.76,.97,1.58,2.29,1.78,3.65,.05,.32,.07,.65,.04,.97v.02c-.12,1.08-.75,2.04-1.92,2.94-.62,.48-.89,1.27-.73,2.01Zm.5-14.53l-.53,.15,6.67-4.82c.07-.05,.12-.11,.18-.16h7.53l-9.93,7.17c-.1-.43-.28-.85-.56-1.22-.79-1.03-2.11-1.47-3.36-1.12Zm-25.86-4.83h25.39l-10.32,7.45-7.5-2.54c-1.45-.49-3.04-.06-4.05,1.09-.63,.71-.95,1.62-.94,2.52-1.36,1.32-2.18,2.83-2.44,4.51-.05,.16-.07,.33-.08,.51,0,.06,0,.12,0,.17-.08,1.3,.17,2.66,.76,4.03l-6.06,4.37V30.26c0-2.89,2.35-5.23,5.23-5.23Zm-5.23,27.04l7.67-5.53c.11,.26,.24,.52,.42,.75,.61,.79,1.53,1.24,2.5,1.24,.29,0,.58-.04,.86-.12l.61-.17v.54l-12.06,8.7v-5.41Zm11.52-7.84c-.04-.13-.08-.25-.15-.37-.06-.12-.13-.23-.21-.33-.67-.85-1.41-2-1.72-3.21-.12-.48-.18-.96-.14-1.44,0-.02,0-.04,0-.05,.12-1.08,.75-2.04,1.92-2.94,.62-.48,.89-1.27,.73-2.01l4.37,1.48,.07,.02c-.79,1.02-1.22,2.22-1.28,3.45-.04,.72,.06,1.44,.27,2.13,.2,.66,.52,1.29,.95,1.87,.01,.01,.02,.03,.03,.04l-.32,.09-4,1.12-.54,.15Zm4.54,6.59v-3.71l5.52-1.55,1.2,.41,3.35,1.13v15.19l-3.71-3.44c-.38-.36-.87-.53-1.36-.53s-.98,.18-1.37,.54l-3.64,3.41v-11.44Zm3.06-11.99c.04-.25,.12-.5,.24-.73,.1-.18,.21-.36,.37-.51,.33-.33,.74-.53,1.17-.6,.12-.02,.25-.04,.37-.04,.56,0,1.12,.21,1.55,.64,.35,.35,.55,.79,.61,1.24,.09,.66-.11,1.35-.61,1.85-.85,.85-2.24,.85-3.09,0-.5-.5-.7-1.19-.61-1.85Zm-19.12,30.91v-7.32l12.06-8.7v13.16c0,.8,.47,1.52,1.2,1.83,.26,.11,.53,.17,.8,.17,.5,0,.99-.19,1.37-.54l5.65-5.28,5.7,5.29c.58,.54,1.43,.68,2.16,.37,.73-.32,1.2-1.04,1.2-1.83v-18.45c1.34,.3,2.74-.14,3.66-1.19,.63-.71,.95-1.62,.94-2.53,1.36-1.32,2.18-2.83,2.44-4.51,.05-.16,.07-.33,.08-.51,0-.06,0-.11,0-.17,.07-1.16-.13-2.36-.58-3.58l15.03-10.85s.05-.05,.08-.07h16.83c2.89,0,5.23,2.35,5.23,5.23v10.69l-46.53,33.59c-.17,.12-.3,.26-.42,.42H18.32c-2.88,0-5.23-2.35-5.23-5.23Zm68.6,5.23h-20.72l25.95-18.73v13.5c0,2.89-2.35,5.23-5.23,5.23Z" style="fill:#7c6576;"/></g><g id="c"/><g id="d"/><g id="e"/><g id="f"/><g id="g"/><g id="h"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="a"/><g id="b"/><g id="c"><g><path d="M69.02,26.08L21.21,73.89c-.78,.78-2.05,.78-2.83,0s-.78-2.05,0-2.83L66.19,23.25c.78-.78,2.05-.78,2.83,0s.78,2.05,0,2.83Z" style="fill:#7c6576;"/><path d="M63.69,20.76L15.89,68.56c-.78,.78-2.05,.78-2.83,0s-.78-2.05,0-2.83L60.86,17.93c.78-.78,2.05-.78,2.83,0s.78,2.05,0,2.83Z" style="fill:#7c6576;"/><path d="M47.77,75.37l-3.64,3.64c-3.03,3.03-7.95,3.03-10.98,0s-3.03-7.95,0-10.98l3.64-3.64c3.03-3.03,7.95-3.03,10.98,0s3.03,7.95,0,10.98Zm-11.79-4.51c-1.47,1.47-1.47,3.86,0,5.32s3.86,1.47,5.32,0l3.64-3.64c1.47-1.47,1.47-3.86,0-5.32s-3.86-1.47-5.32,0l-3.64,3.64Z" style="fill:#7c6576;"/><g><path d="M43.56,88.76c-1.76,1.76-4.62,1.76-6.38,0L12.07,63.64c-1.76-1.76-1.76-4.62,0-6.38L52.39,16.94c1.76-1.76,4.62-1.76,6.38,0l15.72,15.72,1.61,.55,2.63-1.96L61.6,14.11c-3.32-3.32-8.72-3.32-12.03,0L9.24,54.43c-3.32,3.32-3.32,8.72,0,12.03l25.12,25.12c3.32,3.32,8.72,3.32,12.03,0l28.37-28.37-2.34-3.31-28.85,28.85Z" style="fill:#7c6576;"/><path d="M60.44,56.01c-.22-.42-.37-.86-.48-1.31l-3.85,3.85c-.78,.78-.78,2.05,0,2.83s2.05,.78,2.83,0l3.26-3.26c-.72-.55-1.33-1.26-1.76-2.1Z" style="fill:#7c6576;"/><path d="M63.22,40.78l-12.44,12.44c-.78,.78-.78,2.05,0,2.83s2.05,.78,2.83,0l9.92-9.92,1.03-1.38-1.34-3.96Z" style="fill:#7c6576;"/><path d="M85.55,32.64c-.17-.09-.35-.15-.54-.19-.56-.12-1.18-.02-1.72,.38l-1.7,1.27-3.24,2.42-.64,.48c-.28,.21-.61,.35-.95,.4-.34,.05-.69,.03-1.02-.09l-6.59-2.24c-.42-.14-.84-.15-1.21-.05-.38,.1-.72,.3-.99,.57-.14,.14-.25,.29-.35,.45-.19,.33-.3,.72-.28,1.13,0,.14,.05,.29,.08,.44,.02,.06,.01,.12,.03,.18l1.38,4.06,.86,2.53c.06,.17,.09,.34,.11,.51,.04,.52-.1,1.04-.42,1.46l-.04,.05-4.13,5.53c-.88,1.18-.31,2.76,.93,3.26,.25,.1,.52,.17,.82,.17l6.96-.09c.53,0,1.04,.18,1.43,.52,.13,.11,.25,.24,.35,.39l.61,.86,2.34,3.31,1.07,1.51c.12,.17,.25,.3,.4,.42,.04,.04,.09,.07,.14,.1,.11,.07,.22,.14,.33,.19,.06,.02,.11,.05,.17,.07,.12,.04,.24,.07,.37,.09,.05,0,.1,.02,.15,.02,.17,.02,.35,.01,.52-.01,.02,0,.05-.01,.07-.02,.15-.03,.29-.07,.43-.13,.05-.02,.1-.05,.15-.07,.11-.06,.21-.12,.31-.19,.05-.04,.1-.07,.14-.11,.03-.03,.06-.05,.09-.07,.06-.06,.11-.14,.16-.21,.04-.05,.08-.09,.11-.14,.1-.16,.19-.33,.25-.53l2.07-6.65c.1-.34,.29-.64,.53-.88,.18-.18,.4-.32,.64-.42,.08-.04,.15-.08,.24-.11l6.65-2.07c.2-.06,.37-.15,.53-.25,.05-.03,.1-.07,.14-.11,.07-.05,.15-.1,.21-.16,.03-.03,.05-.06,.07-.09,.04-.05,.08-.09,.11-.14,.07-.1,.14-.2,.19-.31,.03-.05,.05-.1,.07-.15,.06-.14,.1-.28,.13-.43,0-.02,.01-.05,.02-.07,.03-.17,.03-.35,.01-.52,0-.05-.02-.1-.03-.15-.02-.12-.05-.25-.09-.37-.02-.06-.04-.11-.07-.17-.05-.11-.12-.22-.19-.33-.03-.05-.06-.1-.1-.14-.12-.14-.26-.28-.42-.4l-4.05-2.87-1.63-1.15c-.14-.1-.27-.22-.39-.35-.34-.39-.53-.9-.52-1.43l.03-2.37,.06-4.6c0-.44-.12-.84-.33-1.17s-.5-.59-.84-.77Z" style="fill:#7c6576;"/></g></g></g><g id="d"/><g id="e"/><g id="f"/><g id="g"/><g id="h"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="a"/><g id="b"/><g id="c"><g><rect x="9.79" y="23.51" width="77.83" height="53.94" rx="7.23" ry="7.23" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><line x1="86.26" y1="43.76" x2="11.14" y2="43.76" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><line x1="11.14" y1="35.4" x2="86.26" y2="35.4" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><g><line x1="69.26" y1="63.57" x2="50.61" y2="63.57" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><line x1="50.61" y1="55.2" x2="77.03" y2="55.2" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/></g><rect x="18.71" y="52.98" width="18.53" height="12.81" rx="6.41" ry="6.41" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><g><path d="M86.23,86.49c-.71,0-1.4-.17-2.04-.51l-6.85-3.6c-.06-.03-.12-.04-.18-.04s-.12,.01-.18,.04l-6.85,3.6c-.64,.34-1.33,.51-2.04,.51-1.29,0-2.52-.57-3.36-1.57-.83-.98-1.18-2.28-.96-3.56l1.31-7.62c.02-.12-.02-.25-.11-.34l-5.54-5.4c-1.2-1.17-1.63-2.89-1.11-4.49,.52-1.6,1.88-2.74,3.54-2.98l7.66-1.11c.12-.02,.23-.1,.29-.21l3.42-6.94c.74-1.51,2.25-2.44,3.93-2.44h0c1.68,0,3.19,.94,3.93,2.44l3.42,6.94c.06,.11,.16,.19,.29,.21l7.65,1.11c1.66,.24,3.02,1.38,3.54,2.98,.52,1.6,.09,3.32-1.11,4.49l-5.54,5.4c-.09,.09-.13,.21-.11,.34l1.31,7.62c.22,1.28-.13,2.57-.96,3.56-.84,1-2.07,1.57-3.36,1.57Z" style="fill:#7c6576;"/><path d="M77.17,51.83c.85,0,1.7,.44,2.14,1.33l3.42,6.94c.35,.7,1.02,1.19,1.79,1.3l7.66,1.11c1.95,.28,2.73,2.68,1.32,4.06l-5.54,5.4c-.56,.55-.82,1.34-.69,2.11l1.31,7.62c.26,1.54-.96,2.79-2.35,2.79-.37,0-.74-.09-1.11-.28l-6.85-3.6c-.35-.18-.73-.27-1.11-.27s-.76,.09-1.11,.27l-6.85,3.6c-.36,.19-.74,.28-1.11,.28-1.39,0-2.61-1.25-2.35-2.79l1.31-7.62c.13-.77-.12-1.56-.69-2.11l-5.54-5.4c-1.41-1.38-.63-3.78,1.32-4.06l7.66-1.11c.78-.11,1.45-.6,1.79-1.3l3.42-6.94c.44-.89,1.29-1.33,2.14-1.33m0-4c-2.45,0-4.64,1.36-5.72,3.56l-3.05,6.17-6.81,.99c-2.42,.35-4.4,2.02-5.15,4.34-.76,2.33-.14,4.83,1.61,6.54l4.93,4.81-1.16,6.79c-.32,1.86,.19,3.75,1.4,5.18,1.22,1.45,3,2.28,4.89,2.28,1.02,0,2.05-.26,2.97-.74l6.09-3.2,6.09,3.2c.92,.48,1.95,.74,2.97,.74,1.88,0,3.67-.83,4.89-2.28,1.21-1.43,1.72-3.32,1.4-5.18l-1.16-6.79,4.93-4.81c1.75-1.71,2.37-4.21,1.61-6.54-.76-2.33-2.73-3.99-5.15-4.34l-6.81-.99-3.05-6.17c-1.08-2.19-3.28-3.56-5.72-3.56h0Z" style="fill:#fff;"/></g></g></g><g id="d"/><g id="e"/><g id="f"/><g id="g"/><g id="h"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="a"><g><path d="M53.33,8.53L8.53,53.33c-2.82,2.82-2.82,7.4,0,10.23l4.85,4.85c5.04-4.89,13.09-4.85,18.08,.13s5.02,13.03,.13,18.08l4.85,4.85c2.82,2.82,7.4,2.82,10.23,0l44.8-44.8c2.82-2.82,2.82-7.4,0-10.23L63.56,8.53c-2.82-2.82-7.4-2.82-10.23,0Z" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><path d="M31.59,86.62c4.89-5.04,4.85-13.09-.13-18.08s-13.03-5.02-18.08-.13" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><g><line x1="19.65" y1="42.53" x2="22.48" y2="45.36" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><line x1="28.86" y1="51.74" x2="51.19" y2="74.07" style="fill:none; stroke:#7c6576; stroke-dasharray:0 0 9.02 9.02; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><line x1="54.38" y1="77.26" x2="57.21" y2="80.09" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/></g><line x1="57.32" y1="59.75" x2="57.32" y2="25.61" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><circle cx="69.58" cy="42.13" r="4.93" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/><circle cx="44.81" cy="42.92" r="4.93" style="fill:none; stroke:#7c6576; stroke-linecap:round; stroke-linejoin:round; stroke-width:4px;"/></g></g><g id="b"/><g id="c"/><g id="d"/><g id="e"/><g id="f"/><g id="g"/><g id="h"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g id="a"/><g id="b"/><g id="c"/><g id="d"/><g id="e"><g><g><path d="M58.32,64.91c-.18,0-.36-.02-.53-.07l-27.73-7.62c-.87-.24-1.47-1.03-1.47-1.93v-19c0-.9,.6-1.69,1.47-1.93l27.73-7.62c.6-.16,1.25-.04,1.74,.34,.5,.38,.79,.97,.79,1.59V62.91c0,.62-.29,1.21-.79,1.59-.35,.27-.78,.41-1.21,.41Zm-25.73-11.15l23.73,6.52V31.29l-23.73,6.52v15.95Z" style="fill:#7c6576;"/><g><path d="M24.32,72.61c.25,.94,.85,1.72,1.7,2.21,.84,.49,1.82,.61,2.76,.36h0c.94-.25,1.72-.85,2.21-1.69,.48-.84,.61-1.82,.36-2.76l-3.64-13.53h-7.53l4.14,15.42Z" style="fill:none;"/><path d="M35.21,69.69l-3.46-12.87c.51-.36,.84-.95,.84-1.63v-18.82c0-1.1-.9-2-2-2h-11.19c-4.46,0-8.09,3.63-8.09,8.09v6.63c0,3.17,1.84,5.92,4.51,7.24,0,.02,0,.05,.02,.07l4.63,17.23c.53,1.97,1.79,3.62,3.56,4.63,1.17,.68,2.48,1.02,3.8,1.02,.67,0,1.33-.09,1.99-.26,1.97-.53,3.61-1.79,4.63-3.56s1.29-3.82,.76-5.79ZM15.3,42.47c0-2.26,1.84-4.09,4.09-4.09h9.19v14.82h-9.19c-1.33,0-2.51-.65-3.26-1.64-.52-.69-.83-1.53-.83-2.45v-6.63Zm15.68,31.01c-.49,.84-1.27,1.44-2.21,1.69h0c-.94,.25-1.92,.12-2.76-.36-.84-.49-1.44-1.27-1.7-2.21l-4.14-15.42h7.53l3.64,13.53c.25,.94,.12,1.92-.36,2.76Z" style="fill:#7c6576;"/></g><g><path d="M69.27,45.79c0-1.52-.97-2.81-2.33-3.31v6.62c1.35-.5,2.33-1.79,2.33-3.31Z" style="fill:none;"/><path d="M66.94,38.36v-12.33c0-2.94-2.39-5.33-5.33-5.33s-5.33,2.39-5.33,5.33v39.53c0,2.94,2.39,5.33,5.33,5.33s5.33-2.39,5.33-5.33v-12.33c3.58-.58,6.33-3.69,6.33-7.43s-2.75-6.85-6.33-7.43Zm-4,.44v26.76c0,.73-.6,1.33-1.33,1.33s-1.33-.6-1.33-1.33V26.02c0-.73,.6-1.33,1.33-1.33s1.33,.6,1.33,1.33v12.77Zm4,10.31v-6.62c1.35,.5,2.33,1.79,2.33,3.31s-.97,2.81-2.33,3.31Z" style="fill:#7c6576;"/></g></g><g><path d="M86.69,47.79h-6.51c-1.1,0-2-.9-2-2s.9-2,2-2h6.51c1.1,0,2,.9,2,2s-.9,2-2,2Z" style="fill:#7c6576;"/><path d="M84.2,64.37c-.51,0-1.02-.2-1.41-.59l-4.42-4.42c-.78-.78-.78-2.05,0-2.83s2.05-.78,2.83,0l4.42,4.42c.78,.78,.78,2.05,0,2.83-.39,.39-.9,.59-1.41,.59Z" style="fill:#7c6576;"/><path d="M79.78,35.63c-.51,0-1.02-.2-1.41-.59-.78-.78-.78-2.05,0-2.83l4.43-4.43c.78-.78,2.05-.78,2.83,0s.78,2.05,0,2.83l-4.43,4.43c-.39,.39-.9,.59-1.41,.59Z" style="fill:#7c6576;"/></g></g></g><g id="f"/><g id="g"/><g id="h"/></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,36 @@
/** @odoo-module **/
import ControlPanelX2Many from 'web.ControlPanelX2Many';
import { FieldOne2Many } from 'web.relational_fields';
import fieldRegistry from 'web.field_registry';
export class LoyaltyControlPanelX2Many extends ControlPanelX2Many {
}
LoyaltyControlPanelX2Many.props = Object.assign({}, ControlPanelX2Many.props, {
label: String,
});
LoyaltyControlPanelX2Many.template = 'LoyaltyControlPanelX2Many';
export const LoyaltyFieldOne2Many = FieldOne2Many.extend({
/**
* @override
* @private
*/
_getControlPanelComponent: function () {
return LoyaltyControlPanelX2Many;
},
/**
* @override
* @private
*/
_getControlPanelContext: function () {
const context = this._super.apply(this);
Object.assign(context, {
label: this.string,
});
return context;
}
});
fieldRegistry.add('loyalty_one2many', LoyaltyFieldOne2Many);
@@ -0,0 +1,23 @@
/** @odoo-module **/
import { LoyaltyModelMixin, LoyaltyRendererMixin } from "@loyalty/js/loyalty_views";
import ListModel from 'web.ListModel';
import ListRenderer from 'web.ListRenderer';
import ListView from 'web.ListView';
import viewRegistry from 'web.view_registry';
export const LoyaltyListModel = ListModel.extend(LoyaltyModelMixin);
export const LoyaltyListRenderer = ListRenderer.extend(LoyaltyRendererMixin, {
events: _.extend({}, ListRenderer.prototype.events, {
'click .loyalty-template': '_onTemplateClick',
}),
});
export const LoyaltyListView = ListView.extend({
config: _.extend({}, ListView.prototype.config, {
Model: LoyaltyListModel,
Renderer: LoyaltyListRenderer,
})
});
viewRegistry.add('loyalty_program_list_view', LoyaltyListView);
@@ -0,0 +1,61 @@
/** @odoo-module **/
import session from 'web.session';
import core from 'web.core';
const qweb = core.qweb;
/*
* This file provides the necessary mixins that can be used on views to change the behaviour of the help
* display (no records) in order to provide templates to the user
*/
export const LoyaltyModelMixin = {
__load: async function () {
const res = await this._super.apply(this, arguments);
this.loyaltyTemplateData = await this._rpc({
model: 'loyalty.program',
method: 'get_program_templates',
context: session.user_context,
});
return res;
},
__get: function () {
const res = this._super.apply(this, arguments);
if (res) {
res.loyaltyTemplateData = this.loyaltyTemplateData;
}
return res;
}
};
export const LoyaltyRendererMixin = {
_renderNoContentHelper: function (context) {
this._super.apply(this, arguments);
if (!this.state.loyaltyTemplateData) {
return;
}
const noContentHelper = this.$('.o_view_nocontent');
if (noContentHelper) {
noContentHelper.append(qweb.render('loyalty_program_helper', {
templateData: this.state.loyaltyTemplateData,
}));
}
},
_onTemplateClick: async function (ev) {
ev.stopPropagation();
const action = await this._rpc({
model: 'loyalty.program',
method: 'create_from_template',
args: [ev.currentTarget.id || ''],
context: session.user_context,
});
if (!action) {
return;
}
this.do_action(action);
}
};
@@ -0,0 +1,57 @@
.o_loyalty_kanban_inline {
width: 100% !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_not_found {
margin-top: -15vh;
}
.loyalty-templates-container {
pointer-events: auto;
margin-top: -5vh;
.loyalty-template {
&, * {
transition: all .15s;
}
cursor: pointer !important;
img {
filter: invert(.5);
}
&:hover {
* {
color: #7C6576 !important;
}
background-color: var(--o-color-4);
box-shadow: 0 6px 10px 0 rgba(0, 0, 0, 0.1), 0 2px 2px 0 rgba(0, 0, 0, 0.05);
border-color: #7C6576 !important;
img {
filter: invert(0);
}
.card-body {
background-color: var(--o-color-4) !important;
}
}
}
}
.loyalty-rule-form {
// The base width for this field is 100px which is problematic for us.
.o_field_widget.o_field_monetary.o_input > input {
width: 100%;
}
}
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<t t-name="loyalty_program_helper">
<div class="container">
<div class="row loyalty-templates-container">
<t t-foreach="Object.entries(templateData)" t-as="data">
<t t-set="loyalty_el_icon" t-value="data[1].icon"/>
<t t-set="loyalty_el_title" t-value="data[1].title"/>
<div class="col-6 col-lg-3 py-4">
<div class="card rounded p-3 d-flex align-items-stretch h-100 loyalty-template" t-att-id="data[0]">
<div class="row m-0 w-100 h-100">
<div class="col-lg-4 p-0">
<div class="d-flex w-100 h-100 align-items-center justify-content-center display-3 p-3 text-muted">
<img t-attf-src="/loyalty/static/img/{{loyalty_el_icon}}.svg" t-attf-alt="{{loyalty_el_title}}"/>
</div>
</div>
<div class="col-lg-8 p-0">
<div class="card-body d-flex flex-column text-center justify-content-center text-lg-left h-100">
<h3 class="card-title" t-out="loyalty_el_title"/>
<p class="card-text" t-out="data[1].description"/>
</div>
</div>
</div>
</div>
</div>
</t>
</div>
</div>
</t>
<t t-name="LoyaltyControlPanelX2Many" owl="1">
<div t-ref="controlPanel">
<h4 class="d-inline"><t t-esc="props.label or ''"/></h4>
<div class="o_cp_buttons float-right" role="toolbar" aria-label="Control panel buttons" t-ref="buttons">
<t t-slot="buttons"/>
</div>
</div>
</t>
</odoo>
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
from . import test_loyalty
+98
View File
@@ -0,0 +1,98 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from psycopg2 import IntegrityError
from odoo.tests import tagged, TransactionCase
from odoo.tools import mute_logger
from unittest.mock import patch
@tagged('post_install', '-at_install')
class TestLoyalty(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.program = cls.env['loyalty.program'].create({
'name': 'Test Program',
'reward_ids': [(0, 0, {})],
})
def test_discount_product_unlink(self):
# Test that we can not unlink dicount line product id
with mute_logger('odoo.sql_db'):
with self.assertRaises(IntegrityError):
with self.cr.savepoint():
self.program.reward_ids.discount_line_product_id.unlink()
def test_loyalty_mail(self):
# Test basic loyalty_mail functionalities
loyalty_card_model_id = self.env.ref('loyalty.model_loyalty_card')
create_tmpl, fifty_tmpl, hundred_tmpl = self.env['mail.template'].create([
{
'name': 'CREATE',
'model_id': loyalty_card_model_id.id,
},
{
'name': '50 points',
'model_id': loyalty_card_model_id.id,
},
{
'name': '100 points',
'model_id': loyalty_card_model_id.id,
},
])
self.program.write({'communication_plan_ids': [
(0, 0, {
'program_id': self.program.id,
'trigger': 'create',
'mail_template_id': create_tmpl.id,
}),
(0, 0, {
'program_id': self.program.id,
'trigger': 'points_reach',
'points': 50,
'mail_template_id': fifty_tmpl.id,
}),
(0, 0, {
'program_id': self.program.id,
'trigger': 'points_reach',
'points': 100,
'mail_template_id': hundred_tmpl.id,
}),
]})
sent_mails = self.env['mail.template']
def mock_send_mail(self, *args, **kwargs):
nonlocal sent_mails
sent_mails |= self
partner = self.env['res.partner'].create({'name': 'Test Partner'})
with patch('odoo.addons.mail.models.mail_template.MailTemplate.send_mail', new=mock_send_mail):
# Send mail at creation
coupon = self.env['loyalty.card'].create({
'program_id': self.program.id,
'partner_id': partner.id,
'points': 0,
})
self.assertEqual(sent_mails, create_tmpl)
sent_mails = self.env['mail.template']
# 50 points mail
coupon.points = 50
self.assertEqual(sent_mails, fifty_tmpl)
sent_mails = self.env['mail.template']
# Check that it does not get sent again
coupon.points = 99
self.assertFalse(sent_mails)
# 100 points mail
coupon.points = 100
self.assertEqual(sent_mails, hundred_tmpl)
sent_mails = self.env['mail.template']
# Reset and go straight to 100 points
coupon.points = 0
self.assertFalse(sent_mails)
coupon.points = 100
self.assertEqual(sent_mails, hundred_tmpl)
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_card_view_form" model="ir.ui.view">
<field name="name">loyalty.card.view.form</field>
<field name="model">loyalty.card</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="code"/>
<field name="expiration_date"/>
<field name="partner_id"/>
<field name="points"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="loyalty_card_view_tree" model="ir.ui.view">
<field name="name">loyalty.card.view.tree</field>
<field name="model">loyalty.card</field>
<field name="arch" type="xml">
<tree string="Coupons" create="false" edit="false" delete="false">
<field name="code"/>
<field name="points" string="Balance"/>
<field name="expiration_date"/>
<field name="program_id"/>
<field name="partner_id"/>
<button name="action_coupon_send" string="Send" type="object" icon="fa-paper-plane-o"/>
</tree>
</field>
</record>
<record id="loyalty_card_action" model="ir.actions.act_window">
<field name="name">Coupons</field>
<field name="res_model">loyalty.card</field>
<field name="view_mode">tree,form</field>
<field name="domain">[('program_id', '=', active_id)]</field>
<field name="context">{'create': False}</field>
<field name="help" type="html">
<h1>No Coupons Found.</h1>
<p>There haven't been any coupons generated yet.</p>
</field>
</record>
</odoo>
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_mail_view_tree" model="ir.ui.view">
<field name="name">loyalty.mail.view.tree</field>
<field name="model">loyalty.mail</field>
<field name="arch" type="xml">
<tree editable="bottom">
<field name="trigger"/>
<field name="points" string="Limit"
attrs="{'required': [('trigger', '=', 'points_reach')], 'invisible': [('trigger', '!=', 'points_reach')]}"/>
<field name="mail_template_id"/>
</tree>
</field>
</record>
</odoo>
@@ -0,0 +1,147 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<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">
<header>
<button name="%(loyalty_generate_wizard_action)d" string="Generate Coupons" class="btn-primary" type="action"
attrs="{'invisible': [('program_type', '!=', 'coupons')]}"/>
<button name="%(loyalty_generate_wizard_action)d" string="Generate Gift Cards" class="btn-primary" type="action"
attrs="{'invisible': [('program_type', '!=', 'gift_card')]}"/>
<button name="%(loyalty_generate_wizard_action)d" string="Generate eWallet" class="btn-primary" type="action"
attrs="{'invisible': [('program_type', '!=', 'ewallet')]}" context="{'default_mode': 'selected'}"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" type="action" name="%(loyalty_card_action)d" 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', '!=', '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>
<field name="active" invisible="1"/>
<div class="oe_title">
<label for="name" string="Program Name"/>
<h1>
<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"/>
<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.
</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.
</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.
<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.
<br/>
Then, eWallets are proposed during the checkout, to pay orders.
</p>
</div>
<field name="company_id" groups="base.group_multi_company"/>
<field name="currency_id"/>
<field name="currency_symbol" invisible="1"/>
</group>
<group>
<field name="date_to"/>
<label for="limit_usage"/>
<span>
<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')]}"/>
</group>
</group>
<notebook>
<page string="Triggers &amp; Rewards" name="rules_rewards">
<group>
<group>
<field name="rule_ids" colspan="2" mode="kanban" nolabel="1" add-label="Add a trigger"
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}"/>
</group>
</group>
</page>
<page string="Communications" name="communications" attrs="{'invisible': [('applies_on', '=', 'current')]}">
<field name="communication_plan_ids" mode="tree"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<record id="loyalty_program_view_tree" model="ir.ui.view">
<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">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="program_type"/>
<field name="active"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="loyalty_program_view_search" model="ir.ui.view">
<field name="name">loyalty.program.view.search</field>
<field name="model">loyalty.program</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<record id="loyalty_program_action" model="ir.actions.act_window">
<field name="name">Promotions, Gift Card, Loyalty</field>
<field name="res_model">loyalty.program</field>
<field name="view_mode">tree,form</field>
<field name="help" type="html">
<div class="o_loyalty_not_found container">
<h1>No loyalty program found.</h1>
<p class="lead font-weight-light">Create a new one from scratch, or use one of the templates below.</p>
</div>
</field>
</record>
</odoo>
@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_reward_view_form" model="ir.ui.view">
<field name="name">loyalty.reward.view.form</field>
<field name="model">loyalty.reward</field>
<field name="arch" type="xml">
<form>
<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 pr-2"/>
<field name="discount_mode" class="oe_edit_only col-4 oe_inline pr-2"/>
<field name="discount" class="oe_read_only"/>
<field name="discount_mode" class="oe_read_only"/>
<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"/>
</group>
<group id="product_group" attrs="{'invisible': [('reward_type', '!=', 'product')]}">
<field name="multi_product" invisible="1"/>
<field name="reward_product_ids" invisible="1"/>
<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>
<field name="required_points"/>
<field name="clear_wallet"/>
</group>
</group>
<group>
<group>
<field name="description"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="loyalty_reward_view_kanban" model="ir.ui.view">
<field name="name">loyalty.reward.view.kanban</field>
<field name="model">loyalty.reward</field>
<field name="arch" type="xml">
<kanban>
<field name="program_id"/>
<field name="company_id"/>
<field name="currency_id"/>
<field name="description"/>
<field name="reward_type"/>
<field name="discount"/>
<field name="discount_mode"/>
<field name="discount_applicability"/>
<field name="discount_product_domain"/>
<field name="discount_max_amount"/>
<field name="discount_line_product_id"/>
<field name="reward_product_id"/>
<field name="reward_product_tag_id"/>
<field name="multi_product"/>
<field name="reward_product_qty"/>
<field name="reward_product_uom_id"/>
<field name="required_points"/>
<field name="point_name"/>
<field name="clear_wallet"/>
<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>
</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>
<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>
<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>
</t>
</templates>
</kanban>
</field>
</record>
</odoo>
+110
View File
@@ -0,0 +1,110 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_rule_view_form" model="ir.ui.view">
<field name="name">loyalty.rule.view.form</field>
<field name="model">loyalty.rule</field>
<field name="arch" type="xml">
<form class="loyalty-rule-form">
<sheet>
<group>
<group>
<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 pr-2"/>
<span>tax</span>
<field name="minimum_amount_tax_mode" class="oe_inline"/>
</div>
</group>
<group>
<field name="mode" widget="radio"/>
<field name="code"
attrs="{'invisible': [('mode', '!=', 'with_code')],
'required': [('mode', '=', 'with_code')]}"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<record id="loyalty_rule_view_kanban" model="ir.ui.view">
<field name="name">loyalty.rule.view.kanban</field>
<field name="model">loyalty.rule</field>
<field name="arch" type="xml">
<kanban>
<field name="program_id"/>
<field name="company_id"/>
<field name="currency_id"/>
<field name="product_domain"/>
<field name="product_ids"/>
<field name="product_category_id"/>
<field name="product_tag_id"/>
<field name="reward_point_amount"/>
<field name="reward_point_split"/>
<field name="reward_point_name"/>
<field name="reward_point_mode"/>
<field name="minimum_qty"/>
<field name="minimum_amount"/>
<field name="minimum_amount_tax_mode"/>
<field name="mode"/>
<field name="code"/>
<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>
<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>
<br/>
</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>
</t>
</templates>
</kanban>
</field>
</record>
</odoo>
@@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import sale_coupon_apply_code
from . import loyalty_generate_wizard
@@ -0,0 +1,69 @@
# -*- 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.osv import expression
class LoyaltyGenerateWizard(models.TransientModel):
_name = 'loyalty.generate.wizard'
_description = 'Generate Coupons'
program_id = fields.Many2one('loyalty.program', required=True, default=lambda self: self.env.context.get('active_id', False))
program_type = fields.Selection(related='program_id.program_type')
mode = fields.Selection([
('anonymous', 'Anonymous Customers'),
('selected', 'Selected Customers')],
string='For', required=True, default='anonymous'
)
customer_ids = fields.Many2many('res.partner', string='Customers')
customer_tag_ids = fields.Many2many('res.partner.category', string='Customer Tags')
coupon_qty = fields.Integer('Quantity',
compute='_compute_coupon_qty', readonly=False, store=True)
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()
def _get_partners(self):
self.ensure_one()
if self.mode != 'selected':
return self.env['res.partner']
domain = []
if self.customer_ids:
domain = [('id', 'in', self.customer_ids.ids)]
if self.customer_tag_ids:
domain = expression.OR([domain, [('category_id', 'in', self.customer_tag_ids.ids)]])
return self.env['res.partner'].search(domain)
@api.depends('customer_ids', 'customer_tag_ids', 'mode')
def _compute_coupon_qty(self):
for wizard in self:
if wizard.mode == 'selected':
wizard.coupon_qty = len(wizard._get_partners())
else:
wizard.coupon_qty = wizard.coupon_qty or 0
def _get_coupon_values(self, partner):
self.ensure_one()
return {
'program_id': self.program_id.id,
'points': self.points_granted,
'expiration_date': self.valid_until,
'partner_id': partner.id if self.mode == 'selected' else False,
}
def generate_coupons(self):
if any(not wizard.program_id for wizard in self):
raise ValidationError(_("Can not generate coupon, no program is set."))
if any(wizard.coupon_qty <= 0 for wizard in self):
raise ValidationError(_("Invalid quantity."))
coupon_create_vals = []
for wizard in self:
customers = wizard._get_partners() or range(wizard.coupon_qty)
for partner in customers:
coupon_create_vals.append(wizard._get_coupon_values(partner))
self.env['loyalty.card'].create(coupon_create_vals)
return True
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_generate_wizard_view_form" model="ir.ui.view">
<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">
<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>
</sheet>
<footer>
<button name="generate_coupons" type="object" string="Generate Coupons" class="btn-primary" data-hotkey="q"/>
<button special="cancel" data-hotkey="z" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="loyalty_generate_wizard_action" model="ir.actions.act_window">
<field name="name">Generate Coupons</field>
<field name="res_model">loyalty.generate.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>
+18
View File
@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Coupons & Loyalty - Delivery',
'summary': "Add a free shipping option to your rewards",
'category': 'Sales',
'version': '1.0',
'depends': ['loyalty', 'delivery'],
'data': [
'data/loyalty_delivery_data.xml',
'views/loyalty_reward_views.xml',
],
'assets': {
},
'installable': True,
'auto_install': True,
'license': 'LGPL-3',
}
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="loyalty.gift_card_product_50" model="product.product">
<field name="tracking">none</field>
</record>
<record id="loyalty.ewallet_product_50" model="product.product">
<field name="tracking">none</field>
</record>
</odoo>
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import gift_card
from . import product
from . import loyalty_program
from . import loyalty_reward
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models
class LoyaltyProgram(models.Model):
_inherit = 'loyalty.program'
@api.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,
}))
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")
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',
})]
return res
@@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, fields, models
class LoyaltyReward(models.Model):
_inherit = 'loyalty.reward'
reward_type = fields.Selection(
selection_add=[('shipping', 'Free Shipping')],
ondelete={'shipping': 'set default'})
def _compute_description(self):
shipping_rewards = self.filtered(lambda r: r.reward_type == 'shipping')
super(LoyaltyReward, self - shipping_rewards)._compute_description()
shipping_rewards.description = _('Free shipping')
for reward in shipping_rewards:
if reward.discount_max_amount:
format_string = '%(amount)g %(symbol)s'
if reward.currency_id.position == 'before':
format_string = '%(symbol)s %(amount)g'
formatted_amount = format_string % {'amount': reward.discount_max_amount, 'symbol': reward.currency_id.symbol}
reward.description += _(' (Max %s)', formatted_amount)
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="loyalty_reward_view_form_inherit_loyalty_delivery" model="ir.ui.view">
<field name="name">loyalty.reward.view.form.inherit.loyalty.delivery</field>
<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')]}">
<field name="discount_max_amount"/>
</group>
</xpath>
</field>
</record>
</odoo>
@@ -11,9 +11,7 @@ class ResConfigSettings(models.TransientModel):
module_pos_adyen = fields.Boolean(string="Adyen Payment Terminal", help="The transactions are processed by Adyen. Set your Adyen credentials on the related payment method.")
module_pos_six = fields.Boolean(string="Six Payment Terminal", help="The transactions are processed by Six. Set the IP address of the terminal on the related payment method.")
update_stock_quantities = fields.Selection(related="company_id.point_of_sale_update_stock_quantities", readonly=False)
module_pos_coupon = fields.Boolean("Coupon and Promotion Programs", help="Allow the use of coupon and promotion programs in PoS.")
account_default_pos_receivable_account_id = fields.Many2one(string='Default Account Receivable (PoS)', related='company_id.account_default_pos_receivable_account_id', readonly=False)
module_pos_gift_card = fields.Boolean("Gift Cards", help="Allow the use of gift card")
def set_values(self):
super(ResConfigSettings, self).set_values()
@@ -66,28 +66,6 @@
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" title="Manage promotion and coupon programs.">
<div class="o_setting_left_pane">
<field name="module_pos_coupon"/>
</div>
<div class="o_setting_right_pane" id="pos-coupon">
<label for="module_pos_coupon" string="Coupons &amp; Promotions"/>
<div class="text-muted">
Manage promotion &amp; coupon programs
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" title="Manage gift card.">
<div class="o_setting_left_pane">
<field name="module_pos_gift_card"/>
</div>
<div class="o_setting_right_pane" id="pos-gift-card">
<label for="module_pos_gift_card" string="Gift card"/>
<div class="text-muted">
Manage gift card
</div>
</div>
</div>
</div>
<h2>Payments</h2>
<div class="row mt16 o_settings_container">
-44
View File
@@ -1,44 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
"name": "Point of Sale Coupons",
"version": "1.0",
"category": "Sales/Point Of Sale",
"sequence": 6,
"summary": "Use coupons in Point of Sale",
"description": "",
"depends": ["coupon", "point_of_sale"],
"data": [
"data/mail_template_data.xml",
'data/default_barcode_patterns.xml',
"security/ir.model.access.csv",
"views/coupon_views.xml",
"views/coupon_program_views.xml",
"views/pos_config_views.xml",
"views/res_config_settings_views.xml",
],
"demo": [
"demo/pos_coupon_demo.xml",
],
"installable": True,
'assets': {
'point_of_sale.assets': [
'pos_coupon/static/src/css/coupon.css',
'pos_coupon/static/src/js/coupon.js',
'pos_coupon/static/src/js/Orderline.js',
'pos_coupon/static/src/js/PaymentScreen.js',
'pos_coupon/static/src/js/ProductScreen.js',
'pos_coupon/static/src/js/OrderWidget.js',
'pos_coupon/static/src/js/ControlButtons/PromoCodeButton.js',
'pos_coupon/static/src/js/ControlButtons/ResetProgramsButton.js',
],
'web.assets_tests': [
'pos_coupon/static/src/js/tours/**/*',
],
'web.assets_qweb': [
'pos_coupon/static/src/xml/**/*',
],
},
'license': 'LGPL-3',
}
@@ -1,102 +0,0 @@
<?xml version="1.0" ?>
<odoo>
<data noupdate="1">
<record id="pos_coupon.mail_coupon_template" model="mail.template">
<field name="name">[POS] Coupon: Send by Email</field>
<field name="model_id" ref="coupon.model_coupon_coupon"/>
<field name="subject">Your reward coupon from {{ object.program_id.company_id.name }} </field>
<field name="email_from">{{ object.program_id.company_id.email }}</field>
<field name="partner_to">{{ object.source_pos_order_id.partner_id.id or object.partner_id.id }}</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; margin:0px auto;"><tbody>
<tr><td valign="top" style="text-align: center; font-size: 14px;">
<t t-if="object.partner_id.name">
Congratulations <t t-out="object.partner_id.name or ''">Brandon Freeman</t>,<br />
</t>
Here is your reward from <t t-out="object.program_id.company_id.name or ''">YourCompany</t>.<br />
<t t-if="object.program_id.reward_type == 'discount'">
<t t-if="object.program_id.discount_type == 'fixed_amount'">
<span style="font-size: 50px; color: #875A7B; font-weight: bold;" t-out="'%s' % format_amount(object.program_id.discount_fixed_amount, object.program_id.currency_id) or ''">$ 10.0</span><br />
<strong style="font-size: 24px;">off on your next order</strong><br />
</t>
<t t-else="">
<span style="font-size: 50px; color: #875A7B; font-weight: bold;">
<t t-out="object.program_id.discount_percentage or ''">10.0</t> %
</span>
<t t-if="object.program_id.discount_apply_on == 'specific_products'">
<br />
<t t-if="len(object.program_id.discount_specific_product_ids) != 1">
<t t-set="display_specific_products" t-value="True"/>
<strong style="font-size: 24px;">
on some products*
</strong>
</t>
<t t-else="">
<strong style="font-size: 24px;" t-out="'on %s' % object.program_id.discount_specific_product_ids.name or ''">Chair floor protection</strong>
</t>
</t>
<t t-elif="object.program_id.discount_apply_on == 'cheapest_product'">
<br /><strong style="font-size: 24px;">
off on the cheapest product
</strong>
</t>
<t t-else="">
<br /><strong style="font-size: 24px;">
off on your next order
</strong>
</t>
<br />
</t>
</t>
<t t-elif="object.program_id.reward_type == 'product'">
<span style="font-size: 36px; color: #875A7B; font-weight: bold;" t-out="'get %s free %s' % (object.program_id.reward_product_quantity, object.program_id.reward_product_id.name) or ''">Chair floor protection</span><br />
<strong style="font-size: 24px;">on your next order</strong><br />
</t>
<t t-elif="object.program_id.reward_type == 'free_shipping'">
<span style="font-size: 36px; color: #875A7B; font-weight: bold;">
get free shipping
</span><br />
<strong style="font-size: 24px;">on your next order</strong><br />
</t>
</td></tr>
<tr style="margin-top: 16px"><td valign="top" style="text-align: center; font-size: 14px;">
Use this promo code
<t t-if="object.expiration_date">
before <t t-out="object.expiration_date or ''">2021-06-05</t>
</t>
<p style="margin-top: 16px;">
<strong style="padding: 16px 8px 16px 8px; border-radius: 3px; background-color: #F1F1F1;" t-out="object.code or ''">13996301932606901095</strong>
</p>
<t t-if="object.program_id.rule_min_quantity not in [0, 1]">
<span style="font-size: 14px;">
Minimum purchase of <t t-out="object.program_id.rule_min_quantity or ''">10</t> products
</span><br />
</t>
<t t-if="object.program_id.rule_minimum_amount != 0.00">
<span style="font-size: 14px;">
Valid for purchase above <t t-out="object.program_id.company_id.currency_id.symbol or ''">$</t><t t-out="'%0.2f' % float(object.program_id.rule_minimum_amount) or ''">10.00</t>
</span><br />
</t>
<t t-if="display_specific_products">
<span>
*Valid for following products: <t t-out="', '.join(object.program_id.discount_specific_product_ids.mapped('name')) or ''">Office Chair Black</t>
</span><br />
</t>
<br/>
Thank you,
<t t-if="object.source_pos_order_id.user_id.signature">
<br />
<t t-out="object.source_pos_order_id.user_id.signature or ''">--<br/>Mitchell Admin</t>
</t>
</td></tr>
</tbody></table>
</field>
<field name="report_template" ref="coupon.report_coupon_code"/>
<field name="report_name">Your Coupon Code</field>
<field name="lang">{{ object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</data>
</odoo>
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="15_pc_on_next_order" model="coupon.program">
<field name="name">15% on next order</field>
<field name="program_type">promotion_program</field>
<field name="promo_applicability">on_next_order</field>
<field name="promo_code_usage">no_code_needed</field>
<field name="rule_minimum_amount">100</field>
<field name="discount_apply_on">on_order</field>
<field name="discount_type">percentage</field>
<field name="discount_percentage">15.0</field>
<field name="validity_duration">0</field>
</record>
<record id="point_of_sale.pos_config_main" model="pos.config">
<field name="use_coupon_programs">True</field>
<field name="promo_program_ids" eval="[(6,0,[ref('coupon.3_cabinets_plus_1_free'),ref('coupon.10_percent_auto_applied'),ref('pos_coupon.15_pc_on_next_order')])]"/>
<field name="coupon_program_ids" eval="[(6,0,[ref('coupon.10_percent_coupon')])]"/>
</record>
<function name="create" model="coupon.generate.wizard">
<value model="coupon.generate.wizard" eval="dict(
obj().default_get(list(obj().fields_get())),
**{
'nbr_coupons': 10,
}
)"/>
</function>
<!-- Create 10 coupons for the 10% coupon program based on the created record above -->
<function name="generate_coupon" model="coupon.generate.wizard">
<value model="coupon.generate.wizard" eval="obj().search([('nbr_coupons', '=', 10)]).id"/>
<value name="context" eval="{'active_id': ref('coupon.10_percent_coupon')}" />
</function>
<function name="unlink" model="coupon.generate.wizard">
<value model="coupon.generate.wizard" eval="obj().search([('nbr_coupons', '=', 10)]).id"/>
</function>
</data>
</odoo>
-451
View File
@@ -1,451 +0,0 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * pos_coupon
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server saas~15.2\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2022-02-11 14:34+0000\n"
"PO-Revision-Date: 2022-02-11 14:34+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: pos_coupon
#: model:mail.template,body_html:pos_coupon.mail_coupon_template
msgid ""
"<table border=\"0\" cellpadding=\"0\" cellspacing=\"0\" style=\"width:100%; margin:0px auto;\"><tbody>\n"
" <tr><td valign=\"top\" style=\"text-align: center; font-size: 14px;\">\n"
" <t t-if=\"object.partner_id.name\">\n"
" Congratulations <t t-out=\"object.partner_id.name or ''\">Brandon Freeman</t>,<br/>\n"
" </t>\n"
"\n"
" Here is your reward from <t t-out=\"object.program_id.company_id.name or ''\">YourCompany</t>.<br/>\n"
"\n"
" <t t-if=\"object.program_id.reward_type == 'discount'\">\n"
" <t t-if=\"object.program_id.discount_type == 'fixed_amount'\">\n"
" <span style=\"font-size: 50px; color: #875A7B; font-weight: bold;\" t-out=\"'%s' % format_amount(object.program_id.discount_fixed_amount, object.program_id.currency_id) or ''\">$ 10.0</span><br/>\n"
" <strong style=\"font-size: 24px;\">off on your next order</strong><br/>\n"
" </t>\n"
" <t t-else=\"\">\n"
" <span style=\"font-size: 50px; color: #875A7B; font-weight: bold;\">\n"
" <t t-out=\"object.program_id.discount_percentage or ''\">10.0</t> %\n"
" </span>\n"
" <t t-if=\"object.program_id.discount_apply_on == 'specific_products'\">\n"
" <br/>\n"
" <t t-if=\"len(object.program_id.discount_specific_product_ids) != 1\">\n"
" <t t-set=\"display_specific_products\" t-value=\"True\"/>\n"
" <strong style=\"font-size: 24px;\">\n"
" on some products*\n"
" </strong>\n"
" </t>\n"
" <t t-else=\"\">\n"
" <strong style=\"font-size: 24px;\" t-out=\"'on %s' % object.program_id.discount_specific_product_ids.name or ''\">Chair floor protection</strong>\n"
" </t>\n"
" </t>\n"
" <t t-elif=\"object.program_id.discount_apply_on == 'cheapest_product'\">\n"
" <br/><strong style=\"font-size: 24px;\">\n"
" off on the cheapest product\n"
" </strong>\n"
" </t>\n"
" <t t-else=\"\">\n"
" <br/><strong style=\"font-size: 24px;\">\n"
" off on your next order\n"
" </strong>\n"
" </t>\n"
" <br/>\n"
" </t>\n"
" </t>\n"
" <t t-elif=\"object.program_id.reward_type == 'product'\">\n"
" <span style=\"font-size: 36px; color: #875A7B; font-weight: bold;\" t-out=\"'get %s free %s' % (object.program_id.reward_product_quantity, object.program_id.reward_product_id.name) or ''\">Chair floor protection</span><br/>\n"
" <strong style=\"font-size: 24px;\">on your next order</strong><br/>\n"
" </t>\n"
" <t t-elif=\"object.program_id.reward_type == 'free_shipping'\">\n"
" <span style=\"font-size: 36px; color: #875A7B; font-weight: bold;\">\n"
" get free shipping\n"
" </span><br/>\n"
" <strong style=\"font-size: 24px;\">on your next order</strong><br/>\n"
" </t>\n"
" </td></tr>\n"
" <tr style=\"margin-top: 16px\"><td valign=\"top\" style=\"text-align: center; font-size: 14px;\">\n"
" Use this promo code\n"
" <t t-if=\"object.expiration_date\">\n"
" before <t t-out=\"object.expiration_date or ''\">2021-06-05</t>\n"
" </t>\n"
" <p style=\"margin-top: 16px;\">\n"
" <strong style=\"padding: 16px 8px 16px 8px; border-radius: 3px; background-color: #F1F1F1;\" t-out=\"object.code or ''\">13996301932606901095</strong>\n"
" </p>\n"
" <t t-if=\"object.program_id.rule_min_quantity not in [0, 1]\">\n"
" <span style=\"font-size: 14px;\">\n"
" Minimum purchase of <t t-out=\"object.program_id.rule_min_quantity or ''\">10</t> products\n"
" </span><br/>\n"
" </t>\n"
" <t t-if=\"object.program_id.rule_minimum_amount != 0.00\">\n"
" <span style=\"font-size: 14px;\">\n"
" Valid for purchase above <t t-out=\"object.program_id.company_id.currency_id.symbol or ''\">$</t><t t-out=\"'%0.2f' % float(object.program_id.rule_minimum_amount) or ''\">10.00</t>\n"
" </span><br/>\n"
" </t>\n"
" <t t-if=\"display_specific_products\">\n"
" <span>\n"
" *Valid for following products: <t t-out=\"', '.join(object.program_id.discount_specific_product_ids.mapped('name')) or ''\">Office Chair Black</t>\n"
" </span><br/>\n"
" </t>\n"
" <br/>\n"
" Thank you,\n"
" <t t-if=\"object.source_pos_order_id.user_id.signature\">\n"
" <br/>\n"
" <t t-out=\"object.source_pos_order_id.user_id.signature or ''\">--<br/>Mitchell Admin</t>\n"
" </t>\n"
" </td></tr>\n"
"</tbody></table>\n"
" "
msgstr ""
#. module: pos_coupon
#: code:addons/pos_coupon/models/coupon.py:0
#, python-format
msgid ""
"A coupon from the same program has already been reserved for this order."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_coupon_program__promo_barcode
msgid ""
"A technical field used as an alternative to the promo_code. This is "
"automatically generated when promo_code is changed."
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/xml/ActivePrograms.xml:0
#, python-format
msgid "Active Programs"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_order__applied_program_ids
msgid "Applied Programs"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_coupon_coupon__pos_order_id
msgid "Applied on PoS Order"
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/js/ProductScreen.js:0
#, python-format
msgid "Are you sure you want to deactivate %s in this order?"
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/xml/OrderReceipt.xml:0
#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__promo_barcode
#, python-format
msgid "Barcode"
msgstr ""
#. module: pos_coupon
#: model:ir.model,name:pos_coupon.model_barcode_rule
msgid "Barcode Rule"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_order__used_coupon_ids
msgid "Consumed Coupons"
msgstr ""
#. module: pos_coupon
#: model:ir.model,name:pos_coupon.model_coupon_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_order_line__coupon_id
#: model:ir.model.fields.selection,name:pos_coupon.selection__barcode_rule__type__coupon
msgid "Coupon"
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/xml/OrderReceipt.xml:0
#, python-format
msgid "Coupon Codes"
msgstr ""
#. module: pos_coupon
#: model:ir.model,name:pos_coupon.model_coupon_program
msgid "Coupon Program"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_config__coupon_program_ids
#: model:ir.ui.menu,name:pos_coupon.menu_coupon_type_config
#: model_terms:ir.ui.view,arch_db:pos_coupon.res_config_view_form_inherit_pos_coupon
msgid "Coupon Programs"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_pos_order_line__coupon_id
msgid "Coupon that generated this reward."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_config__use_coupon_programs
msgid "Coupons & Promotions"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_config__program_ids
msgid "Coupons and Promotions"
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/js/ProductScreen.js:0
#, python-format
msgid "Deactivating program"
msgstr ""
#. module: pos_coupon
#: model_terms:ir.ui.view,arch_db:pos_coupon.pos_coupon_pos_config_view_form
msgid "Define the coupon and promotion programs you can use in this PoS."
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/xml/ControlButtons/PromoCodeButton.xml:0
#, python-format
msgid "Enter Code"
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/js/ControlButtons/PromoCodeButton.js:0
#, python-format
msgid "Enter Promotion or Coupon Code"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_pos_order_line__is_program_reward
msgid ""
"Flag indicating that this order line is a result of coupon/promo program."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_order__generated_coupon_ids
msgid "Generated Coupons"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_order_line__is_program_reward
msgid "Is reward line"
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/js/ProductScreen.js:0
#, python-format
msgid "No"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_coupon_program__pos_order_line_ids
msgid "Order lines where this program is applied."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__pos_order_count
msgid "PoS Order Count"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__pos_order_line_ids
msgid "PoS Order Lines"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_coupon_coupon__source_pos_order_id
msgid "PoS Order Reference"
msgstr ""
#. module: pos_coupon
#: code:addons/pos_coupon/models/coupon_program.py:0
#, python-format
msgid "PoS Orders"
msgstr ""
#. module: pos_coupon
#: model_terms:ir.ui.view,arch_db:pos_coupon.pos_coupon_program_view_coupon_program_form
#: model_terms:ir.ui.view,arch_db:pos_coupon.pos_coupon_program_view_promo_program_form
msgid "PoS Sales"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_coupon_coupon__pos_order_id
msgid "PoS order where this coupon is consumed/booked."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_coupon_coupon__source_pos_order_id
msgid "PoS order where this coupon is generated."
msgstr ""
#. module: pos_coupon
#: model:ir.model,name:pos_coupon.model_pos_config
msgid "Point of Sale Configuration"
msgstr ""
#. module: pos_coupon
#: model:ir.model,name:pos_coupon.model_pos_order_line
msgid "Point of Sale Order Lines"
msgstr ""
#. module: pos_coupon
#: model:ir.model,name:pos_coupon.model_pos_order
msgid "Point of Sale Orders"
msgstr ""
#. module: pos_coupon
#: model:ir.model,name:pos_coupon.model_pos_session
msgid "Point of Sale Session"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__pos_config_ids
msgid "Point of Sales"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__pos_order_ids
msgid "Pos Order"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_order_line__program_id
msgid "Program"
msgstr ""
#. module: pos_coupon
#: code:addons/pos_coupon/models/pos_config.py:0
#, python-format
msgid "Program: %(name)s (%(type)s), Reward Product: `%(reward_product)s`"
msgstr ""
#. module: pos_coupon
#: model_terms:ir.ui.view,arch_db:pos_coupon.pos_coupon_pos_config_view_form
msgid "Promotion & coupon programs to use."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_pos_config__promo_program_ids
#: model:ir.ui.menu,name:pos_coupon.menu_promotion_type_config
#: model_terms:ir.ui.view,arch_db:pos_coupon.res_config_view_form_inherit_pos_coupon
msgid "Promotion Programs"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_pos_order_line__program_id
msgid "Promotion/Coupon Program where this reward line is based."
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/xml/ControlButtons/ResetProgramsButton.xml:0
#, python-format
msgid "Reset Programs"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_pos_order__applied_program_ids
msgid ""
"Technical field. This is set when the order is validated. We normally get "
"this value thru the `program_id` of the reward lines."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_coupon_program__pos_order_ids
msgid "The PoS orders where this program is applied."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_coupon_program__valid_partner_ids
msgid "These are the partners that can avail this program."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_coupon_program__valid_product_ids
msgid "These are the products that are valid in this program."
msgstr ""
#. module: pos_coupon
#: code:addons/pos_coupon/models/pos_config.py:0
#, python-format
msgid "This coupon is invalid (%s)."
msgstr ""
#. module: pos_coupon
#: code:addons/pos_coupon/models/pos_config.py:0
#, python-format
msgid ""
"To continue, make the following reward products to be available in Point of "
"Sale."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_barcode_rule__type
msgid "Type"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,help:pos_coupon.field_pos_config__use_coupon_programs
msgid "Use coupon and promotion programs in this PoS configuration."
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__valid_partner_ids
msgid "Valid Partner"
msgstr ""
#. module: pos_coupon
#: model:ir.model.fields,field_description:pos_coupon.field_coupon_program__valid_product_ids
msgid "Valid Product"
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/xml/OrderReceipt.xml:0
#, python-format
msgid "Valid until:"
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/js/ProductScreen.js:0
#, python-format
msgid "Yes"
msgstr ""
#. module: pos_coupon
#: model:mail.template,report_name:pos_coupon.mail_coupon_template
msgid "Your Coupon Code"
msgstr ""
#. module: pos_coupon
#: model:mail.template,subject:pos_coupon.mail_coupon_template
msgid "Your reward coupon from {{ object.program_id.company_id.name }} "
msgstr ""
#. module: pos_coupon
#: model:mail.template,name:pos_coupon.mail_coupon_template
msgid "[POS] Coupon: Send by Email"
msgstr ""
#. module: pos_coupon
#. openerp-web
#: code:addons/pos_coupon/static/src/xml/OrderReceipt.xml:0
#, python-format
msgid "no expiration"
msgstr ""
-9
View File
@@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import pos_config
from . import pos_order
from . import coupon
from . import coupon_program
from . import barcode_rule
from . import pos_session
-15
View File
@@ -1,15 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
from odoo.tools.translate import _
class BarcodeRule(models.Model):
_inherit = 'barcode.rule'
type = fields.Selection(selection_add=[
('coupon', 'Coupon'),
], ondelete={
'coupon': 'set default',
})
-42
View File
@@ -1,42 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# NOTE Use black to automatically format this code.
from odoo import api, fields, models, _
class Coupon(models.Model):
_inherit = "coupon.coupon"
source_pos_order_id = fields.Many2one(
"pos.order",
string="PoS Order Reference",
help="PoS order where this coupon is generated.",
)
pos_order_id = fields.Many2one(
"pos.order",
string="Applied on PoS Order",
help="PoS order where this coupon is consumed/booked.",
)
def _check_coupon_code(self, order_date, partner_id, **kwargs):
if self.program_id.id in kwargs.get("reserved_program_ids", []):
return {
"error": _("A coupon from the same program has already been reserved for this order.")
}
return super()._check_coupon_code(order_date, partner_id, **kwargs)
def _get_default_template(self):
if self.source_pos_order_id:
return self.env.ref('pos_coupon.mail_coupon_template', False)
return super()._get_default_template()
@api.model
def _generate_code(self):
"""
Modify the generated barcode to be compatible with the default
barcode rule in this module. See `data/default_barcode_patterns.xml`.
"""
code = super()._generate_code()
return '043' + code[3:]
@@ -1,86 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# NOTE Use black to automatically format this code.
from odoo import api, fields, models, _
import ast
class CouponProgram(models.Model):
_inherit = "coupon.program"
pos_config_ids = fields.Many2many(
"pos.config",
string="Point of Sales",
readonly=True,
)
pos_order_line_ids = fields.One2many(
"pos.order.line",
"program_id",
string="PoS Order Lines",
help="Order lines where this program is applied.",
)
promo_barcode = fields.Char(
"Barcode",
default=lambda self: self.env["coupon.coupon"]._generate_code(),
help="A technical field used as an alternative to the promo_code. "
"This is automatically generated when promo_code is changed.",
)
pos_order_ids = fields.Many2many(
"pos.order", help="The PoS orders where this program is applied.",
)
pos_order_count = fields.Integer(
"PoS Order Count", compute="_compute_pos_order_count"
)
valid_product_ids = fields.Many2many(
"product.product",
"Valid Products",
compute="_compute_valid_product_ids",
help="These are the products that are valid in this program.",
)
valid_partner_ids = fields.Many2many(
"res.partner",
"Valid Partners",
compute="_compute_valid_partner_ids",
help="These are the partners that can avail this program.",
)
@api.depends("pos_order_ids")
def _compute_pos_order_count(self):
for program in self:
program.pos_order_count = len(program.pos_order_ids)
def write(self, vals):
if "promo_code" in vals:
vals.update({"promo_barcode": self.env["coupon.coupon"]._generate_code()})
return super(CouponProgram, self).write(vals)
def action_view_pos_orders(self):
self.ensure_one()
return {
"name": _("PoS Orders"),
"view_mode": "tree,form",
"res_model": "pos.order",
"type": "ir.actions.act_window",
"domain": [("id", "in", self.pos_order_ids.ids)],
"context": dict(self._context, create=False),
}
@api.depends("rule_products_domain")
def _compute_valid_product_ids(self):
for program in self:
domain = ast.literal_eval(program.rule_products_domain) if program.rule_products_domain else []
program.valid_product_ids = self.env["product.product"].search(domain).ids
@api.depends("rule_partners_domain")
def _compute_valid_partner_ids(self):
for program in self:
domain = ast.literal_eval(program.rule_partners_domain) if program.rule_partners_domain else []
program.valid_partner_ids = self.env["res.partner"].search(domain).ids
@api.depends('pos_order_ids')
def _compute_total_order_count(self):
super(CouponProgram, self)._compute_total_order_count()
for program in self:
program.total_order_count += len(program.pos_order_ids)
-104
View File
@@ -1,104 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# NOTE Use black to automatically format this code.
from datetime import datetime
from odoo import api, fields, models, _
from odoo.exceptions import UserError
class PosConfig(models.Model):
_inherit = "pos.config"
use_coupon_programs = fields.Boolean(
"Coupons & Promotions",
help="Use coupon and promotion programs in this PoS configuration.",
)
coupon_program_ids = fields.Many2many(
"coupon.program",
string="Coupon Programs",
compute="_filter_programs",
inverse="_set_programs",
)
promo_program_ids = fields.Many2many(
"coupon.program",
string="Promotion Programs",
compute="_filter_programs",
inverse="_set_programs",
)
program_ids = fields.Many2many("coupon.program", string="Coupons and Promotions")
@api.depends("program_ids")
def _filter_programs(self):
for config in self:
config.coupon_program_ids = config.program_ids.filtered(
lambda program: program.program_type == "coupon_program"
)
config.promo_program_ids = config.program_ids.filtered(
lambda program: program.program_type == "promotion_program"
)
def _set_programs(self):
for config in self:
config.program_ids = config.coupon_program_ids | config.promo_program_ids
def open_session_cb(self):
# Check validity of programs before opening a new session
invalid_reward_products_msg = ""
for program in self.program_ids:
if (
program.reward_product_id
and not program.reward_product_id.available_in_pos
):
reward_product = program.reward_product_id
invalid_reward_products_msg += "\n\t"
invalid_reward_products_msg += _(
"Program: %(name)s (%(type)s), Reward Product: `%(reward_product)s`",
name=program.name,
type=program.program_type,
reward_product=reward_product.name,
)
if invalid_reward_products_msg:
intro = _(
"To continue, make the following reward products to be available in Point of Sale."
)
raise UserError(f"{intro}\n{invalid_reward_products_msg}")
return super(PosConfig, self).open_session_cb()
def use_coupon_code(self, code, creation_date, partner_id, reserved_program_ids):
coupon_to_check = self.env["coupon.coupon"].search(
[("code", "=", code), ("program_id", "in", self.program_ids.ids)]
)
# If not unique, we only check the first coupon.
coupon_to_check = coupon_to_check[:1]
if not coupon_to_check:
return {
"successful": False,
"payload": {
"error_message": _("This coupon is invalid (%s).") % (code)
},
}
message = coupon_to_check._check_coupon_code(
fields.Date.from_string(creation_date[:11]),
partner_id,
reserved_program_ids=reserved_program_ids,
)
error_message = message.get("error", False)
if error_message:
return {
"successful": False,
"payload": {"error_message": error_message},
}
coupon_to_check.write({"state": "used"})
return {
"successful": True,
"payload": {
"program_id": coupon_to_check.program_id.id,
"coupon_id": coupon_to_check.id,
},
}
-88
View File
@@ -1,88 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
# NOTE Use black to automatically format this code.
from collections import defaultdict
from odoo import api, fields, models, _
class PosOrder(models.Model):
_inherit = "pos.order"
applied_program_ids = fields.Many2many(
"coupon.program",
string="Applied Programs",
help="Technical field. This is set when the order is validated. "
"We normally get this value thru the `program_id` of the reward lines.",
)
used_coupon_ids = fields.One2many(
"coupon.coupon", "pos_order_id", string="Consumed Coupons"
)
generated_coupon_ids = fields.One2many(
"coupon.coupon", "source_pos_order_id", string="Generated Coupons"
)
def validate_coupon_programs(
self, program_ids_to_generate_coupons, unused_coupon_ids
):
"""This is called after create_from_ui is called. We set here fields
that are used to link programs and coupons to the order.
We also return the generated coupons that can be used in the frontend
to print the generated codes in the receipt.
"""
self.ensure_one()
program_ids_to_generate_coupons = program_ids_to_generate_coupons or []
unused_coupon_ids = unused_coupon_ids or []
self.env["coupon.coupon"].browse(unused_coupon_ids).write({"state": "new"})
self.sudo().write(
{
"applied_program_ids": [(4, i) for i in self.lines.program_id.ids],
"used_coupon_ids": [(4, i) for i in self.lines.coupon_id.ids],
"generated_coupon_ids": [
(4, i)
for i in (
self.env["coupon.program"]
.browse(program_ids_to_generate_coupons)
.sudo()._generate_coupons(self.partner_id.id)
).ids
],
}
)
return [
{
"code": coupon.code,
"expiration_date": coupon.expiration_date,
"program_name": coupon.program_id.name,
}
for coupon in self.generated_coupon_ids
]
def _get_fields_for_order_line(self):
fields = super(PosOrder, self)._get_fields_for_order_line()
fields.extend({
'is_program_reward',
'coupon_id',
'program_id',
})
return fields
class PosOrderLine(models.Model):
_inherit = "pos.order.line"
is_program_reward = fields.Boolean(
"Is reward line",
help="Flag indicating that this order line is a result of coupon/promo program.",
)
program_id = fields.Many2one(
"coupon.program",
string="Program",
help="Promotion/Coupon Program where this reward line is based.",
)
coupon_id = fields.Many2one(
"coupon.coupon", string="Coupon", help="Coupon that generated this reward.",
)
-37
View File
@@ -1,37 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
from odoo.osv.expression import OR
class PosSession(models.Model):
_inherit = 'pos.session'
def _pos_ui_models_to_load(self):
result = super()._pos_ui_models_to_load()
if self.config_id.use_coupon_programs and len(self.config_id.program_ids) > 0:
new_model = 'coupon.program'
if new_model not in result:
result.append(new_model)
return result
def _loader_params_coupon_program(self):
return {
'search_params': {
'domain': [('id', 'in', self.config_id.program_ids.ids), ('active', '=', True)],
'fields': []
},
}
def _get_pos_ui_coupon_program(self, params):
return self.env['coupon.program'].search_read(**params['search_params'])
def _loader_params_product_product(self):
result = super(PosSession, self)._loader_params_product_product()
if self.config_id.use_coupon_programs and len(self.config_id.program_ids) > 0:
discount_product_ids = self.config_id.program_ids.mapped(lambda program: program.discount_line_product_id.id)
reward_product_ids = self.config_id.program_ids.mapped(lambda program: program.reward_product_id.id)
product_ids = [id for id in [*discount_product_ids, *reward_product_ids] if id]
result['search_params']['domain'] = OR([result['search_params']['domain'], [('id', 'in', product_ids)]])
return result
@@ -1,10 +0,0 @@
id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink
access_program_pos_user,Coupon Program (PoS User),coupon.model_coupon_program,point_of_sale.group_pos_user,1,0,0,0
access_program_pos_manager,Coupon Program (PoS Manager),coupon.model_coupon_program,point_of_sale.group_pos_manager,1,1,1,1
access_rule_pos_user,Coupon Rule (PoS User),coupon.model_coupon_rule,point_of_sale.group_pos_user,1,0,0,0
access_rule_pos_manager,Coupon Rule (PoS Manager),coupon.model_coupon_rule,point_of_sale.group_pos_manager,1,1,1,0
access_coupon_pos_user,Coupon (PoS User),coupon.model_coupon_coupon,point_of_sale.group_pos_user,1,0,0,0
access_coupon_pos_manager,Coupon (PoS Manager),coupon.model_coupon_coupon,point_of_sale.group_pos_manager,1,1,1,0
access_reward_pos_user,Coupon Reward (PoS User),coupon.model_coupon_reward,point_of_sale.group_pos_user,1,0,0,0
access_reward_pos_manager,Coupon Reward (PoS Manager),coupon.model_coupon_reward,point_of_sale.group_pos_manager,1,1,1,0
access_coupon_generate_wizard,Coupon Generation,coupon.model_coupon_generate_wizard,point_of_sale.group_pos_user,1,1,1,0
1 id name model_id/id group_id/id perm_read perm_write perm_create perm_unlink
2 access_program_pos_user Coupon Program (PoS User) coupon.model_coupon_program point_of_sale.group_pos_user 1 0 0 0
3 access_program_pos_manager Coupon Program (PoS Manager) coupon.model_coupon_program point_of_sale.group_pos_manager 1 1 1 1
4 access_rule_pos_user Coupon Rule (PoS User) coupon.model_coupon_rule point_of_sale.group_pos_user 1 0 0 0
5 access_rule_pos_manager Coupon Rule (PoS Manager) coupon.model_coupon_rule point_of_sale.group_pos_manager 1 1 1 0
6 access_coupon_pos_user Coupon (PoS User) coupon.model_coupon_coupon point_of_sale.group_pos_user 1 0 0 0
7 access_coupon_pos_manager Coupon (PoS Manager) coupon.model_coupon_coupon point_of_sale.group_pos_manager 1 1 1 0
8 access_reward_pos_user Coupon Reward (PoS User) coupon.model_coupon_reward point_of_sale.group_pos_user 1 0 0 0
9 access_reward_pos_manager Coupon Reward (PoS Manager) coupon.model_coupon_reward point_of_sale.group_pos_manager 1 1 1 0
10 access_coupon_generate_wizard Coupon Generation coupon.model_coupon_generate_wizard point_of_sale.group_pos_user 1 1 1 0
@@ -1,29 +0,0 @@
.pos .order .active-programs {
padding-top: 0.5em;
font-weight: normal;
font-size: 80%;
text-align: right;
margin-right: 15px;
margin-left: 15px;
}
.pos .order .active-programs .title {
padding-top: 0.8em;
padding-bottom: 0.2em;
font-weight: bold;
}
.pos .order .orderline.program-reward {
font-style: italic;
}
.pos-receipt .pos-coupon-rewards {
text-align: center;
padding: 1em;
}
.pos-coupon-rewards .coupon-container {
padding-top: 1em;
font-size: 75%;
}
@@ -1,37 +0,0 @@
odoo.define('pos_coupon.PromoCodeButton', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const ProductScreen = require('point_of_sale.ProductScreen');
const { useListener } = require("@web/core/utils/hooks");
const Registries = require('point_of_sale.Registries');
class PromoCodeButton extends PosComponent {
setup() {
super.setup();
useListener('click', this.onClick);
}
async onClick() {
const { confirmed, payload: code } = await this.showPopup('TextInputPopup', {
title: this.env._t('Enter Promotion or Coupon Code'),
startingValue: '',
});
if (confirmed && code !== '') {
const order = this.env.pos.get_order();
order.activateCode(code);
}
}
}
PromoCodeButton.template = 'PromoCodeButton';
ProductScreen.addControlButton({
component: PromoCodeButton,
condition: function () {
return this.env.pos.config.use_coupon_programs;
},
});
Registries.Component.add(PromoCodeButton);
return PromoCodeButton;
});
@@ -1,31 +0,0 @@
odoo.define('pos_coupon.ResetProgramsButton', function (require) {
'use strict';
const PosComponent = require('point_of_sale.PosComponent');
const ProductScreen = require('point_of_sale.ProductScreen');
const { useListener } = require("@web/core/utils/hooks");
const Registries = require('point_of_sale.Registries');
class ResetProgramsButton extends PosComponent {
setup() {
super.setup();
useListener('click', this.onClick);
}
async onClick() {
const order = this.env.pos.get_order();
order.resetPrograms();
}
}
ResetProgramsButton.template = 'ResetProgramsButton';
ProductScreen.addControlButton({
component: ResetProgramsButton,
condition: function () {
return this.env.pos.config.use_coupon_programs;
},
});
Registries.Component.add(ResetProgramsButton);
return ResetProgramsButton;
});
@@ -1,65 +0,0 @@
odoo.define('pos_coupon.OrderWidget', function (require) {
'use strict';
const OrderWidget = require('point_of_sale.OrderWidget');
const Registries = require('point_of_sale.Registries');
const { onWillRender } = owl;
const PosCouponOrderWidget = (OrderWidget) =>
class PosCouponOrderWidget extends OrderWidget {
setup() {
super.setup();
this.withRewardsPromoPrograms = null;
this.withRewardsBookedCoupons = null;
this.onNextOrderPromoPrograms = null;
this.show = null;
onWillRender(() => this.updateActivePrograms());
}
updateActivePrograms() {
const order = this.env.pos.get_order();
const unRewardedArray = order.rewardsContainer ? order.rewardsContainer.getUnawarded() : [];
const nonGeneratingProgramIds = new Set(unRewardedArray.map(({ program }) => program.id));
const nonGeneratingCouponIds = new Set(
unRewardedArray.map(({ coupon_id }) => coupon_id).filter((coupon_id) => coupon_id)
);
const onNextOrderPromoPrograms = order.activePromoProgramIds
.filter((program_id) => {
const program = order.pos.coupon_programs_by_id[program_id];
return program.promo_applicability === 'on_next_order' && (order.programIdsToGenerateCoupons || []).includes(program_id);
})
.map((program_id) => order.pos.coupon_programs_by_id[program_id]);
const onCurrentOrderPromoProgramIds = order.activePromoProgramIds.filter((program_id) => {
const program = order.pos.coupon_programs_by_id[program_id];
return program.promo_applicability === 'on_current_order';
});
const withRewardsPromoPrograms = onCurrentOrderPromoProgramIds
.filter((program_id) => !nonGeneratingProgramIds.has(program_id))
.map((program_id) => {
const program = order.pos.coupon_programs_by_id[program_id];
return {
name: program.name,
promo_code: program.promo_code,
};
});
const withRewardsBookedCoupons = Object.values(order.bookedCouponCodes)
.filter((couponCode) => !nonGeneratingCouponIds.has(couponCode.coupon_id))
.map((couponCode) => {
let program = order.pos.coupon_programs_by_id[couponCode.program_id];
return {
program_name: program.name,
coupon_code: couponCode.code,
};
});
this.withRewardsPromoPrograms = withRewardsPromoPrograms;
this.withRewardsBookedCoupons = withRewardsBookedCoupons;
this.onNextOrderPromoPrograms = onNextOrderPromoPrograms;
this.show = withRewardsPromoPrograms.length !== 0 ||
withRewardsBookedCoupons.length !== 0 ||
onNextOrderPromoPrograms.length !== 0;
}
};
Registries.Component.extend(OrderWidget, PosCouponOrderWidget);
return OrderWidget;
});
@@ -1,17 +0,0 @@
odoo.define('pos_coupon.Orderline', function (require) {
'use strict';
const Orderline = require('point_of_sale.Orderline');
const Registries = require('point_of_sale.Registries');
const PosCouponOrderline = (Orderline) =>
class extends Orderline {
get addedClasses() {
return Object.assign({ 'program-reward': this.props.line.is_program_reward }, super.addedClasses);
}
};
Registries.Component.extend(Orderline, PosCouponOrderline);
return Orderline;
});
@@ -1,39 +0,0 @@
odoo.define('pos_coupon.PaymentScreen', function (require) {
'use strict';
const PaymentScreen = require('point_of_sale.PaymentScreen');
const Registries = require('point_of_sale.Registries');
const session = require('web.session');
const PosCouponPaymentScreen = (PaymentScreen) =>
class extends PaymentScreen {
async _postPushOrderResolve(order, server_ids) {
const bookedCouponIds = new Set(
Object.values(order.bookedCouponCodes)
.map((couponCode) => couponCode.coupon_id)
.filter((coupon_id) => coupon_id)
);
const usedCouponIds = order.orderlines
.map((line) => line.coupon_id)
.filter((coupon_id) => coupon_id);
for (let coupon_id of usedCouponIds) {
bookedCouponIds.delete(coupon_id);
}
const unusedCouponIds = [...bookedCouponIds.values()];
order.generated_coupons = await this.rpc(
{
model: 'pos.order',
method: 'validate_coupon_programs',
args: [server_ids, order.programIdsToGenerateCoupons || [], unusedCouponIds],
kwargs: { context: session.user_context },
},
{}
);
return super._postPushOrderResolve(order, server_ids);
}
};
Registries.Component.extend(PaymentScreen, PosCouponPaymentScreen);
return PaymentScreen;
});
@@ -1,90 +0,0 @@
odoo.define('pos_coupon.ProductScreen', function (require) {
'use strict';
const ProductScreen = require('point_of_sale.ProductScreen');
const Registries = require('point_of_sale.Registries');
const { useBarcodeReader } = require('point_of_sale.custom_hooks');
const PosCouponProductScreen = (ProductScreen) =>
class extends ProductScreen {
setup() {
super.setup();
useBarcodeReader({
coupon: this._onCouponScan,
});
}
_onCouponScan(code) {
this.currentOrder.activateCode(code.base_code);
}
async _updateSelectedOrderline(event) {
const selectedLine = this.currentOrder.get_selected_orderline();
if (selectedLine && selectedLine.is_program_reward && event.detail.key === 'Backspace') {
const program = this.env.pos.coupon_programs_by_id[selectedLine.program_id]
const { confirmed } = await this.showPopup('ConfirmPopup', {
title: this.env._t('Deactivating program'),
body: _.str.sprintf(
this.env._t('Are you sure you want to deactivate %s in this order?'),
program.name
),
cancelText: this.env._t('No'),
confirmText: this.env._t('Yes'),
});
if (confirmed) {
event.detail.buffer = null;
} else {
return; // do nothing on the line
}
}
return super._updateSelectedOrderline(...arguments);
}
/**
* 1/ Perform the usual set value operation (super._setValue) if the line being modified
* is not a reward line or if it is a reward line, the `val` being set is '' or 'remove' only.
*
* 2/ Update activated programs and coupons when removing a reward line.
*
* 3/ Trigger 'update-rewards' if the line being modified is a regular line or
* if removing a reward line.
*
* @override
*/
_setValue(val) {
const selectedLine = this.currentOrder.get_selected_orderline();
if (
!selectedLine ||
!selectedLine.is_program_reward ||
(selectedLine.is_program_reward && ['', 'remove'].includes(val))
) {
super._setValue(val);
}
if (!selectedLine) return;
if (selectedLine.is_program_reward && val === 'remove') {
if (selectedLine.coupon_id) {
const coupon_code = Object.values(selectedLine.order.bookedCouponCodes).find(
(couponCode) => couponCode.coupon_id === selectedLine.coupon_id
).code;
delete selectedLine.order.bookedCouponCodes[coupon_code];
selectedLine.order.resetCoupons([selectedLine.coupon_id]).then(() => {
this.showNotification(`Coupon (${coupon_code}) has been deactivated.`);
});
} else if (selectedLine.program_id) {
// remove program from active programs
const index = selectedLine.order.activePromoProgramIds.indexOf(selectedLine.program_id);
selectedLine.order.activePromoProgramIds.splice(index, 1);
this.showNotification(
`'${
this.env.pos.coupon_programs_by_id[selectedLine.program_id].name
}' program has been deactivated.`
);
}
}
if (!selectedLine.is_program_reward || (selectedLine.is_program_reward && val === 'remove')) {
selectedLine.order._updateRewards();
}
}
};
Registries.Component.extend(ProductScreen, PosCouponProductScreen);
return ProductScreen;
});
File diff suppressed because it is too large Load Diff
@@ -1,77 +0,0 @@
odoo.define('pos_coupon.tour.pos_coupon1', function (require) {
'use strict';
// --- PoS Coupon Tour Basic Part 1 ---
// Generate coupons for PosCouponTour2.
const { PosCoupon } = require('pos_coupon.tour.PosCouponTourMethods');
const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
const { getSteps, startSteps } = require('point_of_sale.tour.utils');
var Tour = require('web_tour.tour');
startSteps();
ProductScreen.do.confirmOpeningPopup();
ProductScreen.do.clickHomeCategory();
// basic order
// just accept the automatically applied promo program
// applied programs:
// - on cheapest product
ProductScreen.exec.addOrderline('Whiteboard Pen', '5');
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.88');
PosCoupon.do.selectRewardLine('on cheapest product');
PosCoupon.check.orderTotalIs('13.12');
PosCoupon.exec.finalizeOrder('Cash', '20');
// remove the reward from auto promo program
// no applied programs
ProductScreen.exec.addOrderline('Whiteboard Pen', '6');
PosCoupon.check.hasRewardLine('on cheapest product', '-2.88');
PosCoupon.check.orderTotalIs('16.32');
PosCoupon.exec.removeRewardLine('90.0% discount on cheapest product');
PosCoupon.check.orderTotalIs('19.2');
PosCoupon.exec.finalizeOrder('Cash', '20');
// order with coupon code from coupon program
// applied programs:
// - coupon program
ProductScreen.exec.addOrderline('Desk Organizer', '9');
PosCoupon.check.hasRewardLine('on cheapest product', '-4.59');
PosCoupon.exec.removeRewardLine('90.0% discount on cheapest product');
PosCoupon.check.orderTotalIs('45.90');
PosCoupon.do.enterCode('invalid_code');
PosCoupon.do.enterCode('1234');
PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-15.30');
PosCoupon.exec.finalizeOrder('Cash', '50');
// Use coupon but eventually remove the reward
// applied programs:
// - on cheapest product
ProductScreen.exec.addOrderline('Letter Tray', '4');
ProductScreen.exec.addOrderline('Desk Organizer', '9');
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-4.75');
PosCoupon.check.orderTotalIs('62.27');
PosCoupon.do.enterCode('5678');
PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-15.30');
PosCoupon.check.orderTotalIs('46.97');
PosCoupon.exec.removeRewardLine('Free Product - Desk Organizer');
PosCoupon.check.orderTotalIs('62.27');
PosCoupon.exec.finalizeOrder('Cash', '90');
// specific product discount
// applied programs:
// - on cheapest product
// - on specific products
ProductScreen.exec.addOrderline('Magnetic Board', '10') // 1.98
ProductScreen.exec.addOrderline('Desk Organizer', '3') // 5.1
ProductScreen.exec.addOrderline('Letter Tray', '4') // 4.8 tax 10%
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-1.78')
PosCoupon.check.orderTotalIs('54.44')
PosCoupon.do.enterCode('promocode')
PosCoupon.check.hasRewardLine('50.0% discount on products', '-17.55')
PosCoupon.check.orderTotalIs('36.89')
PosCoupon.exec.finalizeOrder('Cash', '50')
Tour.register('PosCouponTour1', { test: true, url: '/pos/web' }, getSteps());
});
@@ -1,110 +0,0 @@
odoo.define('pos_coupon.tour.pos_coupon2', function (require) {
'use strict';
// --- PoS Coupon Tour Basic Part 2 ---
// Using the coupons generated from PosCouponTour1.
const { PosCoupon } = require('pos_coupon.tour.PosCouponTourMethods');
const { ProductScreen } = require('point_of_sale.tour.ProductScreenTourMethods');
const { getSteps, startSteps } = require('point_of_sale.tour.utils');
var Tour = require('web_tour.tour');
startSteps();
ProductScreen.do.clickHomeCategory();
// Cheapest product discount should be replaced by the global discount
// because it's amount is lower.
// Applied programs:
// - global discount
ProductScreen.exec.addOrderline('Desk Organizer', '10'); // 5.1
PosCoupon.check.hasRewardLine('on cheapest product', '-4.59');
ProductScreen.exec.addOrderline('Letter Tray', '4'); // 4.8 tax 10%
PosCoupon.check.hasRewardLine('on cheapest product', '-4.75');
PosCoupon.do.enterCode('123456');
PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-5.10');
PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-2.11');
PosCoupon.check.orderTotalIs('64.91');
PosCoupon.exec.finalizeOrder('Cash', '70');
// Use coupon from global discount but on cheapest discount prevails.
// The global discount coupon should be consumed during the order as it is
// activated in the order. But upon validation, the coupon should return
// to new state.
// Applied programs:
// - on cheapest discount
ProductScreen.exec.addOrderline('Small Shelf', '3'); // 2.83 per item
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.55');
PosCoupon.do.enterCode('345678');
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.55');
ProductScreen.exec.addOrderline('Desk Organizer', '9'); // 4.80 per item
PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-5.44');
ProductScreen.do.pressNumpad('Backspace Backspace')
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.55');
ProductScreen.exec.addOrderline('Desk Pad', '1'); // 1.98
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-1.78');
PosCoupon.check.orderTotalIs('8.69');
PosCoupon.exec.finalizeOrder('Cash', '10');
// Scanning coupon twice.
// Also apply global discount on top of free product to check if the
// calculated discount is correct.
// Applied programs:
// - coupon program (free product)
// - global discount
ProductScreen.exec.addOrderline('Desk Organizer', '11'); // 5.1 per item
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-4.59');
PosCoupon.check.orderTotalIs('51.51');
// add global discount and the discount will be replaced
PosCoupon.do.enterCode('345678');
PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-5.61');
// add free product coupon (for qty=11, free=4)
// the discount should change after having free products
// it should go back to cheapest discount as it is higher
PosCoupon.do.enterCode('5678');
PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-20.40');
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-4.59');
// set quantity to 18
// should result to 'charged qty'=12, 'free qty'=6
ProductScreen.do.pressNumpad('Backspace 8')
PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-6.12');
PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-30.60');
// scan the code again and check notification
PosCoupon.do.enterCode('5678');
PosCoupon.check.orderTotalIs('55.08');
PosCoupon.exec.finalizeOrder('Cash', '60');
// Specific products discount (with promocode) and free product (1357)
// Applied programs:
// - discount on specific products
// - free product
ProductScreen.exec.addOrderline('Desk Organizer', '6'); // 5.1 per item
PosCoupon.check.hasRewardLine('on cheapest product', '-4.59');
PosCoupon.exec.removeRewardLine('90.0% discount on cheapest product');
PosCoupon.do.enterCode('promocode');
PosCoupon.check.hasRewardLine('50.0% discount on products', '-15.30');
PosCoupon.do.enterCode('1357');
PosCoupon.check.hasRewardLine('Free Product - Desk Organizer', '-10.20');
PosCoupon.check.hasRewardLine('50.0% discount on products', '-10.20');
PosCoupon.check.orderTotalIs('10.20');
PosCoupon.exec.finalizeOrder('Cash', '20');
// Check reset program
// Enter two codes and reset the programs.
// The codes should be checked afterwards. They should return to new.
// Applied programs:
// - cheapest product
PosCoupon.do.enterCode('2468');
PosCoupon.do.enterCode('098765');
ProductScreen.exec.addOrderline('Monitor Stand', '6'); // 3.19 per item
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.87');
PosCoupon.check.orderTotalIs('16.27');
PosCoupon.exec.removeRewardLine('90.0% discount on cheapest product');
PosCoupon.check.hasRewardLine('10.0% discount on total amount', '-1.91');
PosCoupon.do.resetActivePrograms();
PosCoupon.check.hasRewardLine('90.0% discount on cheapest product', '-2.87');
PosCoupon.check.orderTotalIs('16.27');
PosCoupon.exec.finalizeOrder('Cash', '20');
Tour.register('PosCouponTour2', { test: true, url: '/pos/web' }, getSteps());
});
@@ -1,40 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="pos_coupon.OrderReceipt" t-inherit="point_of_sale.OrderReceipt" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('before-footer')]" position="inside">
<t t-if="receipt.generated_coupons and receipt.generated_coupons.length !== 0">
<div class="pos-coupon-rewards">
<div>------------------------</div>
<br/>
<div>
Coupon Codes
</div>
<t t-foreach="receipt.generated_coupons" t-as="coupon_info" t-key="coupon_info.code">
<div class="coupon-container">
<div style="font-size: 110%;">
<t t-esc="coupon_info['program_name']"/>
</div>
<div>
<span>Valid until: </span>
<t t-if="coupon_info['expiration_date']">
<t t-esc="coupon_info['expiration_date']"/>
</t>
<t t-else="">
no expiration
</t>
</div>
<div>
<img t-att-src="'/report/barcode/Code128/'+coupon_info['code']" style="width:200px;height:50px" alt="Barcode"/>
</div>
<div>
<t t-esc="coupon_info['code']"/>
</div>
</div>
</t>
</div>
</t>
</xpath>
</t>
</templates>

Some files were not shown because too many files have changed in this diff Show More