19.0 vanilla

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

View file

@ -10,37 +10,16 @@ pip install odoo-bringout-oca-ocb-loyalty
## Dependencies
This addon depends on:
- product
## Manifest Information
- **Name**: Coupons & Loyalty
- **Version**: 1.0
- **Category**: Sales
- **License**: LGPL-3
- **Installable**: True
- portal
- account
## Source
Based on [OCA/OCB](https://github.com/OCA/OCB) branch 16.0, addon `loyalty`.
- Repository: https://github.com/OCA/OCB
- Branch: 19.0
- Path: addons/loyalty
## License
This package maintains the original LGPL-3 license from the upstream Odoo project.
## Documentation
- Overview: doc/OVERVIEW.md
- Architecture: doc/ARCHITECTURE.md
- Models: doc/MODELS.md
- Controllers: doc/CONTROLLERS.md
- Wizards: doc/WIZARDS.md
- Reports: doc/REPORTS.md
- Security: doc/SECURITY.md
- Install: doc/INSTALL.md
- Usage: doc/USAGE.md
- Configuration: doc/CONFIGURATION.md
- Dependencies: doc/DEPENDENCIES.md
- Troubleshooting: doc/TROUBLESHOOTING.md
- FAQ: doc/FAQ.md
This package preserves the original LGPL-3 license.

View file

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import wizard

View file

@ -1,41 +1,51 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Coupons & Loyalty',
'name': "Coupons & Loyalty",
'summary': "Use discounts, gift card, eWallets and loyalty programs in different sales channels",
'category': 'Sales',
'version': '1.0',
'depends': ['product'],
'depends': ['product', 'portal', 'account'],
'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',
'data/loyalty_data.xml',
'wizard/loyalty_card_update_balance_views.xml',
'wizard/loyalty_generate_wizard_views.xml',
'views/loyalty_card_views.xml',
'views/loyalty_history_views.xml',
'views/loyalty_mail_views.xml',
'views/loyalty_program_views.xml',
'views/loyalty_reward_views.xml',
'views/loyalty_rule_views.xml',
'views/portal_templates.xml',
'views/res_partner_views.xml',
],
'demo': [
'data/loyalty_demo.xml',
],
'assets': {
'web.assets_backend': [
'loyalty/static/src/js/loyalty_card_list_view.js',
'loyalty/static/src/js/loyalty_control_panel_widget.js',
'loyalty/static/src/js/loyalty_list_view.js',
'loyalty/static/src/scss/loyalty.scss',
'loyalty/static/src/js/filterable_selection_field/filterable_selection_field.js',
'loyalty/static/src/xml/loyalty_templates.xml',
'loyalty/static/src/js/**/*.js',
'loyalty/static/src/scss/*.scss',
'loyalty/static/src/xml/*.xml',
('remove', 'loyalty/static/src/js/portal/**/*'),
# Don't include dark mode files in light mode
('remove', 'loyalty/static/src/scss/*.dark.scss'),
],
'web.qunit_suite_tests': [
'loyalty/static/tests/**/*.js',
"web.assets_web_dark": [
'loyalty/static/src/scss/*.dark.scss',
],
'web.assets_frontend': [
'loyalty/static/src/js/portal/**/*',
'loyalty/static/src/interactions/*',
],
},
'installable': True,
'author': 'Odoo S.A.',
'license': 'LGPL-3',
}

View file

@ -0,0 +1 @@
from . import portal

View file

