diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/coupon/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/coupon/models')
| -rw-r--r-- | addons/coupon/models/__init__.py | 8 | ||||
| -rw-r--r-- | addons/coupon/models/coupon.py | 92 | ||||
| -rw-r--r-- | addons/coupon/models/coupon_program.py | 159 | ||||
| -rw-r--r-- | addons/coupon/models/coupon_reward.py | 92 | ||||
| -rw-r--r-- | addons/coupon/models/coupon_rules.py | 38 | ||||
| -rw-r--r-- | addons/coupon/models/mail_compose_message.py | 15 |
6 files changed, 404 insertions, 0 deletions
diff --git a/addons/coupon/models/__init__.py b/addons/coupon/models/__init__.py new file mode 100644 index 00000000..14d4dcf9 --- /dev/null +++ b/addons/coupon/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import mail_compose_message +from . import coupon +from . import coupon_reward +from . import coupon_rules +from . import coupon_program diff --git a/addons/coupon/models/coupon.py b/addons/coupon/models/coupon.py new file mode 100644 index 00000000..5f023906 --- /dev/null +++ b/addons/coupon/models/coupon.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import random +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ + + +class Coupon(models.Model): + _name = 'coupon.coupon' + _description = "Coupon" + _rec_name = 'code' + + @api.model + def _generate_code(self): + """Generate a 20 char long pseudo-random string of digits for barcode + generation. + + A decimal serialisation is longer than a hexadecimal one *but* it + generates a more compact barcode (Code128C rather than Code128A). + + Generate 8 bytes (64 bits) barcodes as 16 bytes barcodes are not + compatible with all scanners. + """ + return str(random.getrandbits(64)) + + code = fields.Char(default=_generate_code, required=True, readonly=True) + expiration_date = fields.Date('Expiration Date', compute='_compute_expiration_date') + state = fields.Selection([ + ('reserved', 'Pending'), + ('new', 'Valid'), + ('sent', 'Sent'), + ('used', 'Used'), + ('expired', 'Expired'), + ('cancel', 'Cancelled') + ], required=True, default='new') + partner_id = fields.Many2one('res.partner', "For Customer") + program_id = fields.Many2one('coupon.program', "Program") + discount_line_product_id = fields.Many2one('product.product', related='program_id.discount_line_product_id', readonly=False, + help='Product used in the sales order to apply the discount.') + + _sql_constraints = [ + ('unique_coupon_code', 'unique(code)', 'The coupon code must be unique!'), + ] + + @api.depends('create_date', 'program_id.validity_duration') + def _compute_expiration_date(self): + self.expiration_date = 0 + for coupon in self.filtered(lambda x: x.program_id.validity_duration > 0): + coupon.expiration_date = (coupon.create_date + relativedelta(days=coupon.program_id.validity_duration)).date() + + def action_coupon_sent(self): + """ Open a window to compose an email, with the edi invoice template + message loaded by default + """ + self.ensure_one() + template = self.env.ref('coupon.mail_template_sale_coupon', False) + compose_form = self.env.ref('mail.email_compose_message_wizard_form', False) + ctx = dict( + default_model='coupon.coupon', + default_res_id=self.id, + default_use_template=bool(template), + default_template_id=template.id, + default_composition_mode='comment', + custom_layout='mail.mail_notification_light', + mark_coupon_as_sent=True, + force_email=True, + ) + return { + 'name': _('Compose Email'), + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'res_model': 'mail.compose.message', + 'views': [(compose_form.id, 'form')], + 'view_id': compose_form.id, + 'target': 'new', + 'context': ctx, + } + + def action_coupon_cancel(self): + self.state = 'cancel' + + def cron_expire_coupon(self): + self._cr.execute(""" + SELECT C.id FROM COUPON_COUPON as C + INNER JOIN COUPON_PROGRAM as P ON C.program_id = P.id + WHERE C.STATE in ('reserved', 'new', 'sent') + AND P.validity_duration > 0 + AND C.create_date + interval '1 day' * P.validity_duration < now()""") + + expired_ids = [res[0] for res in self._cr.fetchall()] + self.browse(expired_ids).write({'state': 'expired'}) diff --git a/addons/coupon/models/coupon_program.py b/addons/coupon/models/coupon_program.py new file mode 100644 index 00000000..afcb3da6 --- /dev/null +++ b/addons/coupon/models/coupon_program.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, ValidationError + +import ast + + +class CouponProgram(models.Model): + _name = 'coupon.program' + _description = "Coupon Program" + _inherits = {'coupon.rule': 'rule_id', 'coupon.reward': 'reward_id'} + # We should apply 'discount' promotion first to avoid offering free product when we should not. + # Eg: If the discount lower the SO total below the required threshold + # Note: This is only revelant when programs have the same sequence (which they have by default) + _order = "sequence, reward_type" + + name = fields.Char(required=True, translate=True) + active = fields.Boolean('Active', default=True, help="A program is available for the customers when active") + rule_id = fields.Many2one('coupon.rule', string="Coupon Rule", ondelete='restrict', required=True) + reward_id = fields.Many2one('coupon.reward', string="Reward", ondelete='restrict', required=True, copy=False) + sequence = fields.Integer(copy=False, + help="Coupon program will be applied based on given sequence if multiple programs are " + + "defined on same condition(For minimum amount)") + maximum_use_number = fields.Integer(help="Maximum number of sales orders in which reward can be provided") + program_type = fields.Selection([ + ('promotion_program', 'Promotional Program'), + ('coupon_program', 'Coupon Program'), + ], + help="""A promotional program can be either a limited promotional offer without code (applied automatically) + or with a code (displayed on a magazine for example) that may generate a discount on the current + order or create a coupon for a next order. + + A coupon program generates coupons with a code that can be used to generate a discount on the current + order or create a coupon for a next order.""") + promo_code_usage = fields.Selection([ + ('no_code_needed', 'Automatically Applied'), + ('code_needed', 'Use a code')], + help="Automatically Applied - No code is required, if the program rules are met, the reward is applied (Except the global discount or the free shipping rewards which are not cumulative)\n" + + "Use a code - If the program rules are met, a valid code is mandatory for the reward to be applied\n") + promo_code = fields.Char('Promotion Code', copy=False, + help="A promotion code is a code that is associated with a marketing discount. For example, a retailer might tell frequent customers to enter the promotion code 'THX001' to receive a 10%% discount on their whole order.") + promo_applicability = fields.Selection([ + ('on_current_order', 'Apply On Current Order'), + ('on_next_order', 'Send a Coupon')], + default='on_current_order', string="Applicability") + coupon_ids = fields.One2many('coupon.coupon', 'program_id', string="Generated Coupons", copy=False) + coupon_count = fields.Integer(compute='_compute_coupon_count') + company_id = fields.Many2one('res.company', string="Company", default=lambda self: self.env.company) + currency_id = fields.Many2one(string="Currency", related='company_id.currency_id', readonly=True) + validity_duration = fields.Integer(default=30, + help="Validity duration for a coupon after its generation") + + @api.constrains('promo_code') + def _check_promo_code_constraint(self): + """ Program code must be unique """ + for program in self.filtered(lambda p: p.promo_code): + domain = [('id', '!=', program.id), ('promo_code', '=', program.promo_code)] + if self.search(domain): + raise ValidationError(_('The program code must be unique!')) + + @api.depends('coupon_ids') + def _compute_coupon_count(self): + coupon_data = self.env['coupon.coupon'].read_group([('program_id', 'in', self.ids)], ['program_id'], ['program_id']) + mapped_data = dict([(m['program_id'][0], m['program_id_count']) for m in coupon_data]) + for program in self: + program.coupon_count = mapped_data.get(program.id, 0) + + @api.onchange('promo_code_usage') + def _onchange_promo_code_usage(self): + if self.promo_code_usage == 'no_code_needed': + self.promo_code = False + + @api.onchange('reward_product_id') + def _onchange_reward_product_id(self): + if self.reward_product_id: + self.reward_product_uom_id = self.reward_product_id.uom_id + + @api.onchange('discount_type') + def _onchange_discount_type(self): + if self.discount_type == 'fixed_amount': + self.discount_apply_on = 'on_order' + + @api.model + def create(self, vals): + program = super(CouponProgram, self).create(vals) + if not vals.get('discount_line_product_id', False): + values = program._get_discount_product_values() + discount_line_product_id = self.env['product.product'].create(values) + program.write({'discount_line_product_id': discount_line_product_id.id}) + return program + + def write(self, vals): + res = super(CouponProgram, self).write(vals) + reward_fields = [ + 'reward_type', 'reward_product_id', 'discount_type', 'discount_percentage', + 'discount_apply_on', 'discount_specific_product_ids', 'discount_fixed_amount' + ] + if any(field in reward_fields for field in vals): + self.mapped('discount_line_product_id').write({'name': self[0].reward_id.display_name}) + return res + + def unlink(self): + if self.filtered('active'): + raise UserError(_('You can not delete a program in active state')) + # get reference to rule and reward + rule = self.rule_id + reward = self.reward_id + # unlink the program + super(CouponProgram, self).unlink() + # then unlink the rule and reward + rule.unlink() + reward.unlink() + return True + + def toggle_active(self): + super(CouponProgram, self).toggle_active() + for program in self: + program.discount_line_product_id.active = program.active + coupons = self.filtered(lambda p: not p.active and p.promo_code_usage == 'code_needed').mapped('coupon_ids') + coupons.filtered(lambda x: x.state != 'used').write({'state': 'expired'}) + + def _compute_program_amount(self, field, currency_to): + self.ensure_one() + return self.currency_id._convert(self[field], currency_to, self.company_id, fields.Date.today()) + + def _is_valid_partner(self, partner): + if self.rule_partners_domain and self.rule_partners_domain != '[]': + domain = ast.literal_eval(self.rule_partners_domain) + [('id', '=', partner.id)] + return bool(self.env['res.partner'].search_count(domain)) + else: + return True + + def _is_valid_product(self, product): + # NOTE: if you override this method, think of also overriding _get_valid_products + # we also encourage the use of _get_valid_products as its execution is faster + if self.rule_products_domain: + domain = ast.literal_eval(self.rule_products_domain) + [('id', '=', product.id)] + return bool(self.env['product.product'].search_count(domain)) + else: + return True + + def _get_valid_products(self, products): + if self.rule_products_domain: + domain = ast.literal_eval(self.rule_products_domain) + return products.filtered_domain(domain) + return products + + def _get_discount_product_values(self): + return { + 'name': self.reward_id.display_name, + 'type': 'service', + 'taxes_id': False, + 'supplier_taxes_id': False, + 'sale_ok': False, + 'purchase_ok': False, + 'lst_price': 0, #Do not set a high value to avoid issue with coupon code + } diff --git a/addons/coupon/models/coupon_reward.py b/addons/coupon/models/coupon_reward.py new file mode 100644 index 00000000..d5ad92a9 --- /dev/null +++ b/addons/coupon/models/coupon_reward.py @@ -0,0 +1,92 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class CouponReward(models.Model): + _name = 'coupon.reward' + _description = "Coupon Reward" + _rec_name = 'reward_description' + + # VFE FIXME multi company + """Rewards are not restricted to a company... + You could have a reward_product_id limited to a specific company A. + But still use this reward as reward of a program of company B... + """ + reward_description = fields.Char('Reward Description') + reward_type = fields.Selection([ + ('discount', 'Discount'), + ('product', 'Free Product'), + ], string='Reward Type', default='discount', + help="Discount - Reward will be provided as discount.\n" + + "Free Product - Free product will be provide as reward \n" + + "Free Shipping - Free shipping will be provided as reward (Need delivery module)") + # Product Reward + reward_product_id = fields.Many2one('product.product', string="Free Product", + help="Reward Product") + reward_product_quantity = fields.Integer(string="Quantity", default=1, help="Reward product quantity") + # Discount Reward + discount_type = fields.Selection([ + ('percentage', 'Percentage'), + ('fixed_amount', 'Fixed Amount')], default="percentage", + help="Percentage - Entered percentage discount will be provided\n" + + "Amount - Entered fixed amount discount will be provided") + discount_percentage = fields.Float(string="Discount", default=10, + help='The discount in percentage, between 1 and 100') + discount_apply_on = fields.Selection([ + ('on_order', 'On Order'), + ('cheapest_product', 'On Cheapest Product'), + ('specific_products', 'On Specific Products')], default="on_order", + help="On Order - Discount on whole order\n" + + "Cheapest product - Discount on cheapest product of the order\n" + + "Specific products - Discount on selected specific products") + discount_specific_product_ids = fields.Many2many('product.product', string="Products", + help="Products that will be discounted if the discount is applied on specific products") + discount_max_amount = fields.Float(default=0, + help="Maximum amount of discount that can be provided") + discount_fixed_amount = fields.Float(string="Fixed Amount", help='The discount in fixed amount') + reward_product_uom_id = fields.Many2one(related='reward_product_id.product_tmpl_id.uom_id', string='Unit of Measure', readonly=True) + discount_line_product_id = fields.Many2one('product.product', string='Reward Line Product', copy=False, + help="Product used in the sales order to apply the discount. Each coupon program has its own reward product for reporting purpose") + + @api.constrains('discount_percentage') + def _check_discount_percentage(self): + if self.filtered(lambda reward: reward.discount_type == 'percentage' and (reward.discount_percentage < 0 or reward.discount_percentage > 100)): + raise ValidationError(_('Discount percentage should be between 1-100')) + + def name_get(self): + """ + Returns a complete description of the reward + """ + result = [] + for reward in self: + reward_string = "" + if reward.reward_type == 'product': + reward_string = _("Free Product - %s", reward.reward_product_id.name) + elif reward.reward_type == 'discount': + if reward.discount_type == 'percentage': + reward_percentage = str(reward.discount_percentage) + if reward.discount_apply_on == 'on_order': + reward_string = _("%s%% discount on total amount", reward_percentage) + elif reward.discount_apply_on == 'specific_products': + if len(reward.discount_specific_product_ids) > 1: + reward_string = _("%s%% discount on products", reward_percentage) + else: + reward_string = _( + "%(percentage)s%% discount on %(product_name)s", + percentage=reward_percentage, + product_name=reward.discount_specific_product_ids.name + ) + elif reward.discount_apply_on == 'cheapest_product': + reward_string = _("%s%% discount on cheapest product", reward_percentage) + elif reward.discount_type == 'fixed_amount': + program = self.env['coupon.program'].search([('reward_id', '=', reward.id)]) + reward_string = _( + "%(amount)s %(currency)s discount on total amount", + amount=reward.discount_fixed_amount, + currency=program.currency_id.name + ) + result.append((reward.id, reward_string)) + return result diff --git a/addons/coupon/models/coupon_rules.py b/addons/coupon/models/coupon_rules.py new file mode 100644 index 00000000..1412d683 --- /dev/null +++ b/addons/coupon/models/coupon_rules.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.exceptions import ValidationError + + +class CouponRule(models.Model): + _name = 'coupon.rule' + _description = "Coupon Rule" + + rule_date_from = fields.Datetime(string="Start Date", help="Coupon program start date") + rule_date_to = fields.Datetime(string="End Date", help="Coupon program end date") + rule_partners_domain = fields.Char(string="Based on Customers", help="Coupon program will work for selected customers only") + rule_products_domain = fields.Char(string="Based on Products", default=[['sale_ok', '=', True]], help="On Purchase of selected product, reward will be given") + rule_min_quantity = fields.Integer(string="Minimum Quantity", default=1, + help="Minimum required product quantity to get the reward") + rule_minimum_amount = fields.Float(default=0.0, help="Minimum required amount to get the reward") + rule_minimum_amount_tax_inclusion = fields.Selection([ + ('tax_included', 'Tax Included'), + ('tax_excluded', 'Tax Excluded')], default="tax_excluded") + + @api.constrains('rule_date_to', 'rule_date_from') + def _check_rule_date_from(self): + if any(applicability for applicability in self + if applicability.rule_date_to and applicability.rule_date_from + and applicability.rule_date_to < applicability.rule_date_from): + raise ValidationError(_('The start date must be before the end date')) + + @api.constrains('rule_minimum_amount') + def _check_rule_minimum_amount(self): + if self.filtered(lambda applicability: applicability.rule_minimum_amount < 0): + raise ValidationError(_('Minimum purchased amount should be greater than 0')) + + @api.constrains('rule_min_quantity') + def _check_rule_min_quantity(self): + if not self.rule_min_quantity > 0: + raise ValidationError(_('Minimum quantity should be greater than 0')) diff --git a/addons/coupon/models/mail_compose_message.py b/addons/coupon/models/mail_compose_message.py new file mode 100644 index 00000000..a819777a --- /dev/null +++ b/addons/coupon/models/mail_compose_message.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class MailComposeMessage(models.TransientModel): + _inherit = 'mail.compose.message' + + def send_mail(self, **kwargs): + for wizard in self: + if self._context.get('mark_coupon_as_sent') and wizard.model == 'coupon.coupon' and wizard.partner_ids: + # Mark coupon as sent in sudo, as helpdesk users don't have the right to write on coupons + self.env[wizard.model].sudo().browse(wizard.res_id).state = 'sent' + return super().send_mail(**kwargs) |
