diff --git a/addons/loyalty/__init__.py b/addons/loyalty/__init__.py new file mode 100644 index 00000000000..2ae6446f9dc --- /dev/null +++ b/addons/loyalty/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/addons/loyalty/__manifest__.py b/addons/loyalty/__manifest__.py new file mode 100644 index 00000000000..820dabe1291 --- /dev/null +++ b/addons/loyalty/__manifest__.py @@ -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', +} diff --git a/addons/loyalty/data/loyalty_data.xml b/addons/loyalty/data/loyalty_data.xml new file mode 100644 index 00000000000..091e68cfd96 --- /dev/null +++ b/addons/loyalty/data/loyalty_data.xml @@ -0,0 +1,18 @@ + + + + + Gift Card + 50 + service + + + + + + Top-up eWallet + 50 + service + + + diff --git a/addons/loyalty/data/loyalty_demo.xml b/addons/loyalty/data/loyalty_demo.xml new file mode 100644 index 00000000000..76f0338ecde --- /dev/null +++ b/addons/loyalty/data/loyalty_demo.xml @@ -0,0 +1,97 @@ + + + + + Code for 10% on orders + promotion + with_code + + + + with_code + 10pc + + + + + discount + 10 + percent + order + + + + + + + Buy 3 large cabinets, get one for free + promotion + auto + + + + 3 + unit + 1 + + + + + + product + + 3 + + + + + + 10% Discount + future + with_code + + + + + + + + discount + 10 + percent + order + + + + + create + + + + + + + Gift Cards + gift_card + future + auto + + + + 1 + money + True + + + + + + discount + per_point + 1 + order + 1 + + + + diff --git a/addons/loyalty/data/mail_template_data.xml b/addons/loyalty/data/mail_template_data.xml new file mode 100644 index 00000000000..2e88a278c4e --- /dev/null +++ b/addons/loyalty/data/mail_template_data.xml @@ -0,0 +1,99 @@ + + + + Gift Card: Send by Email + + Your Gift Card at {{ object.company_id.name }} + {{ object._get_mail_partner().id }} + +
+ Here is your gift card! +
+
+ +
+
+

+ $ 150.00

+
+
+

+ Gift Card Code +

+

4f10-15d6-41b7-b04c-7b3e

+
+
+

Card expires 05/05/2021

+
+
+ + Use it right now! + +
+
+ + Your Gift Card + +
+ + + [Loyalty] Coupon: Send by Email + + Your reward coupon from {{ object.program_id.company_id.name }} + {{ object.program_id.company_id.email }} + {{ object._get_mail_partner().id }} + {{ object._get_mail_partner().lang }} + + + + + + + + +
+ + Congratulations Brandon Freeman,
+
+ + Here is your reward from YourCompany.
+ + + + Reward Description +
+
+
+
+ Use this promo code + + before 2021-06-16 + +

+ 15637502648479132902 +

