From 9bb9eaf06726ec6a97c377c1cc2dfa550c1f2be7 Mon Sep 17 00:00:00 2001 From: William Braeckman Date: Mon, 7 Mar 2022 12:13:07 +0100 Subject: [PATCH] [ADD] loyalty: manage loyalty, promotions and more 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 For empty list design: Co-authored-by: Carlos Valverde --- addons/loyalty/__init__.py | 5 + addons/loyalty/__manifest__.py | 39 ++ addons/loyalty/data/loyalty_data.xml | 18 + addons/loyalty/data/loyalty_demo.xml | 97 +++++ addons/loyalty/data/mail_template_data.xml | 99 +++++ addons/loyalty/models/__init__.py | 9 + addons/loyalty/models/loyalty_card.py | 154 +++++++ addons/loyalty/models/loyalty_mail.py | 20 + addons/loyalty/models/loyalty_program.py | 383 ++++++++++++++++++ addons/loyalty/models/loyalty_reward.py | 196 +++++++++ addons/loyalty/models/loyalty_rule.py | 107 +++++ addons/loyalty/models/product_product.py | 16 + addons/loyalty/report/loyalty_report.xml | 24 ++ .../report/loyalty_report_templates.xml | 120 ++++++ addons/loyalty/security/ir.model.access.csv | 7 + addons/loyalty/security/loyalty_security.xml | 28 ++ addons/loyalty/static/img/2_plus_1.svg | 1 + addons/loyalty/static/img/coupons.svg | 1 + addons/loyalty/static/img/ewallet.svg | 1 + addons/loyalty/static/img/fidelity_cards.svg | 1 + addons/loyalty/static/img/gift_card.png | Bin 0 -> 24678 bytes addons/loyalty/static/img/gift_card.svg | 1 + addons/loyalty/static/img/loyalty_cards.svg | 1 + addons/loyalty/static/img/loyalty_cards_2.svg | 1 + addons/loyalty/static/img/promo_code.svg | 1 + .../static/img/promotional_program.svg | 1 + .../src/js/loyalty_control_panel_widget.js | 36 ++ .../static/src/js/loyalty_list_view.js | 23 ++ addons/loyalty/static/src/js/loyalty_views.js | 61 +++ addons/loyalty/static/src/scss/loyalty.scss | 57 +++ .../static/src/xml/loyalty_templates.xml | 39 ++ addons/loyalty/tests/__init__.py | 4 + addons/loyalty/tests/test_loyalty.py | 98 +++++ addons/loyalty/views/loyalty_card_views.xml | 49 +++ addons/loyalty/views/loyalty_mail_views.xml | 15 + .../loyalty/views/loyalty_program_views.xml | 147 +++++++ addons/loyalty/views/loyalty_reward_views.xml | 110 +++++ addons/loyalty/views/loyalty_rule_views.xml | 110 +++++ addons/loyalty/wizard/__init__.py | 4 + .../loyalty/wizard/loyalty_generate_wizard.py | 69 ++++ .../wizard/loyalty_generate_wizard_views.xml | 39 ++ 41 files changed, 2192 insertions(+) create mode 100644 addons/loyalty/__init__.py create mode 100644 addons/loyalty/__manifest__.py create mode 100644 addons/loyalty/data/loyalty_data.xml create mode 100644 addons/loyalty/data/loyalty_demo.xml create mode 100644 addons/loyalty/data/mail_template_data.xml create mode 100644 addons/loyalty/models/__init__.py create mode 100644 addons/loyalty/models/loyalty_card.py create mode 100644 addons/loyalty/models/loyalty_mail.py create mode 100644 addons/loyalty/models/loyalty_program.py create mode 100644 addons/loyalty/models/loyalty_reward.py create mode 100644 addons/loyalty/models/loyalty_rule.py create mode 100644 addons/loyalty/models/product_product.py create mode 100644 addons/loyalty/report/loyalty_report.xml create mode 100644 addons/loyalty/report/loyalty_report_templates.xml create mode 100644 addons/loyalty/security/ir.model.access.csv create mode 100644 addons/loyalty/security/loyalty_security.xml create mode 100644 addons/loyalty/static/img/2_plus_1.svg create mode 100644 addons/loyalty/static/img/coupons.svg create mode 100644 addons/loyalty/static/img/ewallet.svg create mode 100644 addons/loyalty/static/img/fidelity_cards.svg create mode 100644 addons/loyalty/static/img/gift_card.png create mode 100644 addons/loyalty/static/img/gift_card.svg create mode 100644 addons/loyalty/static/img/loyalty_cards.svg create mode 100644 addons/loyalty/static/img/loyalty_cards_2.svg create mode 100644 addons/loyalty/static/img/promo_code.svg create mode 100644 addons/loyalty/static/img/promotional_program.svg create mode 100644 addons/loyalty/static/src/js/loyalty_control_panel_widget.js create mode 100644 addons/loyalty/static/src/js/loyalty_list_view.js create mode 100644 addons/loyalty/static/src/js/loyalty_views.js create mode 100644 addons/loyalty/static/src/scss/loyalty.scss create mode 100644 addons/loyalty/static/src/xml/loyalty_templates.xml create mode 100644 addons/loyalty/tests/__init__.py create mode 100644 addons/loyalty/tests/test_loyalty.py create mode 100644 addons/loyalty/views/loyalty_card_views.xml create mode 100644 addons/loyalty/views/loyalty_mail_views.xml create mode 100644 addons/loyalty/views/loyalty_program_views.xml create mode 100644 addons/loyalty/views/loyalty_reward_views.xml create mode 100644 addons/loyalty/views/loyalty_rule_views.xml create mode 100644 addons/loyalty/wizard/__init__.py create mode 100644 addons/loyalty/wizard/loyalty_generate_wizard.py create mode 100644 addons/loyalty/wizard/loyalty_generate_wizard_views.xml 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

