19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,112 +1,208 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from uuid import uuid4
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'
_description = "Loyalty Program"
_order = 'sequence'
_rec_name = 'name'
name = fields.Char('Program Name', required=True, translate=True)
@api.model
def default_get(self, fields):
defaults = super().default_get(fields)
program_type = defaults.get('program_type')
if program_type:
program_default_values = self._program_type_default_values()
if program_type in program_default_values:
default_values = program_default_values[program_type]
defaults.update({k: v for k, v in default_values.items() if k in fields})
return defaults
name = fields.Char(string="Program Name", translate=True, required=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)
company_id = fields.Many2one(
string="Company", comodel_name='res.company', default=lambda self: self.env.company
)
currency_id = fields.Many2one(
string="Currency",
comodel_name='res.currency',
compute='_compute_currency_id',
precompute=True,
store=True,
readonly=False,
required=True,
)
currency_symbol = fields.Char(related='currency_id.symbol')
pricelist_ids = fields.Many2many(
string="Pricelist",
help="This program is specific to this pricelist set.",
comodel_name='product.pricelist',
domain="[('currency_id', '=', currency_id)]",
)
total_order_count = fields.Integer("Total Order Count", compute="_compute_total_order_count")
total_order_count = fields.Integer(
string="Total Order Count", compute='_compute_total_order_count'
)
rule_ids = fields.One2many('loyalty.rule', 'program_id', 'Conditional rules', copy=True,
compute='_compute_from_program_type', readonly=False, store=True)
reward_ids = fields.One2many('loyalty.reward', 'program_id', 'Rewards', copy=True,
compute='_compute_from_program_type', readonly=False, store=True)
communication_plan_ids = fields.One2many('loyalty.mail', 'program_id', copy=True,
compute='_compute_from_program_type', readonly=False, store=True)
rule_ids = fields.One2many(
string="Conditional rules",
comodel_name='loyalty.rule',
inverse_name='program_id',
compute='_compute_from_program_type',
store=True,
readonly=False,
copy=True,
)
reward_ids = fields.One2many(
string="Rewards",
comodel_name='loyalty.reward',
inverse_name='program_id',
compute='_compute_from_program_type',
store=True,
readonly=False,
copy=True,
)
communication_plan_ids = fields.One2many(
comodel_name='loyalty.mail',
inverse_name='program_id',
compute='_compute_from_program_type',
store=True,
readonly=False,
copy=True,
)
# These fields are used for the simplified view of gift_card and ewallet
mail_template_id = fields.Many2one('mail.template', compute='_compute_mail_template_id', inverse='_inverse_mail_template_id', string="Email template", readonly=False)
mail_template_id = fields.Many2one(
string="Email template",
comodel_name='mail.template',
compute='_compute_mail_template_id',
inverse='_inverse_mail_template_id',
readonly=False,
)
trigger_product_ids = fields.Many2many(related='rule_ids.product_ids', readonly=False)
coupon_ids = fields.One2many('loyalty.card', 'program_id')
coupon_ids = fields.One2many(comodel_name='loyalty.card', inverse_name='program_id')
coupon_count = fields.Integer(compute='_compute_coupon_count')
coupon_count_display = fields.Char(compute='_compute_coupon_count_display', string="Items")
coupon_count_display = fields.Char(string="Items", compute='_compute_coupon_count_display')
program_type = fields.Selection([
('coupons', 'Coupons'),
('gift_card', 'Gift Card'),
('loyalty', 'Loyalty Cards'),
('promotion', 'Promotions'),
('ewallet', 'eWallet'),
('promo_code', 'Discount Code'),
('buy_x_get_y', 'Buy X Get Y'),
('next_order_coupons', 'Next Order Coupons')],
default='promotion', required=True,
program_type = fields.Selection(
selection=[
('coupons', "Coupons"),
('gift_card', "Gift Card"),
('loyalty', "Loyalty Cards"),
('promotion', "Promotions"),
('ewallet', "eWallet"),
('promo_code', "Discount Code"),
('buy_x_get_y', "Buy X Get Y"),
('next_order_coupons', "Next Order Coupons"),
],
required=True,
default='promotion',
)
date_to = fields.Date(string='Validity')
limit_usage = fields.Boolean(string='Limit Usage')
date_from = fields.Date(
string="Start Date",
help="The start date is included in the validity period of this program",
)
date_to = fields.Date(
string="End date",
help="The end date is included in the validity period of this program",
)
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,
applies_on = fields.Selection(
selection=[
('current', "Current order"),
('future', "Future orders"),
('both', "Current & Future orders"),
],
compute='_compute_from_program_type',
store=True,
readonly=False,
required=True,
default='current',
)
trigger = fields.Selection([
('auto', 'Automatic'),
('with_code', 'Use a code')],
compute='_compute_from_program_type', readonly=False, store=True,
trigger = fields.Selection(
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.
"""
""",
selection=[('auto', "Automatic"), ('with_code', "Use a code")],
compute='_compute_from_program_type',
store=True,
readonly=False,
)
portal_visible = fields.Boolean(default=False,
portal_visible = fields.Boolean(
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_portal_point_name', readonly=False, store=True)
Show in web portal, PoS customer ticket, eCommerce checkout, the number of points available
and used by reward.
""",
default=False,
)
portal_point_name = fields.Char(
translate=True,
compute='_compute_portal_point_name',
store=True,
readonly=False,
default='Points',
)
is_nominative = fields.Boolean(compute='_compute_is_nominative')
is_payment_program = fields.Boolean(compute='_compute_is_payment_program')
payment_program_discount_product_id = fields.Many2one(
'product.product',
string='Discount Product',
string="Discount Product",
help="Product used in the sales order to apply the discount.",
comodel_name='product.product',
compute='_compute_payment_program_discount_product_id',
readonly=True,
help="Product used in the sales order to apply the discount."
)
# Technical field used for a label
available_on = fields.Boolean("Available On", store=False,
help="""
Manage where your program should be available for use.
"""
available_on = fields.Boolean(
string="Available On",
help="Manage where your program should be available for use.",
store=False,
)
_sql_constraints = [
('check_max_usage', 'CHECK (limit_usage = False OR max_usage > 0)',
'Max usage must be strictly positive if a limit is used.'),
]
_check_max_usage = models.Constraint(
'CHECK (limit_usage = False OR max_usage > 0)',
"Max usage must be strictly positive if a limit is used.",
)
@api.constrains('currency_id', 'pricelist_ids')
def _check_pricelist_currency(self):
if any(
pricelist.currency_id != program.currency_id
for program in self
for pricelist in program.pricelist_ids
):
raise UserError(_(
"The loyalty program's currency must be the same as all it's pricelists ones."
))
@api.constrains('date_from', 'date_to')
def _check_date_from_date_to(self):
if any(p.date_to and p.date_from and p.date_from > p.date_to for p in self):
raise UserError(_(
"The validity period's start date must be anterior or equal to its end date."
))
@api.constrains('reward_ids')
def _constrains_reward_ids(self):
if self.env.context.get('loyalty_skip_reward_check'):
return
if any(not program.reward_ids for program in self):
raise ValidationError(_('A program must have at least one reward.'))
raise ValidationError(_("A program must have at least one reward."))
def _compute_total_order_count(self):
self.total_order_count = 0
@ -117,14 +213,14 @@ class LoyaltyProgram(models.Model):
for program in self:
program.coupon_count_display = "%i %s" % (program.coupon_count or 0, program_items_name[program.program_type] or '')
@api.depends("communication_plan_ids.mail_template_id")
@api.depends('communication_plan_ids.mail_template_id')
def _compute_mail_template_id(self):
for program in self:
program.mail_template_id = program.communication_plan_ids.mail_template_id[:1]
def _inverse_mail_template_id(self):
for program in self:
if program.program_type not in ("gift_card", "ewallet"):
if program.program_type not in ('gift_card', 'ewallet'):
continue
if not program.mail_template_id:
program.communication_plan_ids = [(5, 0, 0)]
@ -147,8 +243,8 @@ class LoyaltyProgram(models.Model):
@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}
read_group_data = self.env['loyalty.card']._read_group([('program_id', 'in', self.ids)], ['program_id'], ['__count'])
count_per_program = {program.id: count for program, count in read_group_data}
for program in self:
program.coupon_count = count_per_program.get(program.id, 0)
@ -156,7 +252,7 @@ class LoyaltyProgram(models.Model):
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')
(program.program_type in ('ewallet', 'loyalty') and program.applies_on == 'future')
@api.depends('program_type')
def _compute_is_payment_program(self):
@ -174,30 +270,27 @@ class LoyaltyProgram(models.Model):
@api.model
def _program_items_name(self):
return {
'coupons': _('Coupons'),
'promotion': _('Promos'),
'gift_card': _('Gift Cards'),
'loyalty': _('Loyalty Cards'),
'ewallet': _('eWallets'),
'promo_code': _('Discounts'),
'buy_x_get_y': _('Promos'),
'next_order_coupons': _('Coupons'),
'coupons': _("Coupons"),
'promotion': _("Promos"),
'gift_card': _("Gift Cards"),
'loyalty': _("Loyalty Cards"),
'ewallet': _("eWallets"),
'promo_code': _("Discounts"),
'buy_x_get_y': _("Promos"),
'next_order_coupons': _("Coupons"),
}
@api.model
def _program_type_default_values(self):
# All values to change when program_type changes
# NOTE: any field used in `rule_ids`, `reward_ids` and `communication_plan_ids` MUST be present in the kanban view for it to work properly.
first_sale_product = self.env['product.product'].search([
'|', ('company_id', '=', False), ('company_id', '=', self.company_id.id),
('sale_ok', '=', True)
], limit=1)
first_sale_product = self.env['product.product'].search([('company_id', 'in', [False, self.env.company.id]), ('sale_ok', '=', True)], limit=1)
return {
'coupons': {
'applies_on': 'current',
'trigger': 'with_code',
'portal_visible': False,
'portal_point_name': _('Coupon point(s)'),
'portal_point_name': _("Coupon point(s)"),
'rule_ids': [(5, 0, 0)],
'reward_ids': [(5, 0, 0), (0, 0, {
'required_points': 1,
@ -212,7 +305,7 @@ class LoyaltyProgram(models.Model):
'applies_on': 'current',
'trigger': 'auto',
'portal_visible': False,
'portal_point_name': _('Promo point(s)'),
'portal_point_name': _("Promo point(s)"),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_amount': 1,
'reward_point_mode': 'order',
@ -243,7 +336,7 @@ class LoyaltyProgram(models.Model):
'discount': 1,
'discount_applicability': 'order',
'required_points': 1,
'description': _('Gift Card'),
'description': _("Gift Card"),
})],
'communication_plan_ids': [(5, 0, 0), (0, 0, {
'trigger': 'create',
@ -254,7 +347,7 @@ class LoyaltyProgram(models.Model):
'applies_on': 'both',
'trigger': 'auto',
'portal_visible': True,
'portal_point_name': _('Loyalty point(s)'),
'portal_point_name': _("Loyalty point(s)"),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_mode': 'money',
})],
@ -272,6 +365,7 @@ class LoyaltyProgram(models.Model):
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_amount': '1',
'reward_point_mode': 'money',
'reward_point_split': False,
'product_ids': self.env.ref('loyalty.ewallet_product_50', raise_if_not_found=False),
})],
'reward_ids': [(5, 0, 0), (0, 0, {
@ -280,7 +374,7 @@ class LoyaltyProgram(models.Model):
'discount': 1,
'discount_applicability': 'order',
'required_points': 1,
'description': _('eWallet'),
'description': _("eWallet"),
})],
'communication_plan_ids': [(5, 0, 0)],
},
@ -288,7 +382,7 @@ class LoyaltyProgram(models.Model):
'applies_on': 'current',
'trigger': 'with_code',
'portal_visible': False,
'portal_point_name': _('Discount point(s)'),
'portal_point_name': _("Discount point(s)"),
'rule_ids': [(5, 0, 0), (0, 0, {
'mode': 'with_code',
'code': 'PROMO_CODE_' + str(uuid4())[:4], # We should try not to trigger any unicity constraint
@ -306,7 +400,7 @@ class LoyaltyProgram(models.Model):
'applies_on': 'current',
'trigger': 'auto',
'portal_visible': False,
'portal_point_name': _('Credit(s)'),
'portal_point_name': _("Credit(s)"),
'rule_ids': [(5, 0, 0), (0, 0, {
'reward_point_mode': 'unit',
'product_ids': first_sale_product,
@ -323,7 +417,7 @@ class LoyaltyProgram(models.Model):
'applies_on': 'future',
'trigger': 'auto',
'portal_visible': True,
'portal_point_name': _('Coupon point(s)'),
'portal_point_name': _("Coupon point(s)"),
'rule_ids': [(5, 0, 0), (0, 0, {
'minimum_amount': 100,
'minimum_qty': 0,
@ -354,7 +448,7 @@ class LoyaltyProgram(models.Model):
if program_type in program_type_defaults:
programs.write(program_type_defaults[program_type])
@api.depends("currency_id", "program_type")
@api.depends('currency_id', 'program_type')
def _compute_portal_point_name(self):
for program in self:
if program.program_type not in ('ewallet', 'gift_card'):
@ -370,7 +464,7 @@ class LoyaltyProgram(models.Model):
domain = rule._get_valid_product_domain()
if domain:
rule_products[rule] = products.filtered_domain(domain)
elif not domain and rule.program_type != "gift_card":
elif not domain and rule.program_type != 'gift_card':
rule_products[rule] = products
else:
continue
@ -378,7 +472,7 @@ class LoyaltyProgram(models.Model):
def action_open_loyalty_cards(self):
self.ensure_one()
action = self.env['ir.actions.act_window']._for_xml_id("loyalty.loyalty_card_action")
action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_card_action')
action['name'] = self._program_items_name()[self.program_type]
action['display_name'] = action['name']
action['context'] = {
@ -393,17 +487,7 @@ class LoyaltyProgram(models.Model):
@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):
res = super().toggle_active()
# Propagate active state to children
for program in self.with_context(active_test=False):
program.rule_ids.active = program.active
program.reward_ids.active = program.active
program.communication_plan_ids.active = program.active
program.reward_ids.with_context(active_test=True).discount_line_product_id.active = program.active
return res
raise UserError(_("You can not delete a program in an active state"))
def write(self, vals):
# There is an issue when we change the program type, since we clear the rewards and create new ones.
@ -416,14 +500,24 @@ class LoyaltyProgram(models.Model):
#`loyalty.reward`.
if 'program_type' in vals:
self = self.with_context(program_type=vals['program_type'])
return super().write(vals)
res = super().write(vals)
else:
for program in self:
program = program.with_context(program_type=program.program_type)
super(LoyaltyProgram, program).write(vals)
return True
res = True
else:
return super().write(vals)
res = super().write(vals)
# Propagate active state to children
if 'active' in vals:
for program in self.with_context(active_test=False):
program.rule_ids.active = program.active
program.reward_ids.active = program.active
program.communication_plan_ids.active = program.active
program.reward_ids.with_context(active_test=True).discount_line_product_id.active = program.active
return res
@api.model
def get_program_templates(self):
@ -435,57 +529,49 @@ class LoyaltyProgram(models.Model):
return {
'gift_card': {
'title': _("Gift Card"),
'description': _("Sell Gift Cards, that can be used to purchase products."),
'description': _("Sell Gift Cards, that allows to purchase products"),
'icon': 'gift_card',
},
'ewallet': {
'title': _("eWallet"),
'description': _("Fill in your eWallet, and use it to pay future orders."),
'description': _("Fill in your eWallet, to pay future orders"),
'icon': 'ewallet',
},
}
return {
'promotion': {
'title': _("Promotion Program"),
'description': _(
"Define promotions to apply automatically on your customers' orders."
),
'title': _("Promotional Program"),
'description': _("Automatic promo: 10% off on orders higher than $50"),
'icon': 'promotional_program',
},
'promo_code': {
'title': _("Discount Code"),
'description': _(
"Share a discount code with your customers to create a purchase incentive."
),
'title': _("Promo Code"),
'description': _("Get 10% off on some products, with a code"),
'icon': 'promo_code',
},
'buy_x_get_y': {
'title': _("Buy X Get Y"),
'description': _(
"Offer Y to your customers if they are buying X; for example, 2+1 free."
),
'description': _("Buy 2 products and get a third one for free"),
'icon': '2_plus_1',
},
'next_order_coupons': {
'title': _("Next Order Coupons"),
'description': _(
"Reward your customers for a purchase with a coupon to use on their next order."
),
'title': _("Next Order Coupon"),
'description': _("Send a coupon after an order, valid for next purchase"),
'icon': 'coupons',
},
'loyalty': {
'title': _("Loyalty Cards"),
'description': _("Win points with each purchase, and use points to get gifts."),
'title': _("Loyalty Card"),
'description': _("Win points with each purchase, and claim gifts"),
'icon': 'loyalty_cards',
},
'coupons': {
'title': _("Coupons"),
'description': _("Generate and share unique coupons with your customers."),
'title': _("Coupon"),
'description': _("Generate and share unique coupons with your customers"),
'icon': 'coupons',
},
'fidelity': {
'title': _("Fidelity Cards"),
'description': _("Buy 10 products, and get 10$ discount on the 11th one."),
'title': _("Fidelity Card"),
'description': _("Buy 10 products to get 10$ off on the 11th one"),
'icon': 'fidelity_cards',
},
}
@ -523,47 +609,47 @@ class LoyaltyProgram(models.Model):
product = self.env['product.product'].search([('sale_ok', '=', True)], limit=1)
return {
'gift_card': {
'name': _('Gift Card'),
'name': _("Gift Card"),
'program_type': 'gift_card',
**program_type_defaults['gift_card']
},
'ewallet': {
'name': _('eWallet'),
'name': _("eWallet"),
'program_type': 'ewallet',
**program_type_defaults['ewallet'],
},
'loyalty': {
'name': _('Loyalty Cards'),
'name': _("Loyalty Cards"),
'program_type': 'loyalty',
**program_type_defaults['loyalty'],
},
'coupons': {
'name': _('Coupons'),
'name': _("Coupons"),
'program_type': 'coupons',
**program_type_defaults['coupons'],
},
'promotion': {
'name': _('Promotional Program'),
'name': _("Promotional Program"),
'program_type': 'promotion',
**program_type_defaults['promotion'],
},
'promo_code': {
'name': _('Discount code'),
'name': _("Discount code"),
'program_type': 'promo_code',
**program_type_defaults['promo_code'],
},
'buy_x_get_y': {
'name': _('2+1 Free'),
'name': _("2+1 Free"),
'program_type': 'buy_x_get_y',
**program_type_defaults['buy_x_get_y'],
},
'next_order_coupons': {
'name': _('Next Order Coupons'),
'name': _("Next Order Coupons"),
'program_type': 'next_order_coupons',
**program_type_defaults['next_order_coupons'],
},
'fidelity': {
'name': _('Fidelity Cards'),
'name': _("Fidelity Cards"),
'program_type': 'loyalty',
'applies_on': 'both',
'trigger': 'auto',