+ + + + Minimum purchase of 10 products +
+
+ + + Valid for purchase above $10.00 +
+
+
+
+ Thank you, + +
+ --
Mitchell Admin
+
+
+
+ + Your Coupon Code + +
+
diff --git a/addons/loyalty/models/__init__.py b/addons/loyalty/models/__init__.py new file mode 100644 index 00000000000..5bd9ac8fccb --- /dev/null +++ b/addons/loyalty/models/__init__.py @@ -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 diff --git a/addons/loyalty/models/loyalty_card.py b/addons/loyalty/models/loyalty_card.py new file mode 100644 index 00000000000..d387caeee2d --- /dev/null +++ b/addons/loyalty/models/loyalty_card.py @@ -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 diff --git a/addons/loyalty/models/loyalty_mail.py b/addons/loyalty/models/loyalty_mail.py new file mode 100644 index 00000000000..ed03b8eb881 --- /dev/null +++ b/addons/loyalty/models/loyalty_mail.py @@ -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')]) diff --git a/addons/loyalty/models/loyalty_program.py b/addons/loyalty/models/loyalty_program.py new file mode 100644 index 00000000000..10a216393f4 --- /dev/null +++ b/addons/loyalty/models/loyalty_program.py @@ -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, + })] + }, + } diff --git a/addons/loyalty/models/loyalty_reward.py b/addons/loyalty/models/loyalty_reward.py new file mode 100644 index 00000000000..6ee6785bd5b --- /dev/null +++ b/addons/loyalty/models/loyalty_reward.py @@ -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] diff --git a/addons/loyalty/models/loyalty_rule.py b/addons/loyalty/models/loyalty_rule.py new file mode 100644 index 00000000000..ecab0d8d222 --- /dev/null +++ b/addons/loyalty/models/loyalty_rule.py @@ -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()) diff --git a/addons/loyalty/models/product_product.py b/addons/loyalty/models/product_product.py new file mode 100644 index 00000000000..772291d1029 --- /dev/null +++ b/addons/loyalty/models/product_product.py @@ -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) diff --git a/addons/loyalty/report/loyalty_report.xml b/addons/loyalty/report/loyalty_report.xml new file mode 100644 index 00000000000..c062eedf05f --- /dev/null +++ b/addons/loyalty/report/loyalty_report.xml @@ -0,0 +1,24 @@ + + + + + Coupon Code + loyalty.card + qweb-pdf + loyalty.loyalty_report_i18n + loyalty.loyalty_report_i18n + + report + + + + Gift Card + loyalty.card + qweb-pdf + loyalty.gift_card_report_i18n + loyalty.gift_card_report_i18n + + report + + + diff --git a/addons/loyalty/report/loyalty_report_templates.xml b/addons/loyalty/report/loyalty_report_templates.xml new file mode 100644 index 00000000000..950c243caba --- /dev/null +++ b/addons/loyalty/report/loyalty_report_templates.xml @@ -0,0 +1,120 @@ + + + + + + + + + + diff --git a/addons/loyalty/security/ir.model.access.csv b/addons/loyalty/security/ir.model.access.csv new file mode 100644 index 00000000000..b6b82436879 --- /dev/null +++ b/addons/loyalty/security/ir.model.access.csv @@ -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 diff --git a/addons/loyalty/security/loyalty_security.xml b/addons/loyalty/security/loyalty_security.xml new file mode 100644 index 00000000000..4c7419a803b --- /dev/null +++ b/addons/loyalty/security/loyalty_security.xml @@ -0,0 +1,28 @@ + + + + + Loyalty program multi company rule + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Loyalty card multi company rule + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Loyalty rule multi company rule + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + + Loyalty reward multi company rule + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + diff --git a/addons/loyalty/static/img/2_plus_1.svg b/addons/loyalty/static/img/2_plus_1.svg new file mode 100644 index 00000000000..923421d4443 --- /dev/null +++ b/addons/loyalty/static/img/2_plus_1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/loyalty/static/img/coupons.svg b/addons/loyalty/static/img/coupons.svg new file mode 100644 index 00000000000..08e70e2e278 --- /dev/null +++ b/addons/loyalty/static/img/coupons.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/loyalty/static/img/ewallet.svg b/addons/loyalty/static/img/ewallet.svg new file mode 100644 index 00000000000..4eef7991375 --- /dev/null +++ b/addons/loyalty/static/img/ewallet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/loyalty/static/img/fidelity_cards.svg b/addons/loyalty/static/img/fidelity_cards.svg new file mode 100644 index 00000000000..3032e11ef40 --- /dev/null +++ b/addons/loyalty/static/img/fidelity_cards.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/loyalty/static/img/gift_card.png b/addons/loyalty/static/img/gift_card.png new file mode 100644 index 00000000000..15a42a747dd Binary files /dev/null and b/addons/loyalty/static/img/gift_card.png differ diff --git a/addons/loyalty/static/img/gift_card.svg b/addons/loyalty/static/img/gift_card.svg new file mode 100644 index 00000000000..7086772e353 --- /dev/null +++ b/addons/loyalty/static/img/gift_card.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/loyalty/static/img/loyalty_cards.svg b/addons/loyalty/static/img/loyalty_cards.svg new file mode 100644 index 00000000000..661ca73d46a --- /dev/null +++ b/addons/loyalty/static/img/loyalty_cards.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/loyalty/static/img/loyalty_cards_2.svg b/addons/loyalty/static/img/loyalty_cards_2.svg new file mode 100644 index 00000000000..daaf88d67b3 --- /dev/null +++ b/addons/loyalty/static/img/loyalty_cards_2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/loyalty/static/img/promo_code.svg b/addons/loyalty/static/img/promo_code.svg new file mode 100644 index 00000000000..a0cce441fb4 --- /dev/null +++ b/addons/loyalty/static/img/promo_code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/loyalty/static/img/promotional_program.svg b/addons/loyalty/static/img/promotional_program.svg new file mode 100644 index 00000000000..aa5d2d40387 --- /dev/null +++ b/addons/loyalty/static/img/promotional_program.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/addons/loyalty/static/src/js/loyalty_control_panel_widget.js b/addons/loyalty/static/src/js/loyalty_control_panel_widget.js new file mode 100644 index 00000000000..382c1ce6520 --- /dev/null +++ b/addons/loyalty/static/src/js/loyalty_control_panel_widget.js @@ -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); diff --git a/addons/loyalty/static/src/js/loyalty_list_view.js b/addons/loyalty/static/src/js/loyalty_list_view.js new file mode 100644 index 00000000000..2e3287782fe --- /dev/null +++ b/addons/loyalty/static/src/js/loyalty_list_view.js @@ -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); diff --git a/addons/loyalty/static/src/js/loyalty_views.js b/addons/loyalty/static/src/js/loyalty_views.js new file mode 100644 index 00000000000..ad99f47e427 --- /dev/null +++ b/addons/loyalty/static/src/js/loyalty_views.js @@ -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); + } +}; diff --git a/addons/loyalty/static/src/scss/loyalty.scss b/addons/loyalty/static/src/scss/loyalty.scss new file mode 100644 index 00000000000..61c918f0a23 --- /dev/null +++ b/addons/loyalty/static/src/scss/loyalty.scss @@ -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%; + } +} diff --git a/addons/loyalty/static/src/xml/loyalty_templates.xml b/addons/loyalty/static/src/xml/loyalty_templates.xml new file mode 100644 index 00000000000..baf4fa418bf --- /dev/null +++ b/addons/loyalty/static/src/xml/loyalty_templates.xml @@ -0,0 +1,39 @@ + + + +
+
+ + + +
+
+
+
+
+ +
+
+
+
+

