19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:12 +01:00
parent 79f83631d5
commit 73afc09215
6267 changed files with 1534193 additions and 1130106 deletions

View file

@ -4,5 +4,6 @@
from . import loyalty_card
from . import loyalty_program
from . import loyalty_rule
from . import product_product
from . import sale_order_line
from . import sale_order

View file

@ -1,8 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class LoyaltyCard(models.Model):
_inherit = 'loyalty.card'

View file

@ -1,13 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import api, fields, models
class LoyaltyProgram(models.Model):
_name = 'loyalty.program'
_inherit = ['loyalty.program', 'website.multi.mixin']
ecommerce_ok = fields.Boolean("Available on Website", default=True)
show_non_published_product_warning = fields.Boolean(compute='_compute_show_non_published_product_warning')
@api.depends('program_type', 'trigger_product_ids.website_published')
def _compute_show_non_published_product_warning(self):
for program in self:
program.show_non_published_product_warning = (
program.program_type == 'ewallet'
and any(not product.website_published for product in program.trigger_product_ids)
)
def action_program_share(self):
self.ensure_one()

View file

@ -1,25 +1,27 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
class LoyaltyRule(models.Model):
_inherit = 'loyalty.rule'
website_id = fields.Many2one(related='program_id.website_id', store=True)
# NOTE: is this sufficient?
@api.constrains('code', 'website_id')
@api.constrains('code', 'website_id', 'active')
def _constrains_code(self):
#Programs with the same code are allowed to coexist as long
# as they are not both accessible from a website.
with_code = self.filtered(lambda r: r.mode == 'with_code')
with_code = self.filtered(lambda r: r.mode == 'with_code' and r.active)
mapped_codes = with_code.mapped('code')
read_result = self.env['loyalty.rule'].search_read(
[('website_id', 'in', [False] + [w.id for w in self.website_id]),
('mode', '=', 'with_code'), ('code', 'in', mapped_codes),
('id', 'not in', with_code.ids)],
('mode', '=', 'with_code'),
('code', 'in', mapped_codes),
('id', 'not in', with_code.ids),
('active', '=', True)],
fields=['code', 'website_id']) + [{'code': p.code, 'website_id': p.website_id} for p in with_code]
existing_codes = set()
for res in read_result:
@ -30,5 +32,7 @@ class LoyaltyRule(models.Model):
raise ValidationError(_('The promo code must be unique.'))
existing_codes.add(val)
# Prevent coupons and programs from sharing a code
if self.env['loyalty.card'].search_count([('code', 'in', mapped_codes)]):
if self.env['loyalty.card'].search_count([
('code', 'in', mapped_codes), ('active', '=', True)
]):
raise ValidationError(_('A coupon with the same code was found.'))

View file

@ -0,0 +1,41 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class ProductProduct(models.Model):
_inherit = 'product.product'
def _can_return_content(self, field_name=None, access_token=None):
""" Override of `orm` to give public users access to the unpublished product image.
Give access to the public users to the unpublished product images if they are linked to a
reward.
:param field_name: The name of the field to check.
:param access_token: The access token.
:return: Whether to allow the access to the image.
:rtype: bool
"""
if (
field_name in ["image_%s" % size for size in [1920, 1024, 512, 256, 128]]
and self.env['loyalty.reward'].sudo().search_count([
('discount_line_product_id', '=', self.id),
], limit=1)
):
return True
return super()._can_return_content(field_name, access_token)
def _get_product_placeholder_filename(self):
""" Override of `product` to set a default image for reward products. """
# In sudo mode to allow eCommerce customers to see the placeholder
if self.env['loyalty.reward'].sudo().search_count([
('discount_line_product_id', '=', self.id),
], limit=1):
if self.env['loyalty.reward'].sudo().search_count([
('program_type', '=', 'gift_card'),
('discount_line_product_id', '=', self.id),
], limit=1):
return 'loyalty/static/img/gift_card.png'
return 'loyalty/static/img/discount_placeholder_thumbnail.png'
return super()._get_product_placeholder_filename()

View file