@ -0,0 +1,132 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, fields
from odoo.http import request, route
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.addons.portal.controllers.portal import pager as portal_pager
class CustomerPortalLoyalty(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if not counters:
# we want those data to be added to the /my/home page only, and always computed
values['cards_per_programs'] = dict(request.env['loyalty.card'].sudo()._read_group(
domain=[
('partner_id', '=', request.env.user.partner_id.id),
('program_id.active', '=', True),
('program_id.program_type', 'in', ['loyalty', 'ewallet']),
'|',
('expiration_date', '>=', fields.Date().today()),
('expiration_date', '=', False),
],
groupby=['program_id'],
aggregates=['id:recordset'],
))
return values
def _get_loyalty_searchbar_sortings(self):
return {
'date': {'label': _("Date"), 'order': 'create_date desc'},
'used': {'label': _("Used"), 'order': 'used desc'},
'description': {'label': _("Description"), 'order': 'description desc'},
'issued': {'label': _("Issued"), 'order': 'issued desc'},
}
@route(
[
'/my/loyalty_card/<int:card_id>/history',
'/my/loyalty_card/<int:card_id>/history/page/<int:page>',
],
type='http',
auth='user',
website=True,
)
def portal_my_loyalty_card_history(self, card_id, page=1, sortby='date', **kw):
card_sudo = request.env['loyalty.card'].sudo().search([
('id', '=', int(card_id)),
('partner_id', '=', request.env.user.partner_id.id),
])
if not card_sudo:
return request.redirect('/my')
LoyaltyHistorySudo = request.env['loyalty.history'].sudo()
searchbar_sortings = self._get_loyalty_searchbar_sortings()
order = searchbar_sortings[sortby]['order']
lines_count = LoyaltyHistorySudo.search_count([('card_id', '=', card_id)])
pager = portal_pager(
url='/my/loyalty_card/<int:card_id>/history',
url_args={'sortby': sortby, 'card_id': card_id},
total=lines_count,
page=page,
step=self._items_per_page,
)
history_lines = LoyaltyHistorySudo.search(
domain=[
('card_id', '=', card_id),
('card_id.partner_id', '=', request.env.user.partner_id.id)
],
order=order,
limit=self._items_per_page,
offset=pager['offset'],
)
values = {
'pager': pager,
'searchbar_sortings': searchbar_sortings,
'page_name': 'loyalty_history',
'sortby': sortby,
'history_lines': history_lines,
}
return request.render('loyalty.loyalty_card_history_template', values)
@route('/my/loyalty_card/<int:card_id>/values', type='jsonrpc', auth='user')
def portal_get_card_history_values(self, card_id):
"""Retrieve card history values for portal card dialog.
:param card_id(str): The ID of the loyalty card.
:return(dict): A dictionary with card history values.
"""
card_sudo = request.env['loyalty.card'].sudo().search([
('id', '=', int(card_id)),
('partner_id', '=', request.env.user.partner_id.id)
])
if not card_sudo:
return {}
program_type = card_sudo.program_id.program_type
rewards = request.env['loyalty.reward'].sudo().search(
[
('program_id', '=', card_sudo.program_id.id),
('required_points', '<=', card_sudo.points)
],
order='required_points desc',
limit=3,
)
return {
'card': {
'id': card_sudo.id,
'points_display': card_sudo.points_display,
'expiration_date': card_sudo.expiration_date,
'code': card_sudo.code,
},
'program': {
'program_name': card_sudo.program_id.name,
'program_type': program_type,
},
'history_lines': [{
'order_id': line.order_id,
'description': line.description,
'order_portal_url': line._get_order_portal_url(),
'points': f'{"-" if line.issued < line.used else "+"}'
f'{card_sudo._format_points(abs(line.issued - line.used))}',
} for line in card_sudo.history_ids[:5]],
'rewards': [{
'description': reward.description,
'points': card_sudo._format_points(reward.required_points),
} for reward in rewards],
'img_path': f'/loyalty/static/src/img/{program_type}.svg',
}

View file

@ -1,25 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Basic product for gift card program -->
<record id="gift_card_product_50" model="product.product">
<field name="name">Gift Card</field>
<field name="list_price">50</field>
<field name="detailed_type">service</field>
<field name="purchase_ok" eval="False"/>
<field name="image_1920" type="base64" file="loyalty/static/img/gift_card.png"/>
</record>
<!-- Basic product for eWallet programs -->
<record id="ewallet_product_50" model="product.product">
<field name="name">Top-up eWallet</field>
<field name="list_price">50</field>
<field name="detailed_type">service</field>
<field name="purchase_ok" eval="False"/>
</record>
<data noupdate="1">
<!-- Basic product for gift card program -->
<record id="gift_card_product_50" model="product.product">
<field name="name">Gift Card</field>
<field name="list_price">50</field>
<field name="type">service</field>
<field name="purchase_ok" eval="False"/>
<field name="categ_id" eval="ref('product.product_category_services', raise_if_not_found=False)"/>
<field name="image_1920" type="base64" file="loyalty/static/img/gift_card.png"/>
</record>
<!-- Basic product for eWallet programs -->
<record id="ewallet_product_50" model="product.product">
<field name="name">Top-up eWallet</field>
<field name="list_price">50</field>
<field name="type">service</field>
<field name="purchase_ok" eval="False"/>
<field name="categ_id" eval="ref('product.product_category_services', raise_if_not_found=False)"/>
</record>
<record forcecreate="0" id="config_online_sync_proxy_mode" model="ir.config_parameter">
<field name="key">loyalty.compute_all_discount_product_ids</field>
<field name="value">False</field>
</record>
</data>
<!-- Gift Cards -->
<record id="gift_card_program" model="loyalty.program">
<field name="name">Gift Cards</field>
<field name="program_type">gift_card</field>
<field name="applies_on">future</field>
<field name="trigger">auto</field>
<field name="portal_visible">True</field>
<field name="portal_point_name">$</field>
<field name="mail_template_id" ref="loyalty.mail_template_gift_card"/>
</record>
<record id="gift_card_program_reward" model="loyalty.reward">
<field name="reward_type">discount</field>
<field name="discount_mode">per_point</field>
<field name="discount">1</field>
<field name="discount_applicability">order</field>
<field name="required_points">1</field>
<field name="program_id" ref="loyalty.gift_card_program"/>
</record>
<record id="gift_card_program_rule" model="loyalty.rule">
<field name="reward_point_amount">1</field>
<field name="reward_point_mode">money</field>
<field name="reward_point_split">True</field>
<field name="product_ids" eval="[(4, ref('loyalty.gift_card_product_50'))]"/>
<field name="program_id" ref="loyalty.gift_card_program"/>
</record>
</odoo>

View file

@ -72,31 +72,4 @@
<field name="mail_template_id" ref="loyalty.mail_template_loyalty_card"/>
<field name="program_id" ref="loyalty.10_percent_coupon"/>
</record>
<!-- Gift Cards -->
<record id="gift_card_program" model="loyalty.program">
<field name="name">Gift Cards</field>
<field name="program_type">gift_card</field>
<field name="applies_on">future</field>
<field name="trigger">auto</field>
<field name="portal_visible">True</field>
<field name="portal_point_name">$</field>
<field name="mail_template_id" ref="loyalty.mail_template_gift_card"/>
</record>
<record id="gift_card_program_rule" model="loyalty.rule">
<field name="reward_point_amount">1</field>
<field name="reward_point_mode">money</field>
<field name="reward_point_split">True</field>
<field name="product_ids" eval="[(4, ref('loyalty.gift_card_product_50'))]"/>
<field name="program_id" ref="loyalty.gift_card_program"/>
</record>
<record id="gift_card_program_reward" model="loyalty.reward">
<field name="reward_type">discount</field>
<field name="discount_mode">per_point</field>
<field name="discount">1</field>
<field name="discount_applicability">order</field>
<field name="required_points">1</field>
<field name="program_id" ref="loyalty.gift_card_program"/>
</record>
</odoo>

View file

@ -4,8 +4,8 @@
<field name="name">Gift Card: Gift Card Information</field>
<field name="model_id" ref="model_loyalty_card"/>
<field name="subject">Your Gift Card at {{ object.company_id.name }}</field>
<field name="partner_to">{{ object._get_mail_partner().id }}</field>
<field name="lang">{{ object._get_mail_partner().lang }}</field>
<field name="partner_to" eval="False"/>
<field name="use_default_to" eval="True"/>
<field name="description">Sent to customer who purchased a gift card</field>
<field name="body_html" type="html">
<div style="background: #ffffff">
@ -30,13 +30,12 @@
</div>
<div style="padding:20px; margin:0px; text-align:center;">
<span style="background-color:#999999; display:inline-block; width:auto; border-radius:5px;">
<a t-attf-href="{{ object.get_base_url() }}/shop" target="_blank" style="text-decoration:none; font-family:arial, 'helvetica neue', helvetica, sans-serif; font-size:22px; color:#FFFFFF; border-style:solid; border-color:#999999; border-width:20px 30px; display:inline-block; background-color:#999999; border-radius:5px; font-weight:bold; font-style:normal; line-height:26px; width:auto; text-align:center">Use it right now!</a>
<a t-attf-href="{{ object.get_base_url() }}/shop" target="_blank" t-attf-style="text-decoration:none; font-family:arial, 'helvetica neue', helvetica, sans-serif; font-size:22px; color: {{object.company_id.email_primary_color or '#FFFFFF'}}; border-style:solid; border-width:20px 30px; display:inline-block; background-color: {{object.company_id.email_secondary_color or '#875A7B'}}; border-radius:5px; font-weight:bold; font-style:normal; line-height:26px; width:auto; text-align:center">Use it right now!</a>
</span>
</div>
</div>
</field>
<field name="report_template" ref="loyalty.report_gift_card"/>
<field name="report_name">Your Gift Card</field>
<field name="report_template_ids" eval="[(4, ref('loyalty.report_gift_card'))]"/>
<field name="auto_delete" eval="True"/>
</record>
@ -45,22 +44,22 @@
<field name="model_id" ref="loyalty.model_loyalty_card"/>
<field name="subject">Your reward coupon from {{ object.program_id.company_id.name }} </field>
<field name="email_from">{{ object.program_id.company_id.email }}</field>
<field name="partner_to">{{ object._get_mail_partner().id }}</field>
<field name="lang">{{ object._get_mail_partner().lang }}</field>
<field name="partner_to" eval="False"/>
<field name="use_default_to" eval="True"/>
<field name="description">Sent to customer with coupon information</field>
<field name="body_html" type="html">
<table border="0" cellpadding="0" cellspacing="0" style="width:100%; margin:0px auto; background:#ffffff; color:#333333;"><tbody>
<tr>
<td valign="top" style="text-align: center; font-size: 14px;">
<t t-if="object._get_mail_partner().name">
Congratulations <t t-out="object._get_mail_partner().name or ''">Brandon Freeman</t>,<br />
<t t-if="object._mail_get_customer().name">
Congratulations <t t-out="object._mail_get_customer().name or ''">Brandon Freeman</t>,<br />
</t>
Here is your reward from <t t-out="object.program_id.company_id.name or ''">YourCompany</t>.<br />
<t t-foreach="object.program_id.reward_ids" t-as="reward">
<t t-if="reward.required_points &lt;= object.points">
<span style="font-size: 50px; color: #875A7B; font-weight: bold;" t-esc="reward.description">Reward Description</span>
<span t-attf-style="font-size: 50px; color: {{object.program_id.company_id.email_secondary_color or '#875A7B'}}; font-weight: bold;" t-esc="reward.description">Reward Description</span>
<br/>
</t>
</t>
@ -70,7 +69,7 @@
<td valign="top" style="text-align: center; font-size: 14px;">
Use this promo code
<t t-if="object.expiration_date">
before <t t-out="object.expiration_date or ''">2021-06-16</t>
before <t t-out="format_date(object.expiration_date) or ''">2021-06-16</t>
</t>
<p style="margin-top: 16px;">
<strong style="padding: 16px 8px 16px 8px; border-radius: 3px; background-color: #F1F1F1;" t-out="object.code or ''">15637502648479132902</strong>
@ -91,14 +90,13 @@
Thank you,
<t t-if="object._get_signature()">
<br />
<t t-out="object._get_signature() or ''">--<br/>Mitchell Admin</t>
<div>--<br/><t t-out="object._get_signature() or ''">Mitchell Admin</t></div>
</t>
</td>
</tr>
</tbody></table>
</field>
<field name="report_template" ref="loyalty.report_loyalty_card"/>
<field name="report_name">Your Coupon Code</field>
<field name="report_template_ids" eval="[(4, ref('loyalty.report_loyalty_card'))]"/>
<field name="auto_delete" eval="True"/>
</record>
</odoo>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import loyalty_card
from . import loyalty_history
from . import loyalty_mail
from . import loyalty_program
from . import loyalty_reward
from . import loyalty_rule
from . import loyalty_program
from . import product_pricelist
from . import product_product
from . import product_template
from . import res_partner

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from uuid import uuid4
@ -11,7 +10,7 @@ from odoo.tools import format_amount
class LoyaltyCard(models.Model):
_name = 'loyalty.card'
_inherit = ['mail.thread']
_description = 'Loyalty Coupon'
_description = "Loyalty Coupon"
_rec_name = 'code'
@api.model
@ -19,35 +18,50 @@ class LoyaltyCard(models.Model):
"""
Barcode identifiable codes.
"""
return '044' + str(uuid4())[7:-18]
return "044" + str(uuid4())[7:-18]
def name_get(self):
return [(card.id, f'{card.program_id.name}: {card.code}') for card in self]
@api.depends('program_id', 'code')
def _compute_display_name(self):
for card in self:
card.display_name = f"{card.program_id.name}: {card.code}"
program_id = fields.Many2one('loyalty.program', ondelete='restrict', default=lambda self: self.env.context.get('active_id', None))
program_id = fields.Many2one(
comodel_name='loyalty.program',
ondelete='restrict',
index='btree_not_null',
default=lambda self: self.env.context.get('active_id', None),
)
program_type = fields.Selection(related='program_id.program_type')
company_id = fields.Many2one(related='program_id.company_id', store=True)
# TODO probably isn't useful to store this company_id anymore
company_id = fields.Many2one(related='program_id.company_id', store=True, precompute=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)
partner_id = fields.Many2one(comodel_name='res.partner', index=True)
points = fields.Float(tracking=True)
point_name = fields.Char(related='program_id.portal_point_name', readonly=True)
points_display = fields.Char(compute='_compute_points_display')
code = fields.Char(default=lambda self: self._generate_code(), required=True)
code = fields.Char(required=True, default=lambda self: self._generate_code())
expiration_date = fields.Date()
use_count = fields.Integer(compute='_compute_use_count')
active = fields.Boolean(default=True)
history_ids = fields.One2many(
comodel_name='loyalty.history',
inverse_name='card_id',
readonly=True,
)
_sql_constraints = [
('card_code_unique', 'UNIQUE(code)', 'A coupon/loyalty card must have a unique code.')
]
_card_code_unique = models.Constraint(
'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.'))
raise ValidationError(_("A trigger with the same code as one of your coupon already exists."))
@api.depends('points', 'point_name')
def _compute_points_display(self):
@ -62,7 +76,7 @@ class LoyaltyCard(models.Model):
def _format_points(self, points):
self.ensure_one()
if self.point_name == self.program_id.currency_id.symbol:
if self.program_id.currency_id and self.point_name == self.program_id.currency_id.symbol:
return format_amount(self.env, points, self.program_id.currency_id)
if points == int(points):
return f"{int(points)} {self.point_name or ''}"
@ -76,10 +90,6 @@ class LoyaltyCard(models.Model):
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_mail_author(self):
self.ensure_one()
return (
@ -103,16 +113,14 @@ class LoyaltyCard(models.Model):
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_res_ids=self.ids,
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'),
'name': _("Compose Email"),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'mail.compose.message',
@ -133,7 +141,7 @@ class LoyaltyCard(models.Model):
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():
if not create_comm_per_program[coupon.program_id] or not coupon._mail_get_customer():
continue
for comm in create_comm_per_program[coupon.program_id]:
mail_template = comm.mail_template_id
@ -163,7 +171,7 @@ class LoyaltyCard(models.Model):
.filtered(lambda c: c.trigger == 'points_reach')\
.sorted('points', reverse=True)
for coupon in self:
if not coupon._get_mail_partner():
if not coupon._mail_get_customer():
continue
coupon_change = points_changes[coupon]
# Do nothing if coupon lost points or did not change
@ -195,3 +203,15 @@ class LoyaltyCard(models.Model):
points_changes = {coupon: {'old': points_before[coupon], 'new': coupon.points} for coupon in self}
self._send_points_reach_communication(points_changes)
return res
def action_loyalty_update_balance(self):
return {
'name': _("Update Balance"),
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'loyalty.card.update.balance',
'target': 'new',
'context': {
'default_card_id': self.id,
},
}

View file

@ -0,0 +1,28 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class LoyaltyHistory(models.Model):
_name = 'loyalty.history'
_description = "History for Loyalty cards and Ewallets"
_order = 'id desc'
card_id = fields.Many2one(comodel_name='loyalty.card', required=True, index=True, ondelete='cascade')
company_id = fields.Many2one(related='card_id.company_id')
description = fields.Text(required=True)
issued = fields.Float()
used = fields.Float()
order_model = fields.Char(readonly=True)
order_id = fields.Many2oneReference(model_field='order_model', readonly=True)
def _get_order_portal_url(self):
self.ensure_one()
return False
def _get_order_description(self):
self.ensure_one()
return self.env[self.order_model].browse(self.order_id).display_name

View file

@ -1,20 +1,31 @@
# -*- 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'
_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
program_id = fields.Many2one(comodel_name='loyalty.program', ondelete='cascade', required=True, index=True)
trigger = fields.Selection(
string="When",
selection=[
('create', "At Creation"),
('points_reach', "When Reaching")
],
required=True,
)
points = fields.Float()
mail_template_id = fields.Many2one('mail.template', string="Email Template", required=True, domain=[('model', '=', 'loyalty.card')])
mail_template_id = fields.Many2one(
string="Email Template",
comodel_name='mail.template',
ondelete='cascade',
domain=[('model', '=', 'loyalty.card')],
required=True,
)

View file

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

View file

@ -1,23 +1,23 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import ast
import json
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.exceptions import ValidationError
from odoo.fields import Domain
class LoyaltyReward(models.Model):
_name = 'loyalty.reward'
_description = 'Loyalty Reward'
_description = "Loyalty Reward"
_rec_name = 'description'
_order = 'required_points asc'
@api.model
def default_get(self, fields_list):
def default_get(self, fields):
# Try to copy the values of the program types default's
result = super().default_get(fields_list)
result = super().default_get(fields)
if 'program_type' in self.env.context:
program_type = self.env.context['program_type']
program_default_values = self.env['loyalty.program']._program_type_default_values()
@ -25,7 +25,7 @@ class LoyaltyReward(models.Model):
len(program_default_values[program_type]['reward_ids']) == 2 and\
isinstance(program_default_values[program_type]['reward_ids'][1][2], dict):
result.update({
k: v for k, v in program_default_values[program_type]['reward_ids'][1][2].items() if k in fields_list
k: v for k, v in program_default_values[program_type]['reward_ids'][1][2].items() if k in fields
})
return result
@ -34,73 +34,116 @@ class LoyaltyReward(models.Model):
# 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))
('percent', "%"),
('per_order', symbol),
('per_point', _("%s per point", symbol)),
]
def name_get(self):
return [(reward.id, '%s - %s' % (reward.program_id.name, reward.description)) for reward in self]
@api.depends('program_id', 'description')
def _compute_display_name(self):
for reward in self:
reward.display_name = f"{reward.program_id.name} - {reward.description}"
active = fields.Boolean(default=True)
program_id = fields.Many2one('loyalty.program', required=True, ondelete='cascade')
program_type = fields.Selection(related="program_id.program_type")
program_id = fields.Many2one(comodel_name='loyalty.program', ondelete='cascade', required=True, index=True)
program_type = fields.Selection(related='program_id.program_type')
# 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)
description = fields.Char(
translate=True,
compute='_compute_description',
precompute=True,
store=True,
readonly=False,
required=True,
)
reward_type = fields.Selection([
('product', 'Free Product'),
('discount', 'Discount')],
default='discount', required=True,
reward_type = fields.Selection(
selection=[
('product', "Free Product"),
('discount', "Discount"),
],
required=True,
default='discount',
)
user_has_debug = fields.Boolean(compute='_compute_user_has_debug')
# 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 = fields.Float(string="Discount", default=10)
discount_mode = fields.Selection(
selection=_get_discount_mode_select, required=True, default='percent'
)
discount_applicability = fields.Selection(
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_product_ids = fields.Many2many(
string="Discounted Products", comodel_name='product.product'
)
discount_product_category_id = fields.Many2one(
string="Discounted Prod. Categories", comodel_name='product.category'
)
discount_product_tag_id = fields.Many2one(
string="Discounted Prod. Tag", comodel_name='product.tag'
)
all_discount_product_ids = fields.Many2many(
comodel_name='product.product', compute='_compute_all_discount_product_ids'
)
reward_product_domain = fields.Char(compute='_compute_reward_product_domain', store=False)
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")
discount_max_amount = fields.Monetary(
string="Max Discount",
help="This is the max amount this reward may discount, leave to 0 for no limit.",
)
discount_line_product_id = fields.Many2one(
help="Product used in the sales order to apply the discount. Each reward has its own"
" product for reporting purpose",
comodel_name='product.product',
ondelete='restrict',
copy=False,
)
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')
reward_product_id = fields.Many2one(
string="Product", comodel_name='product.product', domain=[('type', '!=', 'combo')]
)
reward_product_tag_id = fields.Many2one(string="Product Tag", comodel_name='product.tag')
multi_product = fields.Boolean(compute='_compute_multi_product')
reward_product_ids = fields.Many2many(
'product.product', string="Reward Products", compute='_compute_multi_product',
string="Reward Products",
help="These are the products that can be claimed with this rule.",
comodel_name='product.product',
compute='_compute_multi_product',
search='_search_reward_product_ids',
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')
reward_product_uom_id = fields.Many2one(
comodel_name='uom.uom', compute='_compute_reward_product_uom_id'
)
required_points = fields.Float('Points needed', default=1)
required_points = fields.Float(string="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.'),
]
_required_points_positive = models.Constraint(
'CHECK (required_points > 0)',
"The required points for a reward must be strictly positive.",
)
_product_qty_positive = models.Constraint(
"CHECK (reward_type != 'product' OR reward_product_qty > 0)",
"The reward product quantity must be strictly positive.",
)
_discount_positive = models.Constraint(
"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):
@ -116,17 +159,18 @@ class LoyaltyReward(models.Model):
def _get_discount_product_domain(self):
self.ensure_one()
domain = []
constrains = []
if self.discount_product_ids:
domain = [('id', 'in', self.discount_product_ids.ids)]
constrains.append([('id', 'in', self.discount_product_ids.ids)])
if self.discount_product_category_id:
product_category_ids = self._find_all_category_children(self.discount_product_category_id, [])
product_category_ids.append(self.discount_product_category_id.id)
domain = expression.OR([domain, [('categ_id', 'in', product_category_ids)]])
constrains.append([('categ_id', 'in', product_category_ids)])
if self.discount_product_tag_id:
domain = expression.OR([domain, [('all_product_tag_ids', 'in', self.discount_product_tag_id.id)]])
constrains.append([('all_product_tag_ids', 'in', self.discount_product_tag_id.id)])
domain = Domain.OR(constrains) if constrains else Domain.TRUE
if self.discount_product_domain and self.discount_product_domain != '[]':
domain = expression.AND([domain, ast.literal_eval(self.discount_product_domain)])
domain &= Domain(ast.literal_eval(self.discount_product_domain))
return domain
@api.model
@ -152,7 +196,7 @@ class LoyaltyReward(models.Model):
if compute_all_discount_product == 'enabled':
reward.reward_product_domain = "null"
else:
reward.reward_product_domain = json.dumps(reward._get_discount_product_domain())
reward.reward_product_domain = json.dumps(list(reward._get_discount_product_domain()))
@api.depends('discount_product_ids', 'discount_product_category_id', 'discount_product_tag_id', 'discount_product_domain')
def _compute_all_discount_product_ids(self):
@ -166,13 +210,15 @@ class LoyaltyReward(models.Model):
@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
products = reward.reward_product_id + reward.reward_product_tag_id.product_ids.filtered(
lambda product: product.type != 'combo'
)
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']
def _search_reward_product_ids(self, operator, value):
if operator not in ('=', '!=', 'in'):
raise NotImplementedError(_("Unsupported search operator"))
if operator != 'in':
return NotImplemented
return [
'&', ('reward_type', '=', 'product'),
'|', ('reward_product_id', operator, value),
@ -191,57 +237,58 @@ class LoyaltyReward(models.Model):
elif reward.reward_type == 'product':
products = reward.reward_product_ids
if len(products) == 0:
reward_string = _('Free Product')
reward_string = _("Free Product")
elif len(products) == 1:
reward_string = _('Free Product - %s', reward.reward_product_id.with_context(display_default_code=False).display_name)
reward_string = _("Free Product - %s", reward.reward_product_id.with_context(display_default_code=False).display_name)
else:
reward_string = _('Free Product - [%s]', ', '.join(products._origin.with_context(display_default_code=False).mapped('display_name')))
reward_string = _("Free Product - [%s]", ', '.join(products.with_context(display_default_code=False).mapped('display_name')))
elif reward.reward_type == 'discount':
format_string = '%(amount)g %(symbol)s'
format_string = "%(amount)g %(symbol)s"
if reward.currency_id.position == 'before':
format_string = '%(symbol)s %(amount)g'
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)
reward_string = _("%g%% on ", reward.discount)
elif reward.discount_mode == 'per_point':
reward_string = _('%s per point on ', formatted_amount)
reward_string = _("%s per point on ", formatted_amount)
elif reward.discount_mode == 'per_order':
reward_string = _('%s per order on ', formatted_amount)
reward_string = _("%s on ", formatted_amount)
if reward.discount_applicability == 'order':
reward_string += _('your order')
reward_string += _("your order")
elif reward.discount_applicability == 'cheapest':
reward_string += _('the cheapest product')
reward_string += _("the cheapest product")
elif reward.discount_applicability == 'specific':
product_available = self.env['product.product'].search(reward._get_discount_product_domain(), limit=2)
if len(product_available) == 1:
reward_string += product_available.with_context(display_default_code=False).display_name
else:
reward_string += _('specific products')
reward_string += _("specific products")
if reward.discount_max_amount:
format_string = '%(amount)g %(symbol)s'
format_string = "%(amount)g %(symbol)s"
if reward.currency_id.position == 'before':
format_string = '%(symbol)s %(amount)g'
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_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'
reward.is_global_discount = (
reward.reward_type == 'discount'
and reward.discount_applicability == 'order'
and reward.discount_mode in ['per_order', 'percent']
)
@api.depends_context('uid')
@api.depends("reward_type")
@api.depends('reward_type')
def _compute_user_has_debug(self):
self.user_has_debug = self.user_has_groups('base.group_no_one')
self.user_has_debug = self.env.user.has_group('base.group_no_one')
@api.onchange('description')
def _ensure_reward_has_description(self):
for reward in self:
if not reward.description:
raise UserError(_("The reward description field cannot be empty."))
@api.constrains('reward_product_id')
def _check_reward_product_id_no_combo(self):
if any(reward.reward_product_id.type == 'combo' for reward in self):
raise ValidationError(_("A reward product can't be of type \"combo\"."))
def _create_missing_discount_line_products(self):
# Make sure we create the product that will be used for our discounts

View file

@ -1,20 +1,20 @@
# -*- 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
from odoo.fields import Domain
class LoyaltyRule(models.Model):
_name = 'loyalty.rule'
_description = 'Loyalty Rule'
_description = "Loyalty Rule"
@api.model
def default_get(self, fields_list):
def default_get(self, fields):
# Try to copy the values of the program types default's
result = super().default_get(fields_list)
result = super().default_get(fields)
if 'program_type' in self.env.context:
program_type = self.env.context['program_type']
program_default_values = self.env['loyalty.program']._program_type_default_values()
@ -22,7 +22,7 @@ class LoyaltyRule(models.Model):
len(program_default_values[program_type]['rule_ids']) == 2 and\
isinstance(program_default_values[program_type]['rule_ids'][1][2], dict):
result.update({
k: v for k, v in program_default_values[program_type]['rule_ids'][1][2].items() if k in fields_list
k: v for k, v in program_default_values[program_type]['rule_ids'][1][2].items() if k in fields
})
return result
@ -31,14 +31,14 @@ class LoyaltyRule(models.Model):
# 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')),
('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')
program_type = fields.Selection(related="program_id.program_type")
program_id = fields.Many2one(comodel_name='loyalty.program', ondelete='cascade', required=True, index=True)
program_type = fields.Selection(related='program_id.program_type')
# Stored for security rules
company_id = fields.Many2one(related='program_id.company_id', store=True)
currency_id = fields.Many2one(related='program_id.currency_id')
@ -47,52 +47,74 @@ class LoyaltyRule(models.Model):
user_has_debug = fields.Boolean(compute='_compute_user_has_debug')
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')
product_ids = fields.Many2many(string="Products", comodel_name='product.product')
product_category_id = fields.Many2one(string="Categories", comodel_name='product.category')
product_tag_id = fields.Many2one(string="Product Tag", comodel_name='product.tag')
reward_point_amount = fields.Float(default=1, string="Reward")
reward_point_amount = fields.Float(string="Reward", default=1)
# 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_split = fields.Boolean(
string="Split per unit",
help="Whether to separate reward coupons per matched unit, only applies to 'future' programs and trigger mode per money spent or unit paid...",
default=False,
)
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,
reward_point_mode = fields.Selection(
selection=_get_reward_point_mode_selection, required=True, default='order'
)
mode = fields.Selection([
('auto', 'Automatic'),
('with_code', 'With a promotion code'),
], string="Application", compute='_compute_mode', store=True, readonly=False)
code = fields.Char(string='Discount code', compute='_compute_code', store=True, readonly=False)
minimum_qty = fields.Integer(string="Minimum Quantity", default=1)
minimum_amount = fields.Monetary(string="Minimum Purchase")
minimum_amount_tax_mode = fields.Selection(
selection=[
('incl', "tax included"),
('excl', "tax excluded"),
],
required=True,
default='incl',
)
_sql_constraints = [
('reward_point_amount_positive', 'CHECK (reward_point_amount > 0)', 'Rule points reward must be strictly positive.'),
]
mode = fields.Selection(
string="Application",
selection=[
('auto', "Automatic"),
('with_code', "With a promotion code"),
],
compute='_compute_mode',
store=True,
readonly=False,
)
code = fields.Char(string="Discount code", compute='_compute_code', store=True, readonly=False)
_reward_point_amount_positive = models.Constraint(
'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.'))
raise ValidationError(_("Split per unit is not allowed for Loyalty and eWallet programs."))
@api.constrains('code')
@api.constrains('code', 'active')
def _constrains_code(self):
mapped_codes = self.filtered('code').mapped('code')
mapped_codes = self.filtered(lambda r: r.code and r.active).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.'))
self.env['loyalty.rule'].search_count([
('mode', '=', 'with_code'),
('code', 'in', mapped_codes),
('id', 'not in', self.ids),
('active', '=', True),
]):
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.'))
if self.env['loyalty.card'].search_count([
('code', 'in', mapped_codes), ('active', '=', True)
]):
raise ValidationError(_("A coupon with the same code was found."))
@api.depends('mode')
def _compute_code(self):
@ -110,21 +132,22 @@ class LoyaltyRule(models.Model):
rule.mode = 'auto'
@api.depends_context('uid')
@api.depends("mode")
@api.depends('mode')
def _compute_user_has_debug(self):
self.user_has_debug = self.user_has_groups('base.group_no_one')
self.user_has_debug = self.env.user.has_group('base.group_no_one')
def _get_valid_product_domain(self):
self.ensure_one()
domain = []
constrains = []
if self.product_ids:
domain = [('id', 'in', self.product_ids.ids)]
constrains.append([('id', 'in', self.product_ids.ids)])
if self.product_category_id:
domain = expression.OR([domain, [('categ_id', 'child_of', self.product_category_id.id)]])
constrains.append([('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)]])
constrains.append([('all_product_tag_ids', 'in', self.product_tag_id.id)])
domain = Domain.OR(constrains) if constrains else Domain.TRUE
if self.product_domain and self.product_domain != '[]':
domain = expression.AND([domain, ast.literal_eval(self.product_domain)])
domain &= Domain(ast.literal_eval(self.product_domain))
return domain
def _get_valid_products(self):

View file

@ -0,0 +1,19 @@
from odoo import _, models
from odoo.exceptions import UserError
class ProductPricelist(models.Model):
_inherit = 'product.pricelist'
def action_archive(self):
loyalty_programs = self.env['loyalty.program'].sudo().search([
('active', '=', True),
('pricelist_ids', 'in', self.ids)
])
if loyalty_programs:
raise UserError(_(
"This pricelist may not be archived. "
"It is being used for active promotion programs: %s",
', '.join(loyalty_programs.mapped('name'))
))
return super().action_archive()

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, models

View file

@ -1,13 +1,25 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo import _, api, models
from odoo.exceptions import UserError
from odoo.tools import file_open
class ProductTemplate(models.Model):
_inherit = 'product.template'
@api.model_create_multi
def create(self, vals_list):
""" Override of `product` to set a default image for gift cards. """
templates = super().create(vals_list)
if templates and self.env.context.get('loyalty_is_gift_card_product'):
with file_open('loyalty/static/img/gift_card.png', 'rb') as f:
gift_card_placeholder = base64.b64encode(f.read())
templates.image_1920 = gift_card_placeholder
return templates
@api.ondelete(at_uninstall=False)
def _unlink_except_loyalty_products(self):
product_data = [

View file

@ -0,0 +1,41 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResPartner(models.Model):
_inherit = 'res.partner'
loyalty_card_count = fields.Integer(
string="Active loyalty cards",
compute='_compute_count_active_cards',
compute_sudo=True,
groups='base.group_user')
def _compute_count_active_cards(self):
loyalty_groups = self.env['loyalty.card']._read_group(
domain=[
'|', ('company_id', '=', False), ('company_id', 'in', self.env.companies.ids),
('partner_id', 'in', self.with_context(active_test=False)._search([('id', 'child_of', self.ids)])),
('points', '>', '0'),
('program_id.active', '=', True),
'|',
('expiration_date', '>=', fields.Date().context_today(self)),
('expiration_date', '=', False),
],
groupby=['partner_id'],
aggregates=['__count'],
)
self.loyalty_card_count = 0
for partner, count in loyalty_groups:
while partner:
if partner in self:
partner.loyalty_card_count += count
partner = partner.parent_id
def action_view_loyalty_cards(self):
action = self.env['ir.actions.act_window']._for_xml_id('loyalty.loyalty_card_action')
all_child = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
action['domain'] = [('partner_id', 'in', all_child.ids)]
action['context'] = {'search_default_active' : True, 'create': False}
return action

View file

@ -5,45 +5,50 @@
<div class="card">
<div class="card-body">
<div class="page">
<div class="oe_structure"></div>
<div class="row text-center">
<div class="o_offer col-lg-12">
<h4 t-if="o._get_mail_partner().name">
<h4 t-if="o._mail_get_customer().name">
Congratulations
<t t-esc="o._get_mail_partner().name"/>,
<span t-out="o._mail_get_customer().name">John Doe</span>,
</h4>
<t t-set="text">on your next order</t>
<h4>Here is your reward from <t t-esc="o.program_id.company_id.name"/>.</h4>
<h4>Here is your reward from <span t-out="o.program_id.company_id.name">Odoo</span>.</h4>
<div class="oe_structure"></div>
<t t-foreach="range(len(o.program_id.reward_ids))" t-as="reward_idx">
<t t-set="reward" t-value="o.program_id.reward_ids[reward_idx]"/>
<strong><t t-esc="reward.description"/></strong>
<strong><span t-out="reward.description">loyalty Reward</span></strong>
<br/>
<t t-if="reward_idx &lt; (len(o.program_id.reward_ids) - 1)">
<span class="text-center">OR</span>
<br/>
</t>
</t>
<h1 class="fw-bold" style="font-size: 34px" t-esc="text"/>
<h1 class="fw-bold" style="font-size: 34px"><span t-out="text">DEMO_TEXT</span></h1>
<br/>
<h4 t-if="o.expiration_date">
Use this promo code before
<span t-field="o.expiration_date" t-options='{"format": "yyyy-MM-d"}'/>
<span t-field="o.expiration_date">2023-08-20</span>
</h4>
<h2 class="mt-4">
<strong class="bg-light" t-esc="o.code"></strong>
<strong class="bg-light"><span t-out="o.code">DEMO_CODE</span></strong>
</h2>
<t t-set="rule" t-value="o.program_id.rule_ids[:1]"/>
<h4 t-if="rule.minimum_qty > 1">
<span>Minimum purchase of</span>
<strong t-esc="rule.minimum_qty"/> <span>products</span>
<strong t-out="rule.minimum_qty">5</strong> <span>products</span>
</h4>
<h4 t-if="rule.minimum_amount">
<span>Valid for purchase above</span>
<strong t-esc="rule.minimum_amount" t-options="{'widget': 'monetary', 'display_currency': rule.currency_id}"/>
<strong t-out="rule.minimum_amount" t-options="{'widget': 'monetary', 'display_currency': rule.currency_id}">$100</strong>
</h4>
<div class="oe_structure"></div>
<br/>
<div t-field="o.code" t-options="{'widget': 'barcode', 'width': 600, 'height': 100}"/>
<span t-field="o.code" t-options="{'widget': 'barcode', 'width': 600, 'height': 100}">ABCDE12345</span>
<div class="oe_structure"></div>
<br/><br/>
<h4>Thank you,</h4>
<div class="oe_structure"></div>
<br/>
<div class="mt32">
<div class="text-center">
@ -53,9 +58,10 @@
<div>
<div class="text-center d-inline-block">
<span t-field="o.program_id.company_id.partner_id"
t-options='{"widget": "contact", "fields": ["address", "email"], "no_marker": True}'/>
t-options='{"widget": "contact", "fields": ["address", "email"], "no_marker": True}'>John Doe</span>
</div>
</div>
<div class="oe_structure"></div>
</div>
</div>
</div>
@ -67,8 +73,8 @@
<template id="loyalty_report_i18n">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="o">
<t t-set="o" t-value="o.with_context(lang=o._get_mail_partner().lang or o.env.lang)"/>
<t t-call="loyalty.loyalty_report" t-lang="o._get_mail_partner().lang or o.env.lang"/>
<t t-set="o" t-value="o.with_context(lang=o._mail_get_customer().lang or o.env.lang)"/>
<t t-call="loyalty.loyalty_report" t-lang="o._mail_get_customer().lang or o.env.lang"/>
</t>
</t>
</template>
@ -76,43 +82,52 @@
<template id="gift_card_report">
<t t-call="web.html_container">
<t t-call="web.external_layout">
<div class="oe_structure"></div>
<div style="margin:0px; font-size:24px; font-family:arial, 'helvetica neue', helvetica, sans-serif; line-height:36px; color:#333333; text-align: center">
Here is your gift card!
</div>
<div class="oe_structure"></div>
<div style="padding-top:20px; padding-bottom:20px">
<img src="/loyalty/static/img/gift_card.png" style="display:block; border:0; outline:none; text-decoration:none; margin:auto;" width="300"/>
</div>
<div class="oe_structure"></div>
<div style="padding:0; margin:0px; padding-top:35px; padding-bottom:35px; text-align:center;">
<h3 style="margin:0px; line-height:48px; font-family:arial, 'helvetica neue', helvetica, sans-serif; font-size:40px; font-style:normal; font-weight:normal; color:#333333; text-align:center">
<strong><span t-esc="o.points" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}"/></strong>
<strong><span t-out="o.points" t-options="{'widget': 'monetary', 'display_currency': o.currency_id}">1000</span></strong>
</h3>
</div>
<div class="oe_structure"></div>
<div style="padding:0; margin:0px; padding-top:35px; padding-bottom:35px; background-color:#efefef; text-align:center;">
<p style="margin:0px; font-size:14px;font-family:arial, 'helvetica neue', helvetica, sans-serif; line-height:21px; color:#333333">
<strong>Gift Card Code</strong>
</p>
<p style="margin:0px; font-size:25px;font-family:arial, 'helvetica neue', helvetica, sans-serif; line-height:38px; color:#A9A9A9">
<span t-field="o.code"/>
<span t-field="o.code">ABCDE12345</span>
</p>
</div>
<div class="oe_structure"></div>
<div t-if="o.expiration_date" style="padding:0; margin:0px; padding-top:10px; padding-bottom:10px; text-align:center;">
<h3 style="margin:0px; line-height:17px; font-family:arial, 'helvetica neue', helvetica, sans-serif; font-size:14px; font-style:normal; font-weight:normal; color:#A9A9A9; text-align:center">
Card expires <span t-field="o.expiration_date"/>
Card expires <span t-field="o.expiration_date">2023-12-31</span>
</h3>
</div>
<div class="oe_structure"></div>
<div style="padding:0; margin:0px; padding-top:10px; padding-bottom:10px; text-align:center;">
<img t-att-src="'/report/barcode/Code128/'+o.code" style="width:400px;height:75px" alt="Barcode"/>
</div>
<div class="oe_structure"></div>
</t>
</t>
</template>
<template id="gift_card_report_i18n">
<t t-call="web.html_container">
<div class="oe_structure"></div>
<t t-foreach="docs" t-as="o">
<t t-set="o" t-value="o.with_context(lang=o._get_mail_partner().lang or o.env.lang)"/>
<t t-call="loyalty.gift_card_report" t-lang="o._get_mail_partner().lang or o.env.lang"/>
<t t-set="o" t-value="o.with_context(lang=o._mail_get_customer().lang or o.env.lang)"/>
<t t-call="loyalty.gift_card_report" t-lang="o._mail_get_customer().lang or o.env.lang"/>
</t>
<div class="oe_structure"></div>
</t>
</template>
</odoo>

View file

@ -5,3 +5,5 @@ access_loyalty_program,access_loyalty_program,model_loyalty_program,base.group_u
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
access_loyalty_history,access_loyalty_history,model_loyalty_history,base.group_user,0,0,0,0
access_loyalty_card_update_balance_user,access_loyalty_card_update_balance,model_loyalty_card_update_balance,base.group_user,0,0,0,0

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
5 access_loyalty_reward access_loyalty_reward model_loyalty_reward base.group_user 0 0 0 0
6 access_loyalty_rule access_loyalty_rule model_loyalty_rule base.group_user 0 0 0 0
7 access_loyalty_generate_wizard access_loyalty_generate_wizard model_loyalty_generate_wizard base.group_user 0 0 0 0
8 access_loyalty_history access_loyalty_history model_loyalty_history base.group_user 0 0 0 0
9 access_loyalty_card_update_balance_user access_loyalty_card_update_balance model_loyalty_card_update_balance base.group_user 0 0 0 0

View file

@ -13,6 +13,12 @@
<field name="domain_force">['|', ('company_id', 'in', company_ids + [False]), ('company_id', 'parent_of', company_ids)]</field>
</record>
<record id="loyalty_history_company_rule" model="ir.rule">
<field name="name">Loyalty history multi company rule</field>
<field name="model_id" ref="model_loyalty_history"/>
<field name="domain_force">['|', ('company_id', 'in', company_ids + [False]), ('company_id', 'parent_of', company_ids)]</field>
</record>
<record id="sale_loyalty_rule_company_rule" model="ir.rule">
<field name="name">Loyalty rule multi company rule</field>
<field name="model_id" ref="model_loyalty_rule"/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Before After
Before After

View file

@ -0,0 +1,14 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M46.1574 46.4595L42.8079 44.513L36.2025 37.3103L39.552 39.2568L46.1574 46.4595Z" fill="#FBDBD0"/>
<path d="M46.1575 46.6881C46.0945 46.6881 46.0329 46.662 45.989 46.614L39.3836 39.4113C39.3031 39.3234 39.3037 39.1883 39.385 39.1011C42.4569 35.804 45.0001 31.7863 46.7403 27.4821C46.7696 27.4093 46.8343 27.3569 46.9115 27.3428C46.9249 27.3404 46.9385 27.3392 46.9521 27.3392C47.0155 27.3392 47.0769 27.3656 47.1207 27.4133L54.3577 35.3033C54.4093 35.3596 54.4294 35.4379 54.4113 35.512C54.3932 35.5861 54.3392 35.6464 54.2673 35.6725L47.6041 38.0993L46.3836 46.4924C46.3709 46.5799 46.3091 46.6519 46.2249 46.6781C46.2026 46.6848 46.18 46.6881 46.1575 46.6881Z" fill="white"/>
<path d="M46.9521 27.5679L54.1892 35.4577L47.3975 37.9313L46.1574 46.4595L39.552 39.2568C42.6392 35.9434 45.2241 31.8423 46.9521 27.5679ZM46.9521 27.1107C46.9251 27.1107 46.8978 27.1131 46.8707 27.118C46.7163 27.146 46.5871 27.2511 46.5283 27.3965C44.8234 31.6137 42.227 35.7151 39.2175 38.9452C39.0549 39.1197 39.0538 39.39 39.2151 39.5658L45.8205 46.7685C45.9084 46.8644 46.0313 46.9166 46.1574 46.9166C46.2023 46.9166 46.2477 46.9101 46.292 46.8964C46.4607 46.8444 46.5843 46.7 46.6098 46.5253L47.8106 38.2673L54.3457 35.8872C54.4891 35.835 54.5971 35.7146 54.6333 35.5662C54.6695 35.4179 54.6293 35.2613 54.5261 35.1487L47.289 27.2588C47.2016 27.1636 47.079 27.1107 46.9521 27.1107Z" fill="#374874"/>
<path d="M21.228 60.8524L17.8786 58.9059L16.5942 51.8352L19.9437 53.7817L21.228 60.8524Z" fill="#FBDBD0"/>
<path d="M13.1603 59.1457L9.81079 57.1993L16.476 42.1309L19.8254 44.0774L13.1603 59.1457Z" fill="#FBDBD0"/>
<path d="M21.2009 61.0794C21.1011 61.0676 21.021 60.9919 21.0029 60.8932L19.7869 54.197L13.3018 59.3249C13.2603 59.3578 13.2103 59.3743 13.1601 59.3743C13.1107 59.3743 13.0614 59.3584 13.0201 59.3265C12.9371 59.262 12.9085 59.1493 12.9511 59.0533L19.6163 43.985C19.6473 43.9144 19.7121 43.8645 19.7882 43.8519C19.8005 43.8497 19.813 43.8488 19.8253 43.8488C19.8886 43.8488 19.95 43.8751 19.9936 43.9227C21.502 45.5658 23.5525 46.4341 25.9235 46.4341C26.4424 46.4341 26.9815 46.3923 27.5257 46.3098C27.5373 46.308 27.5487 46.3071 27.5601 46.3071C27.6306 46.3071 27.698 46.3399 27.7418 46.3968C27.7925 46.4631 27.803 46.5517 27.769 46.6281L21.4369 60.9448C21.4 61.0283 21.3177 61.081 21.2279 61.081C21.219 61.081 21.2101 61.0805 21.2009 61.0794Z" fill="white"/>
<path d="M19.8253 44.0774C21.3733 45.7635 23.4719 46.6626 25.9234 46.6626C26.4533 46.6626 26.9992 46.6207 27.5601 46.5356L21.228 60.8525L19.9437 53.7818L13.1602 59.1458L19.8253 44.0774ZM19.8253 43.6203C19.8007 43.6203 19.7758 43.6223 19.7511 43.6263C19.5989 43.6514 19.4696 43.7515 19.4072 43.8925L12.7421 58.9608C12.6571 59.153 12.7141 59.3785 12.8802 59.5071C12.9626 59.571 13.0614 59.6029 13.1602 59.6029C13.2604 59.6029 13.3606 59.57 13.4437 59.5043L19.6299 54.6126L20.7782 60.9341C20.8141 61.1317 20.9746 61.2827 21.174 61.3064C21.1921 61.3085 21.2103 61.3096 21.2282 61.3096C21.4074 61.3096 21.5722 61.2042 21.6461 61.0374L27.9781 46.7204C28.0456 46.5678 28.0246 46.3905 27.9233 46.258C27.836 46.1438 27.7012 46.0784 27.5601 46.0784C27.5373 46.0784 27.5144 46.0801 27.4915 46.0836C26.9584 46.1644 26.4309 46.2055 25.9234 46.2055C23.6182 46.2055 21.626 45.3626 20.1621 43.7682C20.0747 43.6732 19.9522 43.6203 19.8253 43.6203Z" fill="#374874"/>
<path d="M41.2172 4.23825C38.279 2.53078 34.2101 2.77128 29.7196 5.36388C20.7944 10.5168 13.5843 23.0069 13.6134 33.2609C13.6244 37.1606 14.6804 40.1751 16.476 42.1309C17.0262 42.7302 17.6459 43.2301 18.3264 43.6255L21.6758 45.572C20.9954 45.1766 20.3757 44.6767 19.8254 44.0774C18.0298 42.1216 16.9739 39.1071 16.9628 35.2074C16.9338 24.9533 24.1438 12.4633 33.069 7.31037C37.5596 4.71773 41.6285 4.4772 44.5667 6.18474L41.2172 4.23825Z" fill="#FBDBD0"/>
<path d="M25.9231 46.8912C23.4208 46.891 21.2541 45.9716 19.657 44.2321C17.7556 42.1609 16.7449 39.0406 16.7342 35.208C16.705 24.8966 23.9815 12.293 32.9547 7.11242C35.5371 5.62156 38.0148 4.86554 40.319 4.86554C43.032 4.86554 45.3269 5.93406 46.9554 7.95528C48.6159 10.0164 49.499 12.9876 49.509 16.5481C49.5188 19.9946 48.7079 23.8345 47.1639 27.6535C45.4032 32.0089 42.8289 36.0751 39.7193 39.4126C37.688 41.593 35.5244 43.3531 33.2885 44.6439C31.3338 45.7725 29.4179 46.4847 27.5945 46.7613C27.0273 46.8475 26.4648 46.8912 25.9231 46.8912Z" fill="white"/>
<path d="M40.3189 5.09411C45.6134 5.09411 49.2598 9.28819 49.2804 16.5487C49.2905 20.0866 48.4384 23.8912 46.9521 27.5678C45.2241 31.8422 42.6391 35.9434 39.552 39.2568C37.5971 41.355 35.4406 43.1374 33.1742 44.4458C31.2011 45.585 29.31 46.27 27.5601 46.5355C26.9994 46.6206 26.4531 46.6626 25.9234 46.6626C23.4718 46.6626 21.3735 45.7635 19.8253 44.0773C18.0297 42.1216 16.9738 39.1071 16.9627 35.2073C16.9337 24.9533 24.1438 12.4633 33.0689 7.31033C35.674 5.80627 38.1361 5.09411 40.3189 5.09411ZM40.3189 4.63696C37.974 4.63696 35.4579 5.4032 32.8403 6.91447C28.4555 9.44606 24.3436 13.7798 21.2621 19.1174C18.1805 24.455 16.4913 30.1697 16.5056 35.2086C16.5166 39.0994 17.5481 42.273 19.4886 44.3865C21.1302 46.1746 23.3554 47.1197 25.9234 47.1197C26.4767 47.1197 27.0504 47.0752 27.6286 46.9875C29.4807 46.7065 31.4234 45.9846 33.4028 44.8418C35.658 43.5397 37.8394 41.7655 39.8864 39.5684C43.0149 36.2106 45.6048 32.1201 47.3759 27.7392C48.9308 23.893 49.7474 20.0229 49.7375 16.5475C49.7273 12.9345 48.8268 9.91376 47.1333 7.81193C45.4599 5.73486 43.1035 4.63696 40.3189 4.63696Z" fill="#374874"/>
<path d="M33.0825 12.0887C39.7092 8.26271 45.1004 11.335 45.122 18.9496C45.1436 26.5661 39.7874 35.8415 33.1607 39.6675C26.5324 43.4943 21.1428 40.423 21.1212 32.8065C21.0996 25.1919 26.4542 15.9156 33.0825 12.0887ZM39.8434 32.5422L38.5402 24.7193L43.9376 15.5282L36.4516 18.6L33.0843 12.7407L29.7597 22.4636L22.2823 28.031L27.7125 30.9706L26.4594 40.2694L33.14 32.3631L39.8434 32.5422Z" fill="#C1DBF6"/>
<path d="M36.9695 3.14758C38.5496 3.14763 39.9833 3.52119 41.2172 4.23821L44.5666 6.18475C44.558 6.17972 44.5492 6.17527 44.5405 6.17031C47.4563 7.84982 49.266 11.4397 49.2804 16.5488C49.2905 20.0867 48.4384 23.8913 46.9521 27.5679L54.1892 35.4576L47.3975 37.9312L46.1574 46.4595L42.8079 44.5131L38.7492 40.0873C37.0124 41.8262 35.1348 43.3139 33.1742 44.4459C31.2011 45.585 29.31 46.27 27.5601 46.5355L21.2281 60.8524L17.8786 58.9059L17.3241 55.8532L13.1602 59.1457L9.81075 57.1992L16.4759 42.1308C14.6803 40.1751 13.6244 37.1606 13.6133 33.2608C13.5843 23.0068 20.7943 10.5168 29.7195 5.36386C32.3244 3.85991 34.7868 3.14742 36.9695 3.14758ZM36.9696 2.23328C34.5436 2.23312 31.9506 3.02 29.2624 4.57207C24.8091 7.14315 20.6378 11.5364 17.5168 16.9423C14.3955 22.3487 12.6846 28.145 12.6991 33.2634C12.7097 36.9994 13.6435 40.1037 15.4063 42.2889L8.97464 56.8294C8.78609 57.2557 8.94842 57.7555 9.35141 57.9898L12.7009 59.9363C12.8436 60.0192 13.0022 60.06 13.1601 60.06C13.3617 60.06 13.5622 59.9935 13.7273 59.8629L16.6967 57.5149L16.9791 59.0693C17.0269 59.3327 17.1878 59.5619 17.4193 59.6965L20.7687 61.6429C20.9097 61.7249 21.0684 61.7667 21.2281 61.7667C21.323 61.7667 21.4183 61.7519 21.5105 61.722C21.7577 61.6417 21.9591 61.46 22.0643 61.2222L28.1981 47.3536C29.9541 47.0174 31.7793 46.3069 33.6314 45.2376C35.3715 44.233 37.077 42.9441 38.7118 41.3992L42.1341 45.131C42.1965 45.199 42.2688 45.2572 42.3486 45.3036L45.698 47.2501C45.8397 47.3324 45.9984 47.3738 46.1574 47.3738C46.2932 47.3738 46.4292 47.3436 46.555 47.2828C46.8285 47.1508 47.0185 46.8916 47.0622 46.5911L48.2237 38.6034L54.5021 36.3167C54.7891 36.2122 55.0049 35.9713 55.0774 35.6747C55.1499 35.3779 55.0694 35.0647 54.863 34.8396L48.0128 27.3717C49.4511 23.6489 50.2043 19.9177 50.1948 16.5462C50.1798 11.2815 48.3446 7.32297 45.0258 5.39474L45.026 5.39424L41.6766 3.4477C40.2901 2.64201 38.7064 2.23334 36.9696 2.23328Z" fill="#374874"/>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -0,0 +1,15 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.2138 24.3524C12.8908 24.5338 12.7282 24.7757 12.7275 25.0184L12.6284 59.9615C12.6291 59.7187 12.7918 59.4768 13.1148 59.2955L48.2384 39.5764L48.3374 4.6333L13.2138 24.3524Z" fill="#C1DBF6"/>
<path d="M52.7867 7.29175L52.6876 42.2348L17.5641 61.9539L17.6632 27.0109L52.7867 7.29175Z" fill="white"/>
<path d="M9.47156 59.9539L9.57063 25.0109C9.56866 25.707 10.0166 26.4049 10.9184 26.9437C12.7517 28.0391 15.778 28.0692 17.6631 27.0109L17.564 61.954C15.679 63.0123 12.6526 62.9821 10.8194 61.8868C9.91758 61.348 9.46958 60.65 9.47156 59.9539Z" fill="#FBDBD0"/>
<path d="M46.136 3.31799L48.3373 4.63326L13.2137 24.3524C12.5776 24.7095 12.5634 25.3016 13.1834 25.6721C13.802 26.0417 14.8242 26.0519 15.4603 25.6947L50.5839 5.97559L52.7866 7.29168L17.663 27.0108C15.778 28.0692 12.7516 28.039 10.9184 26.9437C9.08515 25.8483 9.12732 24.0955 11.0124 23.0371L46.136 3.31799Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M48.53 4.52518C48.5898 4.63159 48.5519 4.76628 48.4455 4.82602L13.322 24.5452C13.0441 24.7011 12.9516 24.8789 12.9485 25.0125C12.9454 25.1446 13.028 25.3218 13.2968 25.4825C13.5643 25.6423 13.9335 25.7313 14.3176 25.7351C14.7015 25.7389 15.0757 25.6574 15.3523 25.5021L50.4758 5.78301C50.5822 5.72327 50.7169 5.7611 50.7766 5.86751C50.8364 5.97392 50.7985 6.10862 50.6921 6.16836L15.5686 25.8875C15.209 26.0894 14.7541 26.1814 14.3132 26.177C13.8725 26.1726 13.4213 26.0716 13.0701 25.8618C12.7189 25.652 12.4986 25.3479 12.5067 25.0022C12.5147 24.6578 12.7473 24.361 13.1056 24.1598L48.2292 4.44068C48.3356 4.38094 48.4703 4.41877 48.53 4.52518Z" fill="#374874"/>
<path d="M15.4604 25.6947L37.9874 2.06631C38.5993 1.4544 39.5026 1.16302 40.3768 1.30871C41.1635 1.42526 41.7754 2.03717 41.9211 2.79477L43.4938 9.93472C43.4938 9.93472 15.2127 25.9424 15.4604 25.6947Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.1455 2.22077L16.5735 24.8475C17.3183 24.4357 18.3694 23.8492 19.6281 23.144C22.2443 21.6782 25.756 19.7006 29.2756 17.7152C32.7952 15.7297 36.3225 13.7366 38.97 12.2397C40.2937 11.4913 41.3975 10.867 42.1703 10.4298L43.2429 9.82284L41.7054 2.84231C41.705 2.84038 41.7046 2.83844 41.7042 2.83651C41.5757 2.16831 41.0356 1.62968 40.3444 1.5273L40.3405 1.52667C39.537 1.39276 38.7065 1.66094 38.1455 2.22077ZM43.4938 9.93473C43.6026 10.127 43.6025 10.1271 43.6023 10.1272L43.2851 10.3067C43.078 10.4239 42.7743 10.5958 42.3879 10.8144C41.615 11.2516 40.5112 11.876 39.1875 12.6244C36.54 14.1213 33.0125 16.1145 29.4928 18.1001C25.9731 20.0856 22.4609 22.0635 19.8441 23.5295C18.5358 24.2625 17.4509 24.8678 16.7007 25.2821C16.3257 25.4892 16.0336 25.649 15.8387 25.7534C15.7417 25.8053 15.6669 25.8445 15.6175 25.8691C15.5945 25.8806 15.5707 25.892 15.5516 25.8997C15.5465 25.9018 15.5385 25.9048 15.5291 25.9078L15.5286 25.9079C15.5237 25.9095 15.4976 25.9178 15.4647 25.9185C15.4409 25.9178 15.3738 25.9014 15.3337 25.8795C15.282 25.8299 15.2381 25.7093 15.2426 25.6511C15.2511 25.6213 15.2721 25.5786 15.2817 25.5645C15.2868 25.558 15.2949 25.5485 15.3005 25.5424L37.8275 1.91385L37.8312 1.91008C38.4935 1.24777 39.4678 0.933872 40.4112 1.09044C41.2915 1.22173 41.9738 1.90506 42.1376 2.75006L43.7096 9.8872C43.7304 9.98195 43.6871 10.0792 43.6026 10.127L43.4938 9.93473Z" fill="#374874"/>
<path d="M15.4604 25.6948L40.9597 4.4407C41.5716 3.82879 42.4748 3.53741 43.349 3.6831C44.1358 3.79965 45.1265 4.26587 45.2722 5.05261L46.3212 8.34527L15.4604 25.6948Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M43.3126 3.90106C42.5083 3.76701 41.6768 4.0359 41.1158 4.59695C41.1111 4.60167 41.1061 4.60617 41.101 4.61044L17.4547 24.3201L46.0559 8.24087L45.0615 5.11969C45.0587 5.1109 45.0565 5.10193 45.0548 5.09285C44.9975 4.78354 44.7694 4.51538 44.4331 4.3035C44.0989 4.09291 43.6846 3.95621 43.3165 3.90169L43.3126 3.90106ZM43.3833 3.46483C42.4433 3.30884 41.4727 3.61989 40.8105 4.27726L15.3189 25.5251C15.2311 25.5982 15.2136 25.7261 15.2784 25.8202C15.3432 25.9142 15.4691 25.9434 15.5686 25.8874L46.4293 8.53789C46.5207 8.48653 46.5634 8.37807 46.5316 8.27821L45.4866 4.99817C45.3942 4.52784 45.0569 4.17417 44.6687 3.92959C44.2751 3.68163 43.8014 3.52704 43.3833 3.46483Z" fill="#374874"/>
<path d="M54.5285 28.6393L52.7351 29.6808L52.7205 17.0964L54.4556 18.062L54.5285 28.6393Z" fill="#374874"/>
<path d="M54.5285 28.6393L50.2888 31.1015C48.2054 32.3107 45.5538 31.3346 45.5538 28.4062C45.5538 25.4777 47.1565 22.5202 49.7935 20.9904L54.4265 18.3242L54.5285 28.6393Z" fill="#FBDBD0"/>
<path d="M49.3653 27.7718C49.4524 26.8245 48.9912 26.0076 48.3351 25.9472C47.679 25.8869 47.0765 26.6059 46.9894 27.5532C46.9022 28.5006 47.3634 29.3175 48.0195 29.3778C48.6756 29.4382 49.2781 28.7192 49.3653 27.7718Z" fill="white"/>
<path d="M39.9286 1.2717C40.0778 1.2717 40.2278 1.28384 40.3768 1.30866C41.1636 1.42523 41.7755 2.03712 41.9212 2.79474L42.1333 3.75797C42.3848 3.68569 42.6469 3.64706 42.9114 3.64706C43.057 3.64706 43.2033 3.65879 43.3489 3.68302C43.7696 3.74535 44.2477 3.90866 44.6246 4.16649L46.136 3.31797L48.3373 4.63326L48.3299 7.24101L50.584 5.97557L52.7866 7.29166L52.7588 17.1177L54.4556 18.0619L54.5285 28.6392L52.7231 29.6876L52.6876 42.2347L17.564 61.9539C16.6428 62.4711 15.449 62.7283 14.255 62.7283C13.0059 62.7283 11.7567 62.4467 10.8194 61.8867C9.91755 61.3479 9.46958 60.6499 9.47154 59.9538L9.57061 25.0107C9.57049 25.0516 9.57717 25.0924 9.58016 25.1332C9.52469 24.3738 10.0003 23.6053 11.0124 23.0371L26.0349 14.6032L37.9874 2.06622C38.495 1.55863 39.203 1.27166 39.9286 1.2717ZM39.9287 0.387817C38.9674 0.387772 38.032 0.771726 37.3625 1.44126C37.3575 1.44622 37.3526 1.45125 37.3477 1.45634L25.4866 13.8973L10.5797 22.2664C9.41483 22.9204 8.74219 23.8649 8.69451 24.893C8.68949 24.9307 8.68683 24.9692 8.68672 25.0083L8.58766 59.9513C8.58471 61.0017 9.21625 61.9585 10.366 62.6455C11.4094 63.2689 12.7905 63.6122 14.255 63.6122C15.6484 63.6122 16.9772 63.297 17.9967 62.7246L53.1202 43.0055C53.3981 42.8495 53.5705 42.556 53.5714 42.2373L53.6055 30.1973L54.9723 29.4035C55.2466 29.2443 55.4144 28.9503 55.4123 28.6332L55.3394 18.0559C55.3372 17.7373 55.1637 17.4445 54.8854 17.2896L53.6441 16.5989L53.6705 7.29422C53.6714 6.98229 53.5077 6.69296 53.24 6.53298L51.0373 5.21689C50.8978 5.13358 50.7409 5.09177 50.5839 5.09177C50.4349 5.09177 50.2858 5.12942 50.1513 5.20491L49.2181 5.72882L49.2211 4.63579C49.222 4.32386 49.0584 4.03456 48.7906 3.87458L46.5893 2.55927C46.4498 2.47595 46.293 2.43415 46.1359 2.43415C45.9869 2.43415 45.8378 2.47179 45.7033 2.54729L44.623 3.15378C44.2695 2.98871 43.8758 2.86843 43.4863 2.8099C43.2976 2.77893 43.1042 2.76322 42.9114 2.76322C42.8807 2.76322 42.8501 2.76361 42.8195 2.76439L42.7866 2.61518C42.5631 1.48194 41.6522 0.607795 40.5146 0.435635C40.3219 0.403932 40.1249 0.387817 39.9287 0.387817Z" fill="#374874"/>
</svg>

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -0,0 +1,14 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M46.1574 46.4595L42.8079 44.513L36.2025 37.3103L39.552 39.2568L46.1574 46.4595Z" fill="#FBDBD0"/>
<path d="M46.1575 46.6881C46.0945 46.6881 46.0329 46.662 45.989 46.614L39.3836 39.4113C39.3031 39.3234 39.3037 39.1883 39.385 39.1011C42.4569 35.804 45.0001 31.7863 46.7403 27.4821C46.7696 27.4093 46.8343 27.3569 46.9115 27.3428C46.9249 27.3404 46.9385 27.3392 46.9521 27.3392C47.0155 27.3392 47.0769 27.3656 47.1207 27.4133L54.3577 35.3033C54.4093 35.3596 54.4294 35.4379 54.4113 35.512C54.3932 35.5861 54.3392 35.6464 54.2673 35.6725L47.6041 38.0993L46.3836 46.4924C46.3709 46.5799 46.3091 46.6519 46.2249 46.6781C46.2026 46.6848 46.18 46.6881 46.1575 46.6881Z" fill="white"/>
<path d="M46.9521 27.5679L54.1892 35.4577L47.3975 37.9313L46.1574 46.4595L39.552 39.2568C42.6392 35.9434 45.2241 31.8423 46.9521 27.5679ZM46.9521 27.1107C46.9251 27.1107 46.8978 27.1131 46.8707 27.118C46.7163 27.146 46.5871 27.2511 46.5283 27.3965C44.8234 31.6137 42.227 35.7151 39.2175 38.9452C39.0549 39.1197 39.0538 39.39 39.2151 39.5658L45.8205 46.7685C45.9084 46.8644 46.0313 46.9166 46.1574 46.9166C46.2023 46.9166 46.2477 46.9101 46.292 46.8964C46.4607 46.8444 46.5843 46.7 46.6098 46.5253L47.8106 38.2673L54.3457 35.8872C54.4891 35.835 54.5971 35.7146 54.6333 35.5662C54.6695 35.4179 54.6293 35.2613 54.5261 35.1487L47.289 27.2588C47.2016 27.1636 47.079 27.1107 46.9521 27.1107Z" fill="#374874"/>
<path d="M21.228 60.8524L17.8786 58.9059L16.5942 51.8352L19.9437 53.7817L21.228 60.8524Z" fill="#FBDBD0"/>
<path d="M13.1603 59.1457L9.81079 57.1993L16.476 42.1309L19.8254 44.0774L13.1603 59.1457Z" fill="#FBDBD0"/>
<path d="M21.2009 61.0794C21.1011 61.0676 21.021 60.9919 21.0029 60.8932L19.7869 54.197L13.3018 59.3249C13.2603 59.3578 13.2103 59.3743 13.1601 59.3743C13.1107 59.3743 13.0614 59.3584 13.0201 59.3265C12.9371 59.262 12.9085 59.1493 12.9511 59.0533L19.6163 43.985C19.6473 43.9144 19.7121 43.8645 19.7882 43.8519C19.8005 43.8497 19.813 43.8488 19.8253 43.8488C19.8886 43.8488 19.95 43.8751 19.9936 43.9227C21.502 45.5658 23.5525 46.4341 25.9235 46.4341C26.4424 46.4341 26.9815 46.3923 27.5257 46.3098C27.5373 46.308 27.5487 46.3071 27.5601 46.3071C27.6306 46.3071 27.698 46.3399 27.7418 46.3968C27.7925 46.4631 27.803 46.5517 27.769 46.6281L21.4369 60.9448C21.4 61.0283 21.3177 61.081 21.2279 61.081C21.219 61.081 21.2101 61.0805 21.2009 61.0794Z" fill="white"/>
<path d="M19.8253 44.0774C21.3733 45.7635 23.4719 46.6626 25.9234 46.6626C26.4533 46.6626 26.9992 46.6207 27.5601 46.5356L21.228 60.8525L19.9437 53.7818L13.1602 59.1458L19.8253 44.0774ZM19.8253 43.6203C19.8007 43.6203 19.7758 43.6223 19.7511 43.6263C19.5989 43.6514 19.4696 43.7515 19.4072 43.8925L12.7421 58.9608C12.6571 59.153 12.7141 59.3785 12.8802 59.5071C12.9626 59.571 13.0614 59.6029 13.1602 59.6029C13.2604 59.6029 13.3606 59.57 13.4437 59.5043L19.6299 54.6126L20.7782 60.9341C20.8141 61.1317 20.9746 61.2827 21.174 61.3064C21.1921 61.3085 21.2103 61.3096 21.2282 61.3096C21.4074 61.3096 21.5722 61.2042 21.6461 61.0374L27.9781 46.7204C28.0456 46.5678 28.0246 46.3905 27.9233 46.258C27.836 46.1438 27.7012 46.0784 27.5601 46.0784C27.5373 46.0784 27.5144 46.0801 27.4915 46.0836C26.9584 46.1644 26.4309 46.2055 25.9234 46.2055C23.6182 46.2055 21.626 45.3626 20.1621 43.7682C20.0747 43.6732 19.9522 43.6203 19.8253 43.6203Z" fill="#374874"/>
<path d="M41.2172 4.23825C38.279 2.53078 34.2101 2.77128 29.7196 5.36388C20.7944 10.5168 13.5843 23.0069 13.6134 33.2609C13.6244 37.1606 14.6804 40.1751 16.476 42.1309C17.0262 42.7302 17.6459 43.2301 18.3264 43.6255L21.6758 45.572C20.9954 45.1766 20.3757 44.6767 19.8254 44.0774C18.0298 42.1216 16.9739 39.1071 16.9628 35.2074C16.9338 24.9533 24.1438 12.4633 33.069 7.31037C37.5596 4.71773 41.6285 4.4772 44.5667 6.18474L41.2172 4.23825Z" fill="#FBDBD0"/>
<path d="M25.9231 46.8912C23.4208 46.891 21.2541 45.9716 19.657 44.2321C17.7556 42.1609 16.7449 39.0406 16.7342 35.208C16.705 24.8966 23.9815 12.293 32.9547 7.11242C35.5371 5.62156 38.0148 4.86554 40.319 4.86554C43.032 4.86554 45.3269 5.93406 46.9554 7.95528C48.6159 10.0164 49.499 12.9876 49.509 16.5481C49.5188 19.9946 48.7079 23.8345 47.1639 27.6535C45.4032 32.0089 42.8289 36.0751 39.7193 39.4126C37.688 41.593 35.5244 43.3531 33.2885 44.6439C31.3338 45.7725 29.4179 46.4847 27.5945 46.7613C27.0273 46.8475 26.4648 46.8912 25.9231 46.8912Z" fill="white"/>
<path d="M40.3189 5.09411C45.6134 5.09411 49.2598 9.28819 49.2804 16.5487C49.2905 20.0866 48.4384 23.8912 46.9521 27.5678C45.2241 31.8422 42.6391 35.9434 39.552 39.2568C37.5971 41.355 35.4406 43.1374 33.1742 44.4458C31.2011 45.585 29.31 46.27 27.5601 46.5355C26.9994 46.6206 26.4531 46.6626 25.9234 46.6626C23.4718 46.6626 21.3735 45.7635 19.8253 44.0773C18.0297 42.1216 16.9738 39.1071 16.9627 35.2073C16.9337 24.9533 24.1438 12.4633 33.0689 7.31033C35.674 5.80627 38.1361 5.09411 40.3189 5.09411ZM40.3189 4.63696C37.974 4.63696 35.4579 5.4032 32.8403 6.91447C28.4555 9.44606 24.3436 13.7798 21.2621 19.1174C18.1805 24.455 16.4913 30.1697 16.5056 35.2086C16.5166 39.0994 17.5481 42.273 19.4886 44.3865C21.1302 46.1746 23.3554 47.1197 25.9234 47.1197C26.4767 47.1197 27.0504 47.0752 27.6286 46.9875C29.4807 46.7065 31.4234 45.9846 33.4028 44.8418C35.658 43.5397 37.8394 41.7655 39.8864 39.5684C43.0149 36.2106 45.6048 32.1201 47.3759 27.7392C48.9308 23.893 49.7474 20.0229 49.7375 16.5475C49.7273 12.9345 48.8268 9.91376 47.1333 7.81193C45.4599 5.73486 43.1035 4.63696 40.3189 4.63696Z" fill="#374874"/>
<path d="M33.0825 12.0887C39.7092 8.26271 45.1004 11.335 45.122 18.9496C45.1436 26.5661 39.7874 35.8415 33.1607 39.6675C26.5324 43.4943 21.1428 40.423 21.1212 32.8065C21.0996 25.1919 26.4542 15.9156 33.0825 12.0887ZM39.8434 32.5422L38.5402 24.7193L43.9376 15.5282L36.4516 18.6L33.0843 12.7407L29.7597 22.4636L22.2823 28.031L27.7125 30.9706L26.4594 40.2694L33.14 32.3631L39.8434 32.5422Z" fill="#C1DBF6"/>
<path d="M36.9695 3.14758C38.5496 3.14763 39.9833 3.52119 41.2172 4.23821L44.5666 6.18475C44.558 6.17972 44.5492 6.17527 44.5405 6.17031C47.4563 7.84982 49.266 11.4397 49.2804 16.5488C49.2905 20.0867 48.4384 23.8913 46.9521 27.5679L54.1892 35.4576L47.3975 37.9312L46.1574 46.4595L42.8079 44.5131L38.7492 40.0873C37.0124 41.8262 35.1348 43.3139 33.1742 44.4459C31.2011 45.585 29.31 46.27 27.5601 46.5355L21.2281 60.8524L17.8786 58.9059L17.3241 55.8532L13.1602 59.1457L9.81075 57.1992L16.4759 42.1308C14.6803 40.1751 13.6244 37.1606 13.6133 33.2608C13.5843 23.0068 20.7943 10.5168 29.7195 5.36386C32.3244 3.85991 34.7868 3.14742 36.9695 3.14758ZM36.9696 2.23328C34.5436 2.23312 31.9506 3.02 29.2624 4.57207C24.8091 7.14315 20.6378 11.5364 17.5168 16.9423C14.3955 22.3487 12.6846 28.145 12.6991 33.2634C12.7097 36.9994 13.6435 40.1037 15.4063 42.2889L8.97464 56.8294C8.78609 57.2557 8.94842 57.7555 9.35141 57.9898L12.7009 59.9363C12.8436 60.0192 13.0022 60.06 13.1601 60.06C13.3617 60.06 13.5622 59.9935 13.7273 59.8629L16.6967 57.5149L16.9791 59.0693C17.0269 59.3327 17.1878 59.5619 17.4193 59.6965L20.7687 61.6429C20.9097 61.7249 21.0684 61.7667 21.2281 61.7667C21.323 61.7667 21.4183 61.7519 21.5105 61.722C21.7577 61.6417 21.9591 61.46 22.0643 61.2222L28.1981 47.3536C29.9541 47.0174 31.7793 46.3069 33.6314 45.2376C35.3715 44.233 37.077 42.9441 38.7118 41.3992L42.1341 45.131C42.1965 45.199 42.2688 45.2572 42.3486 45.3036L45.698 47.2501C45.8397 47.3324 45.9984 47.3738 46.1574 47.3738C46.2932 47.3738 46.4292 47.3436 46.555 47.2828C46.8285 47.1508 47.0185 46.8916 47.0622 46.5911L48.2237 38.6034L54.5021 36.3167C54.7891 36.2122 55.0049 35.9713 55.0774 35.6747C55.1499 35.3779 55.0694 35.0647 54.863 34.8396L48.0128 27.3717C49.4511 23.6489 50.2043 19.9177 50.1948 16.5462C50.1798 11.2815 48.3446 7.32297 45.0258 5.39474L45.026 5.39424L41.6766 3.4477C40.2901 2.64201 38.7064 2.23334 36.9696 2.23328Z" fill="#374874"/>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

View file

@ -0,0 +1,21 @@
import { Interaction } from "@web/public/interaction";
import { registry } from "@web/core/registry";
import { rpc } from '@web/core/network/rpc';
import { PortalLoyaltyCardDialog } from '../js/portal/loyalty_card_dialog/loyalty_card_dialog';
export class LoyaltyCard extends Interaction {
static selector = ".o_loyalty_container .o_loyalty_card";
dynamicContent = {
_root: { "t-on-click": this.onLoyaltyCardClick },
};
async onLoyaltyCardClick() {
const data = await this.waitFor(rpc(`/my/loyalty_card/${this.el.dataset.card_id}/values`));
this.services.dialog.add(PortalLoyaltyCardDialog, data);
}
}
registry
.category("public.interactions")
.add("loyalty.loyalty_card", LoyaltyCard);

View file

@ -1,44 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { SelectionField } from "@web/views/fields/selection/selection_field";
/**
* The purpose of this field is to be able to define some values which should not be
* displayed on our selection field, this way we can have multiple views for the same model
* that uses different possible sets of values on the same selection field.
*/
export class FilterableSelectionField extends SelectionField {
/**
* @override
*/
get options() {
let options = super.options;
if (this.props.whitelisted_values) {
options = options.filter((option) => {
return option[0] === this.props.value || this.props.whitelisted_values.includes(option[0])
});
} else if (this.props.blacklisted_values) {
options = options.filter((option) => {
return option[0] === this.props.value || !this.props.blacklisted_values.includes(option[0]);
});
}
return options;
}
};
FilterableSelectionField.props = {
...SelectionField.props,
whitelisted_values: { type: Array, optional: true },
blacklisted_values: { type: Array, optional: true },
};
FilterableSelectionField.extractProps = ({ attrs }) => {
return {
...SelectionField.extractProps({ attrs }),
whitelisted_values: attrs.options.whitelisted_values,
blacklisted_values: attrs.options.blacklisted_values,
};
};
registry.category("fields").add("filterable_selection", FilterableSelectionField);

View file

@ -1,5 +1,3 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";

View file

@ -1,9 +1,13 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { X2ManyField } from "@web/views/fields/x2many/x2many_field";
import { X2ManyField, x2ManyField } from "@web/views/fields/x2many/x2many_field";
export class LoyaltyX2ManyField extends X2ManyField {};
LoyaltyX2ManyField.template = "loyalty.LoyaltyX2ManyField";
export class LoyaltyX2ManyField extends X2ManyField {
static template = "loyalty.LoyaltyX2ManyField";
}
registry.category("fields").add("loyalty_one2many", LoyaltyX2ManyField);
export const loyaltyX2ManyField = {
...x2ManyField,
component: LoyaltyX2ManyField,
};
registry.category("fields").add("loyalty_one2many", loyaltyX2ManyField);

View file

@ -1,13 +1,12 @@
/** @odoo-module **/
import { Component, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { ListRenderer } from "@web/views/list/list_renderer";
import { useService } from "@web/core/utils/hooks";
const { Component, onWillStart } = owl;
import { ListRenderer } from "@web/views/list/list_renderer";
import { listView } from "@web/views/list/list_view";
export class LoyaltyActionHelper extends Component {
static template = "loyalty.LoyaltyActionHelper";
static props = ["noContentHelp"];
setup() {
this.orm = useService("orm");
this.action = useService("action");
@ -37,13 +36,13 @@ export class LoyaltyActionHelper extends Component {
this.action.doAction(action);
}
};
LoyaltyActionHelper.template = "loyalty.LoyaltyActionHelper";
export class LoyaltyListRenderer extends ListRenderer {};
LoyaltyListRenderer.template = "loyalty.LoyaltyListRenderer";
LoyaltyListRenderer.components = {
...LoyaltyListRenderer.components,
LoyaltyActionHelper,
export class LoyaltyListRenderer extends ListRenderer {
static template = "loyalty.LoyaltyListRenderer";
static components = {
...LoyaltyListRenderer.components,
LoyaltyActionHelper,
};
};
export const LoyaltyListView = {

View file

@ -0,0 +1,12 @@
import { Component } from '@odoo/owl';
import { Dialog } from '@web/core/dialog/dialog';
export class PortalLoyaltyCardDialog extends Component {
static components = { Dialog };
static template = 'loyalty.portal_loyalty_card_dialog';
static props = ['*'];
setup() {
this.csrf_token = odoo.csrf_token;
}
}

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="loyalty.portal_loyalty_card_dialog">
<Dialog size="'md'" header="false" footer="false" t-on-click="props.close">
<div class="d-flex align-items-center" t-on-click.stop="">
<div class="d-flex justify-content-between w-100 align-items-center">
<div class="d-flex align-items-center gap-2">
<img
class="img-fluid"
t-att-src="props.img_path"
width="40"
/>
<div class="fs-5"><t t-out="props.program.program_name"/></div>
</div>
<div class="d-flex flex-column align-items-end">
<span class="fs-5" t-out="props.card.points_display"/>
<i class="text-secondary fs-6" t-if="props.card.expiration_date">
Valid until <t t-out="props.card.expiration_date"/>
</i>
</div>
</div>
<div
type="button"
class="d-sm-block btn-close p-0 p-md-2"
t-on-click="props.close"
/>
</div>
<hr/>
<div class="mt-0 pt-0" t-on-click.stop="">
<div name="history_lines">
<t t-if="props.history_lines.length">
<strong>Last Transactions</strong>
<div
t-foreach="props.history_lines"
t-as="line"
t-key="line_index"
class="row px-2"
>
<div class="col-7 text-truncate">
<t t-if="line.order_id and line.order_portal_url">
<a t-att-href="line.order_portal_url">
<t t-out="line.description"/>
</a>
</t>
<t t-else="">
<t t-out="line.description"/>
</t>
</div>
<div t-out="line.points" class="text-end col-5"/>
</div>
<div class="mt-1 mb-1">
<a t-attf-href="/my/loyalty_card/{{props.card.id}}/history/">
-> View History
</a>
</div>
</t>
<t t-else="">
<p class="alert alert-warning">
There are currently no transaction lines for this card.
</p>
</t>
</div>
</div>
</Dialog>
</t>
</templates>

View file

@ -0,0 +1,7 @@
// = Loyalty view
// ============================================================================
// No CSS hacks, variables overrides only
.loyalty-template {
--LoyaltyTemplate-background-hover: #{$o-gray-300};
}

View file

@ -28,32 +28,11 @@
pointer-events: auto;
.loyalty-template {
&, * {
transition: all .15s;
}
padding: map-get($spacers, 2) !important;
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;
}
background: var(--LoyaltyTemplate-background-hover, #{$o-gray-200});
}
}
}
@ -65,7 +44,7 @@
}
}
.loyalty-program-list-view .o_view_nocontent{
.o_loyalty_program_list_view_view .o_list_renderer .o_view_nocontent{
@include media-breakpoint-down(lg){
height: fit-content;
}

View file

@ -1,14 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<t t-name="loyalty.LoyaltyListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary" owl="1">
<t t-call="web.ActionHelper" position="replace">
<t t-name="loyalty.LoyaltyListRenderer" t-inherit="web.ListRenderer" t-inherit-mode="primary">
<ActionHelper position="replace">
<t t-if="showNoContentHelper">
<LoyaltyActionHelper noContentHelp="props.noContentHelp"/>
</t>
</t>
</ActionHelper>
</t>
<t t-name="loyalty.LoyaltyActionHelper" owl="1">
<t t-name="loyalty.LoyaltyActionHelper">
<div class="o_view_nocontent flex-wrap pt-5">
<div class="container">
<div class="o_nocontent_help">
@ -41,10 +41,10 @@
</div>
</t>
<t t-name="loyalty.LoyaltyX2ManyField" owl="1" t-inherit-mode="primary" t-inherit="web.X2ManyField">
<t t-if="displayAddButton" position="replace">
<t t-name="loyalty.LoyaltyX2ManyField" t-inherit-mode="primary" t-inherit="web.X2ManyField">
<t t-if="displayControlPanelButtons" position="replace">
<h4 t-esc="field.string or ''"/>
<t t-if="displayAddButton">
<t t-if="displayControlPanelButtons">
<div class="o_cp_buttons me-0 ms-auto" role="toolbar" aria-label="Control panel buttons" t-ref="buttons">
<div>
<button type="button" class="btn btn-secondary o-kanban-button-new" title="Create record" accesskey="c" t-on-click="() => this.onAdd()">
@ -54,14 +54,10 @@
</div>
</t>
</t>
<div role="search" position="attributes">
<attribute name="t-if">props.value.count > props.value.limit</attribute>
</div>
</t>
<t t-name="loyalty.LoyaltyCardListView.buttons" owl="1" t-inherit-mode="primary" t-inherit="web.ListView.Buttons">
<xpath expr="//button[hasclass('o_list_button_add')]" position="replace"/>
<xpath expr="//t[contains(@t-if, 'isExportEnable')]" position="before">
<t t-name="loyalty.LoyaltyCardListView.buttons" t-inherit-mode="primary" t-inherit="web.ListView.Buttons">
<xpath expr="." position="inside">
<t t-set="supportedProgramTypes" t-value="['coupons', 'gift_card', 'ewallet']"/>
<button t-if="supportedProgramTypes.includes(props.context.program_type)" type="button" class="btn btn-primary o_loyalty_card_list_button_generate" t-attf-data-tooltip="Generate {{props.context.program_item_name}}"
t-on-click.stop.prevent="() => this.actionService.doAction('loyalty.loyalty_generate_wizard_action', { additionalContext: this.props.context, onClose: () => {this.model.load()} })">

View file

@ -1,123 +0,0 @@
/** @odoo-module **/
import { editSelect, getFixture } from "@web/../tests/helpers/utils";
import { makeView, setupViewRegistries } from "@web/../tests/views/helpers";
let serverData;
let target;
// Note: the containsN always check for one more as there will be an invisible empty option every time.
QUnit.module("Fields", (hooks) => {
hooks.beforeEach(() => {
target = getFixture();
serverData = {
models: {
program: {
fields: {
program_type: {
type: "selection",
selection: [
["coupon", "Coupons"],
["promotion", "Promotion"],
["gift_card", "gift_card"],
],
required: true,
}
},
records: [
{ id: 1, program_type: "coupon" },
{ id: 2, program_type: "gift_card" },
],
},
}
}
setupViewRegistries();
});
QUnit.module("Loyalty > FilterableSelectionField");
QUnit.test("FilterableSelectionField test whitelist", async (assert) => {
await makeView({
type: "form",
resModel: "program",
resId: 1,
serverData,
arch: `
<form>
<field name="program_type" widget="filterable_selection" options="{'whitelisted_values': ['coupons', 'promotion']}"/>
</form>`,
});
assert.containsN(target, "select option", 3);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
);
});
QUnit.test("FilterableSelectionField test blacklist", async (assert) => {
await makeView({
type: "form",
resModel: "program",
resId: 1,
serverData,
arch: `
<form>
<field name="program_type" widget="filterable_selection" options="{'blacklisted_values': ['gift_card']}"/>
</form>`,
});
assert.containsN(target, "select option", 3);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
);
});
QUnit.test("FilterableSelectionField test with invalid value", async (assert) => {
// The field should still display the current value in the list
await makeView({
type: "form",
resModel: "program",
resId: 2,
serverData,
arch: `
<form>
<field name="program_type" widget="filterable_selection" options="{'blacklisted_values': ['gift_card']}"/>
</form>`,
});
assert.containsN(target, "select option", 4);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"gift_card\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
);
await editSelect(target, ".o_field_widget[name='program_type'] select", '"coupon"');
assert.containsN(target, "select option", 3);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"coupon\"']",
);
assert.containsOnce(
target,
".o_field_widget[name='program_type'] select option[value='\"promotion\"']",
);
});
});

View file

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import test_loyalty

View file

@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from unittest.mock import patch
from psycopg2 import IntegrityError
from odoo.exceptions import ValidationError
from odoo.exceptions import UserError, ValidationError
from odoo.fields import Command
from odoo.tests import tagged, TransactionCase, Form
from odoo.tests import Form, TransactionCase, tagged
from odoo.tools import mute_logger
from unittest.mock import patch
@tagged('post_install', '-at_install')
class TestLoyalty(TransactionCase):
@ -16,22 +16,59 @@ class TestLoyalty(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.program = cls.env['loyalty.program'].create({
'name': 'Test Program',
'reward_ids': [(0, 0, {})],
})
cls.product = cls.env['product.product'].with_context(default_taxes_id=False).create({
'name': "Test Product",
'detailed_type': 'consu',
'type': 'consu',
'list_price': 20.0,
})
def create_program_with_code(self, code):
return self.env['loyalty.program'].create({
'name': "Discount delivery",
'program_type': 'promo_code',
'rule_ids': [Command.create({
'code': code,
'minimum_amount': 0,
})],
})
def test_loyalty_program_default_values(self):
# Test that the default values are correctly set when creating a new program
program = self.env['loyalty.program'].create({'name': "Test"})
self._check_promotion_default_values(program)
def _check_promotion_default_values(self, program):
self.assertEqual(program.program_type, 'promotion')
self.assertEqual(program.trigger, 'auto')
self.assertEqual(program.portal_visible, False)
self.assertTrue(program.rule_ids)
self.assertTrue(len(program.rule_ids) == 1)
self.assertEqual(program.rule_ids.reward_point_amount, 1)
self.assertEqual(program.rule_ids.reward_point_mode, 'order')
self.assertEqual(program.rule_ids.minimum_amount, 50)
self.assertEqual(program.rule_ids.minimum_qty, 0)
self.assertTrue(program.reward_ids)
self.assertTrue(len(program.reward_ids) == 1)
self.assertEqual(program.reward_ids.required_points, 1)
self.assertEqual(program.reward_ids.discount, 10)
self.assertFalse(program.communication_plan_ids)
def test_loyalty_program_default_values_in_form(self):
# Test that the default values are correctly set when creating a new program in a form
with Form(self.env['loyalty.program']) as program_form:
program_form.name = 'Test'
program = program_form.save()
self._check_promotion_default_values(program)
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()
# Test that we can not unlink discount line product id
with mute_logger('odoo.sql_db'), self.assertRaises(IntegrityError):
self.program.reward_ids.discount_line_product_id.unlink()
def test_loyalty_mail(self):
# Test basic loyalty_mail functionalities
@ -137,30 +174,42 @@ class TestLoyalty(TransactionCase):
],
})
before_archived_reward_ids = self.program.reward_ids
self.program.toggle_active()
self.program.toggle_active()
self.program.action_archive()
self.program.action_unarchive()
after_archived_reward_ids = self.program.reward_ids
self.assertEqual(before_archived_reward_ids, after_archived_reward_ids)
def test_prevent_archive_pricelist_linked_to_program(self):
self.program.pricelist_ids = demo_pricelist = self.env['product.pricelist'].create({
'name': "Demo"
})
with self.assertRaises(UserError):
demo_pricelist.action_archive()
self.program.action_archive()
demo_pricelist.action_archive()
def test_prevent_archiving_product_linked_to_active_loyalty_reward(self):
self.program.program_type = 'promotion'
self.program.flush_recordset()
product = self.product
reward = self.env['loyalty.reward'].create({
'program_id': self.program.id,
'discount_line_product_id': product.id,
'discount_line_product_id': self.product.id,
})
self.program.write({
'reward_ids': [Command.link(reward.id)],
})
with self.assertRaises(ValidationError):
product.action_archive()
self.product.action_archive()
self.program.action_archive()
product.action_archive()
self.product.action_archive()
def test_prevent_archiving_product_used_for_discount_reward(self):
self.program.program_type = 'promotion'
"""
Ensure products cannot be archived while they have a specific program active.
"""
self.program.write({
'name': f"50% Discount on {self.product.name}",
'program_type': 'promotion',
'reward_ids': [Command.create({
'discount': 50.0,
'discount_applicability': 'specific',
@ -171,6 +220,7 @@ class TestLoyalty(TransactionCase):
self.product.action_archive()
self.program.action_archive()
self.product.action_archive()
self.assertFalse(self.product.active)
def test_prevent_archiving_product_when_archiving_program(self):
"""
@ -178,21 +228,66 @@ class TestLoyalty(TransactionCase):
We just have to archive the free product that has been created while creating
the program itself not the product we already had before.
"""
product = self.product
loyalty_program = self.env['loyalty.program'].create({
'name': 'Test Program',
'program_type': 'buy_x_get_y',
'reward_ids': [
Command.create({
'description': 'Test Product',
'reward_product_id': product.id,
'reward_product_id': self.product.id,
'reward_type': 'product'
}),
],
})
loyalty_program.action_archive()
# Make sure that the main product didn't get archived
self.assertTrue(product.active)
self.assertTrue(self.product.active)
def test_merge_loyalty_cards(self):
"""Test merging nominative loyalty cards from source partners to a destination partner
when partners are merged.
"""
program = self.env['loyalty.program'].create({
'name': 'Test Program',
'is_nominative': True,
'applies_on': 'both',
})
partner_1, partner_2, dest_partner = self.env['res.partner'].create([
{'name': 'Source Partner 1'},
{'name': 'Source Partner 2'},
{'name': 'Destination Partner'},
])
self.env['loyalty.card'].create([
{
'partner_id': partner_1.id,
'program_id': program.id,
'points': 10
}, {
'partner_id': partner_2.id,
'program_id': program.id,
'points': 20
}, {
'partner_id': dest_partner.id,
'program_id': program.id,
'points': 30
}
])
self.env['base.partner.merge.automatic.wizard']._merge(
[partner_1.id, partner_2.id, dest_partner.id], dest_partner
)
dest_partner_loyalty_cards = self.env['loyalty.card'].search([
('partner_id', '=', dest_partner.id),
('program_id', '=', program.id),
])
self.assertEqual(len(dest_partner_loyalty_cards), 1)
self.assertEqual(dest_partner_loyalty_cards.points, 60)
self.assertFalse(self.env['loyalty.card'].search([
('partner_id', 'in', [partner_1.id, partner_2.id]),
]))
def test_card_description_on_tag_change(self):
product_tag = self.env['product.tag'].create({'name': 'Multiple Products'})
@ -200,7 +295,6 @@ class TestLoyalty(TransactionCase):
product1.product_tag_ids = product_tag
self.env['product.product'].create({
'name': 'Test Product 2',
'detailed_type': 'consu',
'list_price': 30.0,
'product_tag_ids': product_tag,
})
@ -222,3 +316,25 @@ class TestLoyalty(TransactionCase):
"Free Product - [Test Product, Test Product 2]",
"Reward description for reward with tag should be 'Free Product - [Test Product, Test Product 2]'"
)
def test_prevent_unarchive_when_conflicting_active_program_exists(self):
"""Unarchiving a program should fail if another active program already has the same rule
code."""
program = self.create_program_with_code("FREE")
program.action_archive()
# create another active program with the same rule code
self.create_program_with_code("FREE")
# attempt to unarchive the first program
with self.assertRaises(ValidationError):
program.action_unarchive()
def test_prevent_unarchive_when_batch_contains_duplicate_codes(self):
"""Unarchiving multiple programs at once should fail if they share the same rule code."""
program1 = self.create_program_with_code("FREE")
program1.action_archive()
# create another program with the same rule code and archive it
program2 = self.create_program_with_code("FREE")
program2.action_archive()
# attempt to unarchive both programs together
with self.assertRaises(ValidationError):
(program1 + program2).action_unarchive()

View file

@ -11,26 +11,40 @@
<field name="code" readonly="1"/>
<field name="expiration_date"/>
<field name="partner_id"/>
<label string="Balance" for="points"/>
<span class="d-inline-block">
<field name="points" class="w-auto oe_inline me-1"/>
<field name="point_name" no_label="1" class="d-inline"/>
</span>
<label string="Balance" for="points_display"/>
<button
name="action_loyalty_update_balance"
class="p-0 text-info fw-normal"
type="object"
>
<field name="points_display" nolabel="1"/>
</button>
</group>
</group>
<notebook invisible="not id">
<page string="History Lines">
<field name="history_ids">
<list>
<field name="description"/>
<field name="order_id"/>
<field name="create_date" string="Date"/>
<field name="issued"/>
<field name="used"/>
</list>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_ids"/>
</div>
<chatter/>
</form>
</field>
</record>
<record id="loyalty_card_view_tree" model="ir.ui.view">
<field name="name">loyalty.card.view.tree</field>
<field name="name">loyalty.card.view.list</field>
<field name="model">loyalty.card</field>
<field name="arch" type="xml">
<tree string="Coupons" edit="false" delete="false" js_class="loyalty_card_list_view">
<list string="Coupons" edit="false" delete="false" js_class="loyalty_card_list_view">
<field name="code" readonly="1"/>
<field name="create_date" optional="hide"/>
<field name="points_display" string="Balance"/>
@ -38,7 +52,7 @@
<field name="program_id"/>
<field name="partner_id"/>
<button name="action_coupon_send" string="Send" type="object" icon="fa-paper-plane-o"/>
</tree>
</list>
</field>
</record>
@ -51,8 +65,23 @@
<field name="partner_id"/>
<field name="program_id"/>
<separator/>
<filter name="active" string="Active" domain="['&amp;', ('points', '>', 0), '|', ('expiration_date', '>=', context_today().strftime('%Y-%m-%d 00:00:00')), ('expiration_date', '=', False)]"/>
<filter name="inactive" string="Inactive" domain="['|', ('points', '&lt;=', 0), ('expiration_date', '&lt;', context_today().strftime('%Y-%m-%d 23:59:59'))]"/>
<filter
name="active"
string="Active"
domain="[
'&amp;', ('active', '=', True),
'&amp;', ('program_id.active', '=', True),
'|', ('expiration_date', '>=', 'today'), ('expiration_date', '=', False)
]"
/>
<filter
name="inactive"
string="Inactive"
domain="[
'|', ('active', '=', False),
'|', ('program_id.active', '=', False), ('expiration_date', '&lt;', 'today')
]"
/>
</search>
</field>
</record>
@ -60,7 +89,7 @@
<record id="loyalty_card_action" model="ir.actions.act_window">
<field name="name">Coupons</field>
<field name="res_model">loyalty.card</field>
<field name="view_mode">tree,form</field>
<field name="view_mode">list,form</field>
<field name="domain">[('program_id', '=', active_id)]</field>
<field name="context">{'create': False}</field>
<field name="help" type="html">

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="loyalty_history_form" model="ir.ui.view">
<field name="name">loyalty.history.view.form</field>
<field name="model">loyalty.history</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="card_id"/>
<field name="description"/>
<field name="order_id" invisible="not order_id"/>
<field name="order_model" invisible="True"/>
</group>
<group>
<field name="issued"/>
<field name="used"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
</odoo>

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