+

+

+
+
+
+
+
+
+
+
+ + +
+

+ +
+
+
diff --git a/addons/loyalty/tests/__init__.py b/addons/loyalty/tests/__init__.py new file mode 100644 index 00000000000..88b0b0cf637 --- /dev/null +++ b/addons/loyalty/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_loyalty diff --git a/addons/loyalty/tests/test_loyalty.py b/addons/loyalty/tests/test_loyalty.py new file mode 100644 index 00000000000..2c8984597a8 --- /dev/null +++ b/addons/loyalty/tests/test_loyalty.py @@ -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) diff --git a/addons/loyalty/views/loyalty_card_views.xml b/addons/loyalty/views/loyalty_card_views.xml new file mode 100644 index 00000000000..e616aadcec2 --- /dev/null +++ b/addons/loyalty/views/loyalty_card_views.xml @@ -0,0 +1,49 @@ + + + + loyalty.card.view.form + loyalty.card + +
+ + + + + + + + +
+ +
+
+
+
+ + + loyalty.card.view.tree + loyalty.card + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + loyalty.program.view.tree + loyalty.program + + + + + + + + + + + + + loyalty.program.view.search + loyalty.program + + + + + + + + + + + Promotions, Gift Card, Loyalty + loyalty.program + tree,form + +
+

No loyalty program found.

+

Create a new one from scratch, or use one of the templates below.

+
+
+
+
diff --git a/addons/loyalty/views/loyalty_reward_views.xml b/addons/loyalty/views/loyalty_reward_views.xml new file mode 100644 index 00000000000..66d4f5eb374 --- /dev/null +++ b/addons/loyalty/views/loyalty_reward_views.xml @@ -0,0 +1,110 @@ + + + + loyalty.reward.view.form + loyalty.reward + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + loyalty.reward.view.kanban + loyalty.reward + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+
+ + +
+
+ + +
+
+
+

With (or more)

+
+
+
+
+
+
+
diff --git a/addons/loyalty/views/loyalty_rule_views.xml b/addons/loyalty/views/loyalty_rule_views.xml new file mode 100644 index 00000000000..bafc74b4d91 --- /dev/null +++ b/addons/loyalty/views/loyalty_rule_views.xml @@ -0,0 +1,110 @@ + + + + loyalty.rule.view.form + loyalty.rule + +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + loyalty.rule.view.kanban + loyalty.rule + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/addons/loyalty/wizard/__init__.py b/addons/loyalty/wizard/__init__.py new file mode 100644 index 00000000000..18790e0b824 --- /dev/null +++ b/addons/loyalty/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import loyalty_generate_wizard diff --git a/addons/loyalty/wizard/loyalty_generate_wizard.py b/addons/loyalty/wizard/loyalty_generate_wizard.py new file mode 100644 index 00000000000..fbb7397c420 --- /dev/null +++ b/addons/loyalty/wizard/loyalty_generate_wizard.py @@ -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 diff --git a/addons/loyalty/wizard/loyalty_generate_wizard_views.xml b/addons/loyalty/wizard/loyalty_generate_wizard_views.xml new file mode 100644 index 00000000000..ed4b5f18d89 --- /dev/null +++ b/addons/loyalty/wizard/loyalty_generate_wizard_views.xml @@ -0,0 +1,39 @@ + + + + loyalty.generate.wizard.view.form + loyalty.generate.wizard + +
+ + + + + + + + + + + +
+
+
+
+
+ + + Generate Coupons + loyalty.generate.wizard + form + new + +