+
+ +
+ + 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 0000000000000000000000000000000000000000..15a42a747dd8ef367e8e9ecb64d3c5c5e2e9e169 GIT binary patch literal 24678 zcmX_n1yodB)HWa?NGRQ?lypc+iHbBRC=4A#cQ-?Wv{EA7NHcWzFapxu-8s@R@ZWjA z|NHJ*F4jWUJ?HMd&yMGLPWUGkSpqz2JTx>k0(rR)YG`QagTOzu$JoGcMqhKeqoF-V zlm8(7#Y6wFRWV9$+VQtuvrI(Z2enCTjeeD$+5h} zr>V4f?$0@plP(p&JpWILh$A#!`U8y<|Fyg}QS1b%c*EoGcGPvNln%lH_!vt&BU6k8 ze~z|t<%NdZg3jI)m6vOOt(}_N9$jE?7}-Af`n5!(thV@KX?v7RWTxJJ`2o{Ewp>E{ zzvpAR0a^zSw{F9UpMW%)zXA#B5A4u9NYOGZB25*6XMkms0`0^W?P@jT2}QZwyV0CR z`{lM_v<^kIN->t{G1OBf-++Ops^8$SdaKK*BQRagZJ^cMwa_s$Ly3m=cMi}pWBk)<9zsD>cPN6 zLP*Gr`wcl2LAl7-ntZnH)%jqmu#u-H6ab8&&#uP{C+nJUvKHj7P( ziPn~=XSU9kMzTT?t|}#9VtRXV z2zYW70jGM2A+HLmubgWW}e02pq1 z!anaigDuCFF!nXSzh&l$+UTF)fa?L*J?1He!PyH}8~Z29 z+0`rf`8skfOJ81xRmGySt75zrH6S#@9whNu0B91e&z znFgm#JRr=K%TS+aN)l*4+*D#x^ir@?^VgYc{rn-B>X~o)|2Y1s`{-Yo_AAkprv4hA z;p+LrE*u6Qv~(4ma{bn(?AHHYaQgbYY7_qaSF^VqU9NiFdwcA6+kW?~x{Xd)7YDPC zE*ISmz7(QXdL6_gP(%Bsc;(GHdrBiZlw2VOqHF3@AzEA!B9NzWRI>2eq=a?8}{y_Zu+e zB^z9juS4xf&s4!sw5-ASdUMkhq^}<;;eAmf^?d}e>-&KhTn_|;jY&jwAvb~h_>xJH zMHWehUn=;ax_Cc%3_@Wr*u``RDqz!koZ2^!vc4|8Uyd*{OfKga^jt zI<{s94oB&N+1tp^iUYhkn}JEqL~K8cWL~@T$!j`IZR~OBQnOuf@{Jr!xDo(1%~$+s z2t*>voXu{B5ql06NFXS#sJ47f`=q!wN2UU`1r$5UR0qmtlWho7S`*f+nD5?pZ4Ufg zea~?7>GlyWV9l4z&ap|CM{b*^fYae7yqO5`>bp5S=-7ZHtNfr^MQjjnqu1guvj{x+Yp$D>XG>Wp;9(20F4 z_7I}`nUqASTrR~WTXROi#j0rif);+5;o99Nq#>}I6Cc3}=hZW{a%wyTZJ`eAdN09D ztu@8LY&9IITFK=L)lbA1Q2y?Z|H$>yDF>d*!0t4XU7UQB~<${@*1^PxxpC1_ml?Y9?+|`pxcR%)>!- zHY(!Qxm?&noP3Lap*2wkIX`lKouWVgL;DHe_E|7QQ1nR>p9Oz5-NV~=c8EO9n-n50 zuG}8pQuC&Eg^hS=UVP|AM=hpb2Tt&UUOdsmMoc7E3NwqAHYb!rer}7cMx*pXK(wq? zU=@eB%UxXE?|DEZf_MB_q&^C}yUp&A^381Cp%FYDE?z}W-8k0cFihDH3$?@6~v3M+~!uMg)cO?%pT%j4{KMWE<<1Ql}uM zJCXw;$tyH^pA+eZhx=PWGLl^0i_a#YwYi=)YKalWi;2<5tAw6wC_x`NHC zlzeR4UD|cF-x{Si8lt>^P|KFl4V0FbcQqfk&ELjs1AhE4`xG3B9PC)+S0U;qj#u+E z1AT+JlHw_{yDVg!PrA?s^3Z2;R>@QdG(Pu;DEvAUIf+xE8=rUK2e`8Vf@~`CAbg#> z0#=mF-)`K+Ona|l1m*n;j{W?GFqy@cf-A+J+mS7UqmLLiP;8(0D5!MGE^&0lCK!B$ zLrm|n5>R^Vk#yqvy874pYpd{vWoJGm1RLLl5w$8e^V%~;iClI$SbXk>9|@G$3>YcC zWsB^OcEmE)*VjkaENkn3HPFBh^1not-r7%mgPD`UO9wB1N6FL3Mr935V%#->MiTsJ91@$W ze>)w?JaRzFk@B>$DGF=Ed44L=b&b=hQPT28yO{|rv5s5`B3c2mEGoma0mm`<$Sfr$ z)>O~_Bc~(X)C}nIw9=^c)9N2O&Avd(Y>QERIhcA*AG%Hjzq8MuQCke^hZO@<0v^Vp z_wYvs1RK#P;tH)NbK>teNHk`%DfAW?th22;xBHxbe_c+y_$7jLaHdRaIME~=_Z)k4 ztZ;vVZsklfhGtDzVIzK51q^S|xllr2HO7~xobpHPv0>_fg(+JyjR6x#4Tw&_f+nr)bUleZsj#fD0KT?KQW#Z3+ zusu^Xat+!%ZV=?4r)w$Ae$|mIOe!9Bonzl^yl>9_IBo1H%7w(*y;JG!ilz}>S%XEk z9(&j=Hhop+o2SSA*f%X5Ps3raZ$WI!iM&X>A!*Lq5$-1C82q{5#v>D9_B`Jssf#9A zSE?16|6TivlRp5&02G@72007jVUTucEXx*;`>{g8zuFqVG=u!xlN=bH0qN$|+@YJ` zkGA+kEO5P<1YA+y)r;l#mrS;VgL$W_IBkFQ|NM^TcD$r_AeJ$8FU+AFW1vHn8^vrF zMB8pEwPhTRj?YUt)1F+R=2#ZC(yEAm!CYeT&Lp6RrDQk!jNCRRxfB*7!}8JUNA;hc zZ@cRZa*tuJ>)Ro`c#=tGAo#-B646FfdvI*Vy(RS3sNY)-P*5mGdB%*^_XsEUS((=> zu3a1q-PR==iMDvmw{d zPS%o`uPV8qQOtZw82P)R&V-Z>UB8r(dfMtg2`}&7zI&CC0a&KL(8%x$T*K=4jD~9o zNv$b`M>j1ie;d>rHyj$UkweAdmJ6{K`dFT>>#Lw3u1Grl-0$|n7FCj_kZ%L0H7v0g zA71hQPR=hiAew8yp7bk%)FPxi+eA2#I&79Dq-m;sGOxe$zhDkpul@S3KZ$X=t~N_+ zsJ|4h;DCD3cDg&Nw$VVIXJaZ~FY<%LPgCNo1#0e7cSsV9spk~VC4Td1kN9w4QH)Qt z0TEN`O8>-X++63ohjlU|%RaRGM~NyMnN)=VpXU({F{g*`pZsOyVp)QAu3=hr95Mi^ zs$}=j&<@#9=8b{6WEvh!2f|}yHC`53D4 z%z`9>zNfD1^vkXCvF)Cw8@8gWh~QgVJ{|gpQq9k7z=;4=yefkmdu;L@5;4_)wu?>d zC`lJUiZLaYx844(t!JqRIk;3ZB_^K~<`iDk`&oZhZ0)iq9;S5$(#z<+aL|$87Yk!9 zZE)sX{iGywZM*-3efgrlsjjH6O7QPFYe5l2 zB4o;mb871cku%pW(3m#3?5Z7S8XhmTrY6#0Di2PY$;@9Wj~zE!S(Pq`I7qP}y*3q} z58|A+FQ1048td$tLpsbN2|Bep%6Qm&aXS9CWwKT67OGp4#Xu&-YU(=b4hUl+rfJ|Kxf2A*p}U z7_mHN)>(3#Sja>+_||GWpM~Z5^^d*GpOEt)1gZ8g#>MxlXCi(YnwqP?+!dtwle;sB zNb)hzz?-;cXB|^dK>}%$cv{;!S5)w7NBzl=mfBMcRH+VWn+$-6wB39TKYq!wF}q%~k(N zb=E~`b%`y_P^$YBTJYHgve@D{*wi4k%&+{vn1sz z_~xi7S#)Jy3q6D4UUFkck9AH8*~IsdOUgttPR83 z*&`yKTkYy~#0_+&%sB0qlD9fwW8>{57J<(Hc$uq+}% zXBhxL7{?^Z@Qy3j;N^q|E}uzNj$_Klg2^c;jDX2vvEs>Q8k=#Rha95nDKVJ}Xo@NS znvrhZg^3fA@OCduEC!7Iw+Zb9HoZbja~@X|j9b`ETckUvcS22s3z`pW#7(KoRB#%d zh{nJ(oGj<55_<3!|bFP(e_WpZCQw9Ne#|@Q{TJIhI_D3pnr=WA~u#xbypMlr7`hLV`N2bvKbV2YH4SKmz z3&oPNE|l9AH%rWlRaAej;@Jya&?U z;bE6k#kD#$E=S!SjjnlRMR5XD?X>eQWw40ncJU^h4&H(+-L?4_T%*IaXc;-bUXQR~ zn<`5Ny@Z8<#@6R{_)=_kpvfwRfQt(X3bM+rlQS|xfv^57^yw>E3-V%qWY+}^&CtzF zV{-M|Ae){a0H^FCn?^DuybXslBueQieO8P=FvQWZ;jThGNJ8KY2k}Bf{_vl~E1;<8 zXxneH9&{XOx=JD^h`4o5%O~;H!aJIOxn?6o!@GN?Shp#nJ^SY#6&~;!bEkZ-yk-+^ zVmoV=BFYHqX2#=ow1WSRM&4d_pPdOPDJyqwY%tM^IG`!;3ky$k6jWDN^GZn(0p3FU zt38HiW|7->0YDoLyJO5CUERMo0hrwK<2aM?cq}0tPXUWDwR2iX{tS-EMiLGXr(5Foq+W7sMd{P@pc{gi#@`o*^Ovq@ zSJY@70KQ|iKULQ9ys@Pa{z~^m0dv53;N>HQ@0^gW9p}TdF?KE{G86K|StD8R!%;vRS z?*H*T5Z|90JdgpSJb)EBN{nq^B`3i{10Yuo4Gl8`U&}d^l}ek01-}&>6g)j;hN%|6 zkHqE*9)o_e=-v;b_j(*dD~cf-OCQqLCr>FbK0eMXAwiJ&aJP+)wlXNZ9Q^ASRr0T2 zyIcz57b&8jb6H8G3yax7sNQnf*L{(Q$zr8z;?QjxHu(gSIdsbGWNGJ&q#|c}*q!N0 z3w5l|BQd5C>x<(1b465TAH9)-`|7hih=nCYn)D#fLUPQlTx9LTaO+aLpOpB;4A^03 zB%9zOEL@h}6O-O^$rZ`lOR+xH#G?4y0ljGzI zPCNJJaOFFASR`|jrF>X9W0R)wt@-ye>!n77&W#Y+U|NTEnw)fX>iX&P&hX${nheEo z*N1!L$wBpS*Te*=wO=wW*4bhGa;SQtN~hFaw^Ya2n3}z#vpjh>UAyBt_CkXrc+p)P zDsKMXs@mW8oTZ56j!MYN3B!MyFMWNGe@nV^^Rbf5(S`$SF?|CGjMiJj=|*l;a9L_h zsO4jf9Xr%Nug>x?g*z;X((+|?EZ|8ZsXN}5o4t~gIqQmOlg^%l#b%1Oe~A?x!+!V9 zSO0#t;{!vLF~*8eD2V zIj@PivQJ1|@9v03i=^kQ-$a|bgXW-60qWocxA{39O1v@e&(%S2K)qp>ScI6b)3j@|(w?sfn=ZXgwo2D00~&4H7P zdHZev6)|#kbzNlPbj%O;h2D^5H6fJe#LMq;c4nP~Z1_GW+U-O*@b-{f~XN#ZJd`zRhivlkf0?QN_<6jQENv+I#jCR1DrDpMT;UnPURc z%DtGjQ<8$aMQ~4b##1hRfHSmKj2_M~%Eo*X5*Awse;0k^KQVN<{2rmJtsRI!IL@^B zdXMSW?W~Amu!I4r?_Bc5;*d1Gh<$K{lq28yK_Y~|^t6I}kQm=sEd|dbMz5HI?C(iE zi}`y1Nea|7@U2A<-TWOKlr9_(s9%P7FIHTkU5B>-xfn>vN39ttDX3LMJWieiu~CmG zS(&>Im*QSZ2H+x~Pa_5pOo`S@)z%fyeYfUV?i0@aMQ^9PwI;T8ygxPJ3Bk`zJC4a$&)9j^!nUK zw?+H53nJ21Q-cZoSL+3kzkcsiXZYQFqJ8;7#2|@}LqxBuY3Ph7m$zj!{Pjs(y|b@> zeckNio5rGGgWu4(P4Gw$}-iMq5*$K>6jZhA`0-f{{F>J zlk}xFGc!Z`6N;BA+f3Zy00;0ZFR5Re(}JkeKu9nC7#>3x6of-$SNS8b-or`GQ8gsv zYUAc-cbL@ODuGOzVg|GChD`|gN+IkN_w+RLfmRX-LOYQnOnxafzPh*DMISXURuT3O z7xpQ?2z)je8J_ty4W;Jsxg1%!oXg#2A$7KzsRO%a?HL**sP~t@3e;kboV~@Fb(cT+ zxS0_)y?UYhKQ>}j%?KVZRDEAK{)|L}b9WP{k}DHM>JC71N={1)2E$`Ix1}DgT_5E0 zaX(txCVW)L>iBCJE*irOzDrYF5z$g(=)V7*Owyz#L-L)dC^Lg_1>{V`ZXFP*^$jcL z=LAu$w88kDbA3+q;#Yw(5QQ|}oWIk}lns2YtZAVuA!gE*fT#6L?MI;J&6%ag)-Q7a zm2~VbSQE;{oK}fX{-`&N+EL?nMi9`w5CS+7w!wA3CaRN9u_EI3bOLS5h;0S1co4kV z7Ifw}g)%-<%sA_>z>K4_YaN}g;fm6s4l*ThSjl;$lxEpXQS|L=Ub5B_7?Zg`0HP+I ze--BgdXn^rY6H2V(_SMQSx}b6fJV@j$iD?G4>k7eyNyz{Y@uxqPEz)j6Tv zUbPOUXeI<9CBq48TGRuSVEp-|4(FH(p%?s@pLn5GeUjuA)JPh>V(U)!u%bVSW)TFS z@ovPHG0`hR{ojNTW!o{(I>{qiozmAku4&Cg5^~q_UUU+JeYo0EI1=nD$t!E$7>r&> z-JNu!QX8YO^7h&8_(_=GRTvE(i!Yjd`I+m``|6N+)$})547W3IlzH}%a1z{sf~l#j zsSJ$t;kZ~@i&BTX?tLf}y7WF(^RRQa6VB-%ln-f;Elr<`iWUpOE3;)I6d~7bpL-v{ zXHJqV__kBj@;iQ$AHjTU0Det)lg=vnN4XdvTc5NZ`^HXI|2rF3?IuwbM+X{}y{&b6 zN@b0)+$Nw&xI?*T`2o2cCcNz99MoDsc@dq0M`3VxLS98V_h!WeJ`x7{4Y83JogRB1 z*xNByPcf7^6aL|#lgziAfzZxY$D}sCp@3drz31CGaLiNNwr=)a-kj{J`cX?FcU7x3(-%Z| zgS3T@+aDwWqDGHopT>ndulCd0GaFd>L8Z+fF7V%5uc;E=GCgmHmGw9!ycq*8@k^AE z&`w$&#gJU-|{X`+mFv^U~UW30jQ z0d^)w$u-yJW@aly_bWtG$lvd{U80!bKHEcd06x!;nz2x052EMQ<`0ai)0r&XnHw}G z{@=5S8z+tfH{s7tYmKKEF5h4&`l&Ibm6OTywA7XI4QpFs*z#-~J;AFb+0W&XsW$MS zp!oQtZHoTAcGLpKA5Vr;pkLD0bpuWP8C#&n@?fH*Utv-Kv? zhPpP~O;m*83?^+#ANKJ2dDYRMxC>;zWoW#~U%C4|eIIakkG$2Qi*9Tby_%bDt1}`Q z-A1?LZp96vkavV#>r4^0icJmn;+l$dJe{!EO!Sm4%MqTVDfW18sb%4l7D5!`?FX}CUWdC znnZWkiwZL$_7pj}xx37zZne->`_UO<$=JgelC5xi*2j0p3s-^jQ=AsE;OGeuu>k;a zHq+?Ls-&cZYJk0MJBjR+S7h-(?fgrSLcYOp$LD5@FjgXM^txWyBVluBd}4~|=t<1% z%ZljuBbmrSmXSM70t&x_N01}#lr#i6)tl=Dv*Onk{F$Cxzg(q?xouT!)i12Gon(l= zNu}{zf%>Trrmu{3o81V?^_r(RGIZ)1>J2|T(B>x>^eU&S$|t=a$0>Y>YsG6Fw%Q$6 zu<@Cjxs^{{cBP($GE>Z(VcPdUEtx3!+t}E+berstXV&4RA@(?L4Oi(U+u>=}^bU%y zLy>&mmv*QRyTu(#>U?(bS$+QlM-Ce@X^AnR;4l`?c$D*58nMtvs+JRQ>PmJPy+T{R z&uR#T*D!~ZYaf}?kt^Pqhw=Kv&=J^zU%T1N?-Pn|14uhVpvQ6mn6%3X^W73U8xFTB z;_ha<-^k=P7)6;MvTW1(f3*O%z9ZAp85uoEQOzGoysMcB_T{A+HB<6;rn?5EPx&-N zy0?^&5q%2_3rz;MxN^e*Op!*6Nq@mu(*u>!?LdRGw)PU`#-1L`gM~(Qb*jfxc`x}a zkkW=+T?=#DE;R^ir2jbf&+5?VI7uxV0@qRejg0KAxOp7CU>6c(U*=tS78QgZ@(Ykb?GVjd!o&e zlPul)_Wh~vmmkJ$O-(4;?r5=Y5uk`U%wI5J6rrtrFk&lUa}Ky2lt7=lQRV7ykh`b` z^?t`Q=boEkcM`^{AUX({SaOBB))YOuuc9bZM)^@W9#`T?+Y z;$6wR>~^oiWxZ9^-B8SJWfyv_=g5_5+7w$Zi1xc}ePJl%M8BR~j+0_8wV~>C?)#dV zZ!1*?nEF?aXfeq%olis(XP?HTlJ!9#|40g+6!JEi-htmkuq*^i3Gs*|VqrCK_m}#x zNFePr&tsVvp;Pus09O$9Yl6>Md>!UI^WoX{$(1Iar4Om|C~XLqFee1`dm1wGJmlA} zUrWwMdsX2wEmV~G=bt0I4y{;qO~)`4o)owqg23{TjEAut8I6q}$EJyWqRR=bon&88 zSVf)&?Z=80M0dUkW3#xZz4-ht#ZP^-2D|H}vv8Je-AIO0BCfT8Ae3}q;d-QZ)Y$wx zvr{WC*rpuBv2Lf;?TjIl8>9$l!K)xs%*>!v^In=(R3cu-79d`ItlVB_agC+9Dc2j|kHtEKP(|dQ4ZQD!7fh42j`biXd*hv>K`n|i zJOLYw5{{MnKa4|7qjIt9?;Ak%GS$N?@^Xz5V#2}{whX-lxwik3%sLT;4A>EU1s!El zUVLllb2+omq2Yd4t6{J@ zN}v&L-6p#}vtef@x4@-Q=705|!2IJ&gJW39(A#1^Xh~M`V1y4^QFtCl!1}WcA}iY( zeBrZlxeh&nRNax>(7IXUL)7r56Qxr4$grVo^4K#XTClck89WoE+vY5`V(p`gC5<B%i&e&__6g7RH>wzTQJE zorud4-#B^OP#R~A#!_0MR5QSQ2qf!<jP_aSaQc&KoqaQMAOw@u!!Eg{1s@`Z_l@ z7RN%&Tx(xy?x{~bBvfC?^`y=8B=|aSnF_Ws1h$dr#UsCz3z)0SARWQL!i82|9sJ+7 z3V6dJ3#N<|-<+PFwr+pjh_B8d$nG3ut(P}G&6o?FFOx6iO6yj&Q2}daei7<5(|^R= zTMoOl>+y0@Z?UMGk!zI@vp9KKMfznoIiH|A-P8Mt)qsPc{LCEf)*hJZrXE*her%EW zFj>frBLkt)b&NIMSo60GE7l%a0L{^Q>5v-0!qRR4&2MwYidPijlTauvGj1d7q~oal ztaBq8BI3WoVcJYRBD0sy4IpM%WZd-TW`fS zhmt_yZ(>~mXT96*GRtc3-}3=x8x_^aOd%HK%(dBN?d5#7MFw*s2J6VJ$Bst}qRM%c zWAE_cnO=KrB93dA9OJ4e8XF<>Q<$&j4Chd3DNv}MZ$8d0MvDFmVE+TXB&vRF3ZHy6NE;dpO3z8Y<$fu&VFq4vjAqja4kt7?gLdb{F3QZyq|T0<>_1L-8?21o30 zF^vHaaUqGSpbmb0qLkJ(nd*E|das=q%1jDFxXQ))1BRkLS0|x)n*6vL*6nP~w(~-i zG&JA}@?Ab%PDCH!d}KxuaK!uv=ADw85$RI1X3}eJ=Cz{C5W|m_|`Y;7PoC zvH%M!tKg5`qGG(J#d4M@%?8-ghB&B_C9mN6r{DF@CFdf55w9&~p8-mh&2pP>&|<`$ zW<1{FGr4o=)cJ{=;0noFNn=c;f=7kKR+vU`3pB1WhUbi8!|}m|R@E(5;zo`aBT2n6q5f=;n32X1@5j(**T)A z?tpY!zl@pWr>__^>P4ACLUYuHO}21czRijDLpEi|ngKg^F0TfWnjb}Nf~R9-JbP+o9*HaS*#zjS5H6@Ytzo(?g+0nGadljCYq)f zUfQM5KH&8IzR@EtbMS)hpu{;4q!++EjnCIDq=624DMM(P%V3~@Dr2o}Hn%Xc0gp0} zO=F~)A{99~dFYnxd~G1{9AX*hr#(=u8UGgp9iOzj%!ljD?xj|5ne@*llyM~I0jF#* z)mWAK`4E%{575@Fw%s1>BN-3;;YoFM#`~gEL~U@ApL8-1Y69&}Hp_z!tU9`- z^Esl=)gFYmfVTbipRn|e;H6HVdLf}(ImR)lfSJtA}XEX6~RQAy-X$U?Q?w`a$ zDP3O#6qVEl{M5gaadL73c^*|eQr&~BRtUNz;C|{IP0BWLU)mK&n*Ui+Ycn;j{R-5~ z?5_Y+BS1ro!lfOSeJ?v_K#dHnk+8XaclA@2+5FqieS1aGJW=*~H5y$*jZoVF*qxTH zF6q3nlB((@*U*ID5j!)Jamx369NTss$}>p~wjDoA{k0vV zUi|T`cKaYYuI?3i^OWcqkPKPPbyLcxmN4f)>I@TZ9WME}{a=+P!(Q(~&PG)sck2w+ zFF9{7j}0iE0E1s@1#QFqNlVX7XEWIpjw9FYBReKX}Hn#llNSDIR~0sQg=AONFSU zBt4Qq+jca+M|x~Df{FFfAN``hU%6tBKMpQK)TaeYOH0opSZx(O^Wt)8-kVd zs<|L7E>_@92_s^_ergvfpsY|;_~y&9^|0djg|l4v_N&jvHj8!?b_)&YnicvXfDvU( zxip_LM<#!WupKItjtZFzKOdyyKu}_iHS8j?xl@>hbjK|ud*_BTr83Wa2j7Pg%FH8} zwz=Q-Jw!C7Oss5_HZfCu$fV8?A&k&{a5{gFXmLBF282oEiZwtT7(_GgR(qvz#%06? zLSBUDT!W04*rv|e0Bmz8p^|UUK@)T}Gh1!0B@qq4rT8vJ&YF=+@2q|_&dY^^rQ{IZ zx$8gl0LB1y3eWeaR{_!{rGafXNu`%>o;CvkAL&gID>wQ;W4{-4@yudFp?U&iMrkCF z>V9h}C*+SU-I3?`NPO^&wR{C`!%HcJsFe2n)!x`h^ZI!~=60iy(^)5*iC)oC2SmK_ zK6lFGV_y-A?>%v}+-XIcDPe?kfdI=Pmv%4%HInO4z8VQ0OsZ+PIAvlc9ZpP4BzSl( zQMgj9`drM*wSGMwNbGgiP+z>UJL78g)yW8JJtXsKT?NdWWqd+NJ z^4c%UGr&m!5ONY$l~S=@vObn4tYM}LK{>5+H9trwRG~HfuoasL*Vb|m1&46x1pNPFH<8P64-clNK7eYtN@V(G-Ll2og+J`k6=M4Og%=8m^S7z7-2r3&w^|YbGQae#hR+ifwb^ zj%y!|g)JKgKR%2#p4+{BLasypD`^XHq^ZrRekRDtUlhzSoGNT@ca@@mYmCKRy91~k zq~oc2W!vSO#!5pslFB~Br<@jcM%mO4lgQ z&(hnn#ksgo=ao?|g9Fy5cX73Q-j@B`vR}WpuE|Sr0_7aP=fd3PVGPV%6t?1wgA9%| zupwxOFcZWu5MyigPr;@bK0#q#1x4W~Bx%2*y&RI0r1Lai_{B6&R0nM$2%Zrr@VK-M z)@xK70NqK5Lzm_j$^b+h9YDdZmf~bO6ttAuGK>xh)6I0EG+8#>0bqLPSA5WJ{YtRm zLznaJci#%?LtSs|V#(1S^Yq?WlbV zME+m4hc@IX&z?4+^y zwkW6QkZ<@tPe^ekdcZ8O_ftxkf04zOnw7CEoh`}wn$}k`@#~HFO5l-(cO%x}tA$cX zw%a~!+aPQKiAXMGI{?0~bU)TNB6YOTRVR5{oDo)lr-pCsN50I-Ps{d3-|6Ph=;q4B zC~wfEvAo(r{!X46Yl~kA`CuBlM>?B)>G6b6ncjFl|Ej3+ht=zU|M*es^%=a!RZ>yu zqOos5uV}x&30B_0bELv>w{iMWFa|tz_EoF zvD_U-)Xf+xj^TG8kXS`bFeyrcS1rHFnj?+bJ*(>(?a+*%?=Wghf^sGto{C3OKsYZcHx z^NNW*iHwZgA~56ny?PD6YJln>OOun8h2;bCOX=w_ENxF~oj1Y*zLf~y*ijSFnF1H_ zC!I58lL-#ul(mv7U&NENOB*7l^#r=#DX{5^M)JzKCwCxnLr5NA2s#=X4t-$R?{pGg zq%rgoz-1v|@&vq&`hlX{(p6Y*mNoOPMI2UuTSK(s9#4Uh#K_Bwx}dO7YgX5yv{axr zL(K>J-RkF_=OLgI)6gr?B^>jy_O(9LP?*K_m@0P*q>LYa5hXgQU#vfllI3#>I=QGr zIlq%CWKyocm|>0D&0lYeRDJPK?5S}G2}y*`tDNNP>uZAJvNcB)RaIf9b4oA-0_or(gY8xIlu{eOpsy5N!*d>;4L z+qq5|=bbR|Ff}&}(F^gW#8ayTM$Kis>DbL=m{erc$FE;UfLkm;wb~=<(z)yHQMnKl zS$u;~FX6kM5aN7YnjBFOEF`Z?AZey~YDrmXXOh+fTt^Zj9j@?0?wkWV=QAj*{QH&v z+k#*1MO;iG3%o!m2_nTSFH^11LYGr(xv`T}a7Kwx-E)MYu?pSYEHpZw$oieho~%Xk z;3dhX0<19&z2BYVRoU_l7a-C=e=eVKKbRd{riS&KA>nKi3}-LtTXj=Oq8~qS4r?E1!--)@5@RbT zJdroisYGZh>HS~3J{SYzlxhT#?z61nS{&{ShU-7FE5FP$sBSLBx3{-f5paeuBEPHd zldJXE?t_C;5y|I#*_uvj3ytegrZj%PEK*`SPQC_&*XW%*tYrVkv`Os+CAl80dt}Q; z8+z+6;`v1Q`By8Gh<^0?;;kHRjhQzN7}mgD;p4}fP80a7!|@m zm-PfiI2RSMiFlp8LM5XLgRiTA$SD{#@d9EPBQrB>U`Ruki(3=olPsup25pbCvu0mE z`@UhZtl>=zO)gleUH|dZL~-WZj~tTRmsmgN3M&wkQ7($@^kX{J0cjGiEyUY20a@2fz01(u=^`Db-#5> zh%D5O-$(p;8Q*YMp&YcM0&JTA%3)t+^({9#m+fYZMC@qYRnNqm7&i$>O<1$aUp0_l zMzAGZ+1qd%!@JGNjlFTY4@n~N6ARZ9eLCuPoRw9pc>3f(n-~jMf;58cDdqFkP(z(L z0eFi=yg?w#ol1b+o2IwjT-rWq&9)rT+jrNepQZ)V#N6!`5i>LSSK#yrCK5|^>u~A0 zKWDY(41^U@d}^)|UgWcC{G?4njqBvaaz-_CE58ZqZCgE% zKQ_2-Gv8Y?xarEMccqp($?x(M#2Z(T>MldUDdH-{(7_k8=mpPv)>!E3Nkt-9g22yL z(-rOhcFg?niR`n016LXrJZ!aNyiG+~yvVP0sUjt1BI;}IyZGkJ8o4;=;PAa#4m#U( zfAE77re_~_9c$l8vSS^SU#SyqV^xZz-7tH~i&xMPLtF@VN?CF&^SASg>m-cDkyT3l zJP#7*)M)x{f!)`2>HMr?VHv#o4~-re2p*yKxx2#Wvk5+%CM~u}7nJ&XO)Q%^4P?ves+L3J9S244mLmi>Ujl=ElUCD)_XQMv;FG zI}#Q(9v*G5_kOG>`DOPte0Ozw&Ie?|+gULYi*!EC-nHoI{chacm$x-pf3sF=EmJi> zcX{>nwACatX{~6s^7-s{r*G!NSS{XyVZ^QjdIIkYp}|L!iZ5VENx>}_r3qEahby3U z9yF$Bzzfk(!P1Ig7R@v+*4(*lL;cwS(OUgU{*ZKp88u&1(b zphASU(Rt5dY1QVt-7nYkdz!LVTsdZ=Yp4H~X|Z!mqW5KxmDpHGfdC|p*|<+#F$Vco zv8xf^i}R{hfQZr#VTJq3mA=)nzTXwj1ta&=N=>=Isk>dBqQ&uC~z+M*nfwq=29JL1~OU7%HNT5zPolqxO^G9pE@@HwLc#p3zI2eQuE7L7lo^tsI5)~2oJ-jgr_xRxxEzD=w$tG z62Az5(+lhJR|s5o@7eVIUZp_}K$MYRW7}fo){PJnLLcYMqHEy=A8aXBFg_Yxa(eUm)O zpo1xs)b~R(Wld~O>JW)BZuXcEK^0Z%AI)-Q=%`4KxX3)V_|F$Uah*gEB#A2++HrwTU zOI^$T5|_t%a!Q`sJm(?0vWnR3Nfuf1n80VlB7zdzg_!lXPYOO1HEhnD>?M7#r&s)e zSVM&9JW;-yAT4TBYXxq9h>f3N3%l~t#&M}KDBoC()^nRsTgNUv?+dS(qCc^_dpa=l zK9~Iq<|}drPJYVr6)h7sQ0&~FU)%m$sb;KSHLDe=dVPCWXDOD%kFpcezm(oG`ESNY zvV^cWaIn20rq@wc(T=ZE{A?WmQ|S$*vZgiBCs9k^;G8UG3+vCtf5?8;Um3Ujdrtbv zONoX9ru^9LuOC5Y%>KK6R; z=K0UH!&u|dZ4+a-14i{u;!88J+OiK~iR&hf;Q07>Ku`KY#OI0^HSYjo;*PoYH-G>J z)$RiqEkl5-u>?(&Hr~LP<#4t$y0j0-KLIr!`ZuQhEGbb@TtIyWHpOVa+vG7aGRih5 zQGd+}%qz71fFkW;jtwXqs2<+F9Vw*S;^FvStn@m)Dy;NPimqMr&t!@6GM5MDEJ;t_ zvwrW&gH2jBpPzdlG)5C57?fp7qW(_{ut8ob|A|rKyM(WY32=3*)nlCi>xqpLE9xsk z`_)=ebNR)r`81iZ(v?&$N~a6xh($dAF}0j@J)zkU@rz-QB80~>9JgiwSWjUl#4CuF z_XfBH7sBIrJy6PN>gGnC0lBe6WcuKs6m_gBY2W%WyTZGk@U)C@WErJ9TB~S>Wz_Ee z*c>^l#rSg=p6o-I^p1tD8?tpHE#JpVUwZ3Cnbk(#EI~68M=KkS=EW-F{&{03@bhwY zKnp(~aG}yEPQ^}4nNwFS4bYNj-k%lH;F$rUMwAfatC^v(F@{Q-2s!{`C=6@RHJ*E2 zoviLyD}~q{T7h#>!_E@$%|Ll3MuuPaJu7$dyQt3WtR9wp0^HQypUaoz&2Y4+($?h! z@(aDfESvl;?W%Ou{1-pUft#~9*ZlAzMokZ5eNUn|Th&Z@tr%Tjp1E+eZB;e~=L3tT zBsX3=Z0cMyxH z0ze1ds?!nxXrh6gsZ~5KS_H)0S9_Z6O-D0VS0dv7{xG)cGypq1i3Oy&Cx}?Tv|4FV z*F91|)accX*xj{6>FNQX^}N^f?&?1kr_aCmOkHqL{bT-A1Fx20+Byb2S?lG92{DYQ6=AyYt#RJA9Fc{;-UYeetZT^UL=_QL(WV)gibQh}$2-QV%}I z8v9T%+zacwPRRm@0=RDB=H}Lo05skrz~1jp!lW=k9Q^?B917?M2&!r-9Bvc&fztvE z@4!^-b3I@X3a9tRM?(WTz7^E#eYRd_@*VR%NhvFs7d3vvcEPKAWR0h2pnNghd7XE7 z3}L+fa(BV`N=EUG{!7E9@ZrPpu}ql)QkOVq;V@aD@9`E+^I(3m)9@)Ewl9`SFwyD^ zoi0!ws?J1mrinTiOvfCqi$1P576a5;z_tBKK-GRE@&T_r43MP)oBncq-Wh}=93`4H z_-qPbID&yAK%L>S7M`JWl|_o3`5m;yX82=zPEY{2tg65!hCXo5wYY5-I4{fQ9jR7h z(Hp@Wrn#iZ8KAvUQ%4Ls&=HEoiET}QIIaDfS21nKqZJzZ!6@qm*Y{Y(Qt#{uo=+iT z7cf%(e=U7?Je%$NwpQ_|mOe^Q1dkblQdE$t){fCq1ho=s1hs3_s-namv1?O=2x8S< zG27aN7By?Ml%hua-oC##f8>uOpL<;Qb)DyV9mjbbF)^Y6x?s0IX9fb*_@$|;t2R4M zZvOuMIp9|2sXSrLnZLjkkW%@yJQExR%(HF)Xb2$aXUh}wgDVy;VOxX>e_=+CxY8(f zLAODs*qCqwpG?#vS>6a^Bo>gCcF59+K*Cn44>769)yJ4bEfq=&WwPY8h8$fb58}Pr zGw1F0w7WU$0|NuzCK*fRQZtj=h$ru;mY6^}Kv!*fx!H>$nL{IH#9!a4t!B$3FytO% zzhsrQ$D;d{UE!copk5cgqSEKt>&(5tOF{c*jtJ!kGL5mt+3X9`xe~5Kv*^Yl`BLN7 zLATwY(2rLFYz?bt8v3p4kpA0#w$zz=uI~jN-fXp{W79eLhI9%8r%~L8ffK#J_}JK7 zDkl|0Js%w_DEU{+-_X!-+g+4rOLz+0_wPRozYg+##jn!Ne)tz##wxls`fIGnMa0@_ z=rPQ#9`VZr;AtcqT7QD^Ducf|3ohj8?3rrJ^*84xs-ocOybPS1$d(`TfnlA6=K0|&(A;o0Xu(E z9nss?YhP)>Z^E-YD>)T7u$0Ixn8{@l?SdE)F_tOrGNeyy8Vm7YV3&Vomr7mgd=+#G z%AksUZ!+zv6j72qZpjfXNIUekfTQZHn(LXf^YA=fLWK_U)QnE3rJa5QB>sYCK+yD? z`|e_=cSArEA+ZMlnol~dWM=`DLkQm}a{b-wn5nc9$)(Z#eltyCTETLtc@{;1T>!jL zN3;!IRsGM)IY((^^vNqG(3~-hvHbJ`HQQBe-&cH`gY#%P7(?uiAtW=_;;RN`js%I& zAidLk0swzrsrEr62X%{3eVRhSl+k{xpl8@ zj)Vw4zdtZn`{)?t;)u5&hcWPKG!%0kTrH(9nk%DJ&QeJv5pyT^FM2V%?eFbpTyMw{ zf3jix9Kq06Bh&($L)Ef?7oftQW1l+GzM{ra`MDPQ@15Kzgz%UTqH`*MXa1$dd_@rG zl>xjv2&WTxFU32nAeYkd*+;LLz)CZx0&d5TeZP?x5h2RnF{6jNL%R=-w44_f{pweS zO7epayGyUD)vA^n5D(@f#2>3xVmn3c`8L}2`Z#x!bI(+7+H37_N_+FEEQnb#&mYAH zpP`M_MI&`PXOKrjnZ5PzK3~N9Al&nypnE5znDB5K0MHBheQ_0`>$$-}pNYVG z+lx6LAR}I6ZD8{WH=k-19V(w4*H+sLi5M2`Udv69G z(*-Btk{f227R@Vxh%UF_e_hQ`^e30f#S#S~B>nN9qjn}Ls)e()yVK|X@vK7>CgEGW z0(M&M<)X(xMuWbcw}vrfW>0~u2ScUh7>r?VgS1cunHQ#Bmt|hK(M;92W_2Yq2tt3V zEsPf-<@h2zX9_=d>N>||hjV=nnWPnF<1Do6PEi0Cnw|E7fzq#`fAMgaD|jK{{puSA zf5DdNNVMrRY0Fq_6;MoZo~5ke>Wz$OfYNLy*3rYm;H*9G7O_YQu(Daw;FBqGcS}hc zr%)TiM5+%fJ*Mh~ARoewpwmjad<`V+SC`xM~)FbKR zln*v$3d~=EC8v$k^*NChjv&(3^F9BENMC?N>Uj!IP zrqVtD)^qhQzsuRy{cL^8pH8HmxCe;C ztzSiT%BfqF?!)gxgF##=()7^P5P3>k3DDV_tK}8otU#;8xjw~E$>R_49^Db-kGvHl zk+SM<<>;}0RTe~r2fnri?S}Dmq_rIgQc?MCmX$67AT?*3fyy$A7vNP?R#xWCbI{p< z(toQ|)}}#^QON(qeeb1w)2B{KB(^K+{`NwcqnoF!tSpT_{1=cRv65|#DztB^?Apz3 zJ4Ea)&>-_mS{Ikd7FLCJ0`^)Zg{@#Y2c5c?&r}A*C+d9zj1hXEB3lu`J8{haH;NPi z#|yg(c*lV0-4-J~#kje0(wqm*yc%iQ{U3Fo)LSLLnxm{*pyuAa(64dYQdyXOm33d~ z|M#MN4e;5koGmUE^dx2iS~6CrGp5|iCmTL4Mn+rFv*>vET6CHW-33)UZ57;lOL5CGtwv?8=uA!cq;nhKUReP zeeEQL(c<&PPbZ%!=$^i)_5NE-JMwwCISkU^zv49@jyD2pZq66_QppJ}1+RZDv`|r2 z6(Jz37P%xm}AkOS)+GU)Fmn^_0H<_7pAToYTZglh9Q*h6GJ zhh%AAAchMGHsW7Euo211RsjPmihIj|;X(I4dp`FN!kyR?LrJ!6B)y&km`6JOd%p!P zqCFL+_v@sIRX7k*043oTYu+v&`}gGf>O(`H(1tJm2;J>-A0u>R{m)@{)z<@ElEcnK=6DXxMjZOI)F0Iw5a4p zr^XJLy11=uI3cG+h0``o%D?aud;aOaw-$#fnrdhR5=0lOT3&nADW9q|X;UNFVR@X= z>JefK4^KwSPyGpj{!DjG7~;n>CwOp zu+Y9ta{aMRu={*vK`-~-=hJCRU}pe71=v~m96)Q_WyM|qw!mp8IlJ>8MlP-lB*UBZ zO2=yN@ttq^@X{MG`#X{vz8)_9Gr1Aj36GMqV=KmMes$-%Q z!REVfeT#35AI+51WJ}x=g6C#tZnma1R99P{5P#bA-c-i?Ok*b9wMVYX!Ybhr-op^- zvA5Bz!|7atMg)2Xy(7Hhn{Bn1P#m`Me6om9OZt+M&@b&|+D?Ya((pU9!uAkLV;N-! zE5TFT#oCgEeRPM&fjPmb6Uyb%8cNF&dPWYs53qZ8H;|vcI&g&1u4C(61x$w;8w2|X zkj^yPgHAhAbzFIg1<#2Lj4Qw_(zz4N#Xtp zJ`9hy`JbmvoSjFXR{XeZGNPPY?;~cYxm2lWrgjs#D&X-;S8Y5~Q&2vzLRYV9MF11@ zsjaTfbFLD(qv%A-%o7fxtfBCV9mGnS<7}5`H5Y;n+=< z<$(gn2QrxDwyN%U!>68m^fHc$dB1G_xKocL(3-g6)%ufnfmVh!3uMy;0)&Zw%<(07 zj6w~e*TmEgBKtzixY`X==>tu@iAqNzDplem?dk1i?GW@Nz*F4UktiOaNXpxWW#fXO zNKXjePWx-+vbG}BFPN}iXq%N|SAwOB$36F&sWx;ybjKL)BLD-QL_>dZy$#20OIFAF z%dE|;E|Z;$viGOovFaIT@?WnIkDX{!+TjA_FiRT*=;+r*?e&}G2fRMC$+0gd&ypxf z)oWxG{@6P{N--4#wPZ0JGh%@UJNB*mU>EV^>1Bj6C?h?)Bz_KEqg&^<3YAWe_T!l@ zh@VC|F)xc`xCxjOuRv`8rJ{H?yVDA(DjY>VL4_J}UxDCR7Gy{}>LRsUq5!bCR4yauvIDjMP z3jGuG3yr@8f@5_$__3ph?^luA@{vZkL3e+%50?ux_QgwjP9|Ow^HvL<3d~L2vrwyL z;S-VXz}+{EcDgzN6eV`VSDm=2@2TCI_xj}8*2Dt8i+7j}%kWfl$vjfxOS1JC?PH8O zH{Hewk@PDpZ+ghoOIiGABcf7C{idEb)P`QnZ#@>4HSXAG#y-@1l>RqzTDR zvoVec9SZiB2q@^#bKW2>6MrPq6zlgxFM}+w?ce~G7LdU~Yl`JZ!GXPza!e|9Azq+x z$9;%6;~vMw3F2UE4r-d;^wXWw{t+eQjMAQw1N2HVGV*4`E{_9}n*Kxcr%BMG%mR`* zab{pP{nE#oVl+7G((!V5k4qK!7b99OkbtOq(?Hr%s89);N6MPXHc9rQ zqr^~QPnGKYETU}{g$snquW9rpLV zdqjlT)jDdQZWGa|49L|=kMwy`RK5Zb%S;)wYh{#`2qY|*q?xODl;GWfD(O(M%&AMJ zs{(Z>*j1A(uqoob5p7IhnkMbrJp|b;s|dX}Aj-DePiI=uLgQRZe`=h0*p2?k&!GPM zQ`La`3SQ&tkZwT zsT(xwdlo(iHJ&TBZK}T!nI7jg|26qpZjvYyBR3PyTYB4z5i2p%URuxONqEU81`5LGrf3V=u)z&Qo^Gmfs3rHmoG}Taa(D= zZ>PlP`fBXFMy=LMSgLUmbB@5Ojql$Nj26EUk7uoj&DN!(u`5{ z8*%@Yp+_c%QlmyE6iC*Ir2dcuYGPHxJ1sZ47C#(CWGOAe?hIm*kq}U9dTVR)nGTs5 zDw)ZY&Rg+2k7RBqpwz4@oH(J%=7m22acHNeFWTG$jn41Ml-VbaCL9=U`=1lHr3k^g zAf1tj={DiW2d`RGA&Tro2Q$Um!Cp-Wj`C_>Pc+h=&%l%c#3L@v47M05ssH-<%orL~ ze(U}i(_)*ai!A-ReK`SkVhqQ|MAWqzItO?|1BPntQO#5Hg?zsYS5;!UEW|)OfahcW z1YIap&+J`4fm(|!qHF15Tof`so#ec$l63{E!w5dj#%u80{Ax7_RnoQbop38*lMSdJj%JDzZvdS-NY7I1TgNz8Gm`acL=!yKiw`s3M`7B>vZ&OLR{Qdw;Vp{2ugl2eTa@&=mNJ{sN@l_{IB?{H#c%So+>Ib- zM=4E$;oeNIOXlBD18FJwK!PqoNl38F&#q43&EBAShg?>5uJ(A4pNko=|4ND}!4NU9 zXO8>*De9ZGwjbh1j%>{RqkH8D zzu~PPLHYwU)-!i3-?QjVXIa5o6oB4kzRpXGW(f|ri@x%HGryXh`s975J@#d}&Y(IO z;DR$>It8S<+jTA_UcbUku+*w%I*`&dINRL-DioPOgyIXRed{ah7dIX$?`}QeLHk(^ zB@=ygr}>xF#()-^6g|(f7!xgnETG5UStIMVC?Z~~Xs}1Fo~GjIeFQsf2V^C4nhuOQ zlQo?1{TZ+Q8JIgB4_q?I(_t%3Q9VAurHJ=;*T3&~cUSzKtvDme5;y#IL=BThHAkh` zbeSM=j~Y=?*tjW;sE|I*&n&v9eB4;>QY0A#vn;ph&T?!0nJ!xRCgyU}^8Tj3EyUOg z&;pD++a$7sesghThrQF$4yt)7uj%9IMbx>bo6BDU*p5PS)*pnG{0^bk6jsg3djd5{ zfCM3J1q3WAMvzH~6qz}kkFI*5w7{bnleg4!{Ujsi4?Jg?`-+oCEP({2zCuiDVb4LM z+!m4j$y?6SVje`hmxV)9D)qWFp-qT~^I;UWfQ4K0cW|azKnJj|1tGQsFbw@Qc~EOb z+@66SmIuT^uQiRXRsEW`1Ln|`izt@VNliEwtQJ36T#<)Y!G&(_=WGeHRqDsFaK~s& z^JBF}msdPpkS=BiA|MVX;u#xe{A|$+PBWgA1~JR65*ty;V?~IIyrn7)39My#Ku}@_ z8D9lwNtwoT#|TP=`(T}8SkA8sz93Z5+NW(rV&VrL&5o_~^i-gu^smK3h14X~B2hUe zJL$b_dbI0m*x368l`bcGs}!Gh;B*s{W;?FuOnlr&r)kWXMf5m5%qdz(xJ z`a03BK>H*;XR+p0!mqTR%`y3=tBZVfctP$;yu%#2qlaZfj{X1@Q=@N5>PkA9SIH_Q-Rk0zXL9 z0)D;+DC9KKHFzpWJAsmB^ilc+$UG@VEsfuZ$*@bkee@?%q6x#3jKYp5ZaZHJv2u98 zT#SuV$0H-wvz5KNnoz6U@2}X;i9O%b5Ou?$rBGUZXu6ahd!l_}Cz9DV4j%7%m14#r z(9~vnP`U3yN*x0Mhvr``4YG-q>SG?tR>~~k|F<@EoGGr8T_Tf;3Q&-n3gf+tNOX9Q zTXi=^qsC6?cf9ygo;J#8Vd|TTs=jGTPoW`2lql9aBPD8m53AEqH@8YC?5x@`;|0$X z^J{LEZRt8qAIJ+Rg76-e2HUhbCDD)DcTeKuV`D4-wrtM;9^8MJB(eboKxTohn^+05 zPl#QTo$?*>#MmJ?)8&J*u0|3OjNlUt4JoiEIwsOzG1dJd%674)WdD)>>)B^bvPq;z z+EsdR5lP>|$B7i;rr8e7tC!l0^(@)ti$*2nxb;_2-31R(=h#ON+PUMrfL5At!#j9V znz|@14=OaT{wtIHAzm4)1YZk9@WQmhlbnRt!g`ZbJ z;sB5B$~#3g>KL-GE&8=qFZNY{UOq!8?EvQr`-B|4FR@^`I0gPek3JXP{KVMY3Otv< z+dez`6}#8}YHvl_IyHHB6YV#YiQr~1dABR|(SP_e*5+DfTZ4nv&tv)2sMh+K+PYD+ zc|HCVzYVxz-=(E;Tj~lIquLd4ZS~%oQdrH-d#qG*>%h66S(Bp2R_~o}{z;BnBRVe4 z;IMpHOd~4YB3c8K&|*Y4WbQ3@6S92NkAfb9+%NSfZJMv|A4NgJidJxdU` zw@SH#@T2*MOmM%8(`@}$1KjLgs>RXW=5o=-qb~u|j*co57wBfJwn*ku(*%=+=6shZ zj019L=+)x8(<|qS?+7@}U)eP8|Hcn=muh}aEAt0yA1smbSDE7Bu0Ix)Qq6BSDd937 zI(bY>2(cx+)PYA>jkAx+F;*}k_3F-|6bm|rPAcL|BRo9u|_AG zMxn_!j~K4bR#Nu}k%&@OBV%nPr}P1tf_~M4)LijDfkg*u-iFQx$;^LBHvL9Vst*XW z0Mb@RVW@f9dCiwWXeTRRgjGjO!EDps{s-KNe}?Pb?4a!85 z07XW*q`tg-%s_PH{W&BByV=c#mpc7h4-NsqpShhNNT_p=>969d=+7I*Ij*;!ZfZ_-jHn>KAVH zy)-wA3OFuo^X;h?1<%ST0Kx^lbca((Oy@6iF;Dcnr>YcqXhMWH#OMD1cU_WGLA8sK p2gT#+fEySU{l$L-Y%=c$Un<>X6UXJ_nt%_fwAA!es~%Xt`hPFbe&7HA literal 0 HcmV?d00001 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 + +