@ -1,15 +1,16 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from datetime import timedelta
from odoo import api, fields, models
from odoo.exceptions import UserError
from odoo.osv import expression
from odoo.fields import Domain
from odoo.http import request
class SaleOrder(models.Model):
_inherit = "sale.order"
_inherit = 'sale.order'
# List of disabled rewards for automatic claim
disabled_auto_rewards = fields.Many2many("loyalty.reward", relation="sale_order_disabled_auto_rewards_rel")
@ -22,7 +23,7 @@ class SaleOrder(models.Model):
if leaf[0] != 'sale_ok':
continue
res[idx] = ('ecommerce_ok', '=', True)
return expression.AND([res, [('website_id', 'in', (self.website_id.id, False))]])
return Domain.AND([res, [('website_id', 'in', (self.website_id.id, False))]])
return res
def _get_trigger_domain(self):
@ -33,9 +34,12 @@ class SaleOrder(models.Model):
if leaf[0] != 'program_id.sale_ok':
continue
res[idx] = ('program_id.ecommerce_ok', '=', True)
return expression.AND([res, [('program_id.website_id', 'in', (self.website_id.id, False))]])
return Domain.AND([res, [('program_id.website_id', 'in', (self.website_id.id, False))]])
return res
def _get_program_timezone(self):
return self.website_id.salesperson_id.tz or super()._get_program_timezone()
def _try_pending_coupon(self):
if not request:
return False
@ -73,12 +77,15 @@ class SaleOrder(models.Model):
claimed_reward_count = 0
claimable_rewards = self._get_claimable_rewards()
for coupon, rewards in claimable_rewards.items():
if len(coupon.program_id.reward_ids) != 1 or\
coupon.program_id.is_nominative or\
(rewards.reward_type == 'product' and rewards.multi_product) or\
rewards in self.disabled_auto_rewards or\
rewards in self.order_line.reward_id:
if (
len(coupon.program_id.reward_ids) != 1
or coupon.program_id.is_nominative
or (rewards.reward_type == 'product' and rewards.multi_product)
or rewards in self.disabled_auto_rewards
or rewards in self.order_line.reward_id
):
continue
try:
res = self._apply_program_reward(rewards, coupon)
if 'error' not in res:
@ -95,7 +102,7 @@ class SaleOrder(models.Model):
products with different taxes.
In this case, each taxes will have their own discount line. This is required
to have correct amount of taxes according to the discount.
But we wan't these lines to be `visually` merged into a single one in the
But we want these lines to be `visually` merged into a single one in the
e-commerce since the end user should only see one discount line.
This is only possible since we don't show taxes in cart.
eg:
@ -120,14 +127,14 @@ class SaleOrder(models.Model):
continue
new_lines += self.env['sale.order.line'].new({
'product_id': lines[0].product_id.id,
'tax_id': False,
'tax_ids': False,
'price_unit': sum(lines.mapped('price_unit')),
'price_subtotal': sum(lines.mapped('price_subtotal')),
'price_total': sum(lines.mapped('price_total')),
'discount': 0.0,
'name': lines[0].name_short if lines.reward_id.reward_type != 'product' else lines[0].name,
'product_uom_qty': 1,
'product_uom': lines[0].product_uom.id,
'product_uom_id': lines[0].product_uom_id.id,
'order_id': order.id,
'is_reward_line': True,
'coupon_id': lines.coupon_id,
@ -156,20 +163,36 @@ class SaleOrder(models.Model):
request.session.pop('successful_code')
return code
def _cart_update(self, product_id, line_id=None, add_qty=0, set_qty=0, **kwargs):
def _set_delivery_method(self, *args, **kwargs):
super()._set_delivery_method(*args, **kwargs)
self._update_programs_and_rewards()
line = self.order_line.filtered(lambda sol: sol.product_id.id == product_id)[:1]
reward_id = line.reward_id
if set_qty == 0 and line.coupon_id and reward_id and reward_id.reward_type == 'discount':
# Force the deletion of the line even if it's a temporary record created by new()
line_id = line.id
def _remove_delivery_line(self):
super()._remove_delivery_line()
self._update_programs_and_rewards()
res = super()._cart_update(
product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty, **kwargs
)
def _cart_update_order_line(self, order_line, quantity, **kwargs):
if (
quantity <= 0
and order_line.coupon_id
and order_line.reward_id
and order_line.reward_id.reward_type == 'discount'
):
# When a reward line is deleted we remove it from the auto claimable rewards
order_line = order_line.with_context(website_sale_loyalty_delete=True)
return super()._cart_update_order_line(order_line, quantity, **kwargs)
def _verify_cart_after_update(self):
super()._verify_cart_after_update()
self._update_programs_and_rewards()
self._auto_apply_rewards()
return res
if request: # In case the rewards application modifies the cart quantity
request.session['website_sale_cart_quantity'] = self.cart_quantity
def _get_non_delivery_lines(self):
"""Override of `website_sale` to exclude delivery reward lines."""
return super()._get_non_delivery_lines() - self._get_free_shipping_lines()
def _get_free_shipping_lines(self):
self.ensure_one()
@ -185,7 +208,7 @@ class SaleOrder(models.Model):
"""Remove coupons from abandonned ecommerce order."""
ICP = self.env['ir.config_parameter']
validity = ICP.get_param('website_sale_coupon.abandonned_coupon_validity', 4)
validity = fields.Datetime.to_string(fields.datetime.now() - timedelta(days=int(validity)))
validity = fields.Datetime.to_string(fields.Datetime.now() - timedelta(days=int(validity)))
so_to_reset = self.env['sale.order'].search([
('state', '=', 'draft'),
('write_date', '<', validity),
@ -196,20 +219,47 @@ class SaleOrder(models.Model):
for so in so_to_reset:
so._update_programs_and_rewards()
def _get_website_sale_extra_values(self):
promo_code_success = self.get_promo_code_success_message(delete=False)
promo_code_error = self.get_promo_code_error(delete=False)
def _get_claimable_and_showable_rewards(self):
self.ensure_one()
res = self._get_claimable_rewards()
loyality_cards = self.env['loyalty.card'].search([
('partner_id', '=', self.partner_id.id),
('program_id', 'any', self._get_program_domain()),
'|',
('program_id.trigger', '=', 'with_code'),
'&', ('program_id.trigger', '=', 'auto'), ('program_id.applies_on', '=', 'future'),
])
total_is_zero = self.currency_id.is_zero(self.amount_total)
global_discount_reward = self._get_applied_global_discount()
for coupon in loyality_cards:
points = self._get_real_points_for_coupon(coupon)
for reward in coupon.program_id.reward_ids - self.order_line.reward_id:
if (
reward.is_global_discount
and global_discount_reward
and self._best_global_discount_already_applied(global_discount_reward, reward)
):
continue
if reward.reward_type == 'discount' and total_is_zero:
continue
if coupon.expiration_date and coupon.expiration_date < fields.Date.today():
continue
if points >= reward.required_points:
if coupon in res:
res[coupon] |= reward
else:
res[coupon] = reward
return res
return {
'promo_code_success': promo_code_success,
'promo_code_error': promo_code_error,
}
def _cart_find_product_line(self, *args, **kwargs):
# Filter out reward lines, they shouldn't be modified by standard _cart_add logic.
# This kind of lines is handled by _update_programs_and_rewards and _auto_apply_rewards.
return super()._cart_find_product_line(*args, **kwargs).filtered(
lambda sol: not sol.is_reward_line
)
def _cart_find_product_line(self, product_id, line_id=None, **kwargs):
""" Override to filter out reward lines from the cart lines.
These are handled by the _update_programs_and_rewards and _auto_apply_rewards methods.
"""
lines = super()._cart_find_product_line(product_id, line_id, **kwargs)
lines = lines.filtered(lambda l: not l.is_reward_line) if not line_id else lines
return lines
def _recompute_cart(self):
"""Recompute cart with loyalty programs and rewards applied."""
self._update_programs_and_rewards()
self._auto_apply_rewards()
super()._recompute_cart()

View file

@ -1,16 +1,26 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import models
class SaleOrderLine(models.Model):
_inherit = 'sale.order.line'
def _get_line_header(self):
if self.is_reward_line:
return self.name
return super()._get_line_header()
def _show_in_cart(self):
# Hide discount lines from website_order_line, see `order._compute_website_order_line`
return self.reward_id.reward_type != 'discount' and super()._show_in_cart()
def _is_reorder_allowed(self):
# Hide all types of rewards from reorder
return not self.reward_id and super()._is_reorder_allowed()
def unlink(self):
if self.env.context.get('website_sale_loyalty_delete', False):
disabled_rewards_per_order = defaultdict(lambda: self.env['loyalty.reward'])
@ -20,3 +30,17 @@ class SaleOrderLine(models.Model):
for order, rewards in disabled_rewards_per_order.items():
order.disabled_auto_rewards += rewards
return super().unlink()
def _should_show_strikethrough_price(self):
""" Override of `website_sale` to hide the strikethrough price for rewards. """
return super()._should_show_strikethrough_price() and not self.is_reward_line
def _is_sellable(self):
"""Override of `website_sale` to flag reward lines as not sellable.
:return: Whether the line is sellable or not.
:rtype: bool
"""
return super()._is_sellable() and (
not self.is_reward_line or self.reward_id.reward_type == 'product'
)