Files
pretix_cgo/src/pretix/base/models/discount.py

476 lines
22 KiB
Python

#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from collections import defaultdict, namedtuple
from decimal import Decimal
from itertools import groupby
from math import ceil, inf
from typing import Dict
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
PositionInfo = namedtuple('PositionInfo', ['item_id', 'subevent_id', 'line_price_gross', 'is_addon_to', 'voucher_discount'])
class Discount(LoggedModel):
SUBEVENT_MODE_MIXED = 'mixed'
SUBEVENT_MODE_SAME = 'same'
SUBEVENT_MODE_DISTINCT = 'distinct'
SUBEVENT_MODE_CHOICES = (
(SUBEVENT_MODE_MIXED, pgettext_lazy('subevent', 'Dates can be mixed without limitation')),
(SUBEVENT_MODE_SAME, pgettext_lazy('subevent', 'All matching products must be for the same date')),
(SUBEVENT_MODE_DISTINCT, pgettext_lazy('subevent', 'Each matching product must be for a different date')),
)
event = models.ForeignKey(
'Event',
on_delete=models.CASCADE,
related_name='discounts',
)
active = models.BooleanField(
verbose_name=_("Active"),
default=True,
)
internal_name = models.CharField(
verbose_name=_("Internal name"),
max_length=255
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
all_sales_channels = models.BooleanField(
verbose_name=_("All supported sales channels"),
default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Sales channels"),
blank=True,
)
available_from = models.DateTimeField(
verbose_name=_("Available from"),
null=True,
blank=True,
)
available_until = models.DateTimeField(
verbose_name=_("Available until"),
null=True,
blank=True,
)
subevent_mode = models.CharField(
verbose_name=_('Event series handling'),
max_length=50,
default=SUBEVENT_MODE_MIXED,
choices=SUBEVENT_MODE_CHOICES,
)
condition_all_products = models.BooleanField(
default=True,
verbose_name=_("Apply to all products (including newly created ones)")
)
condition_limit_products = models.ManyToManyField(
'Item',
verbose_name=_("Apply to specific products"),
blank=True
)
condition_apply_to_addons = models.BooleanField(
default=True,
verbose_name=_("Count add-on products"),
help_text=_("Discounts never apply to bundled products"),
)
condition_ignore_voucher_discounted = models.BooleanField(
default=False,
verbose_name=_("Ignore products discounted by a voucher"),
help_text=_("If this option is checked, products that already received a discount through a voucher will not "
"be considered for this discount. However, products that use a voucher only to e.g. unlock a "
"hidden product or gain access to sold-out quota will still be considered."),
)
condition_min_count = models.PositiveIntegerField(
verbose_name=_('Minimum number of matching products'),
default=0,
)
condition_min_value = models.DecimalField(
verbose_name=_('Minimum gross value of matching products'),
decimal_places=2,
max_digits=13,
default=Decimal('0.00'),
)
benefit_same_products = models.BooleanField(
default=True,
verbose_name=_("Apply discount to same set of products"),
help_text=_("By default, the discount is applied across the same selection of products than the condition for "
"the discount given above. If you want, you can however also select a different selection of "
"products.")
)
benefit_limit_products = models.ManyToManyField(
'Item',
verbose_name=_("Apply discount to specific products"),
related_name='benefit_discounts',
blank=True
)
benefit_discount_matching_percent = models.DecimalField(
verbose_name=_('Percentual discount on matching products'),
decimal_places=2,
max_digits=10,
default=Decimal('0.00'),
validators=[MinValueValidator(Decimal('0.00'))],
)
benefit_only_apply_to_cheapest_n_matches = models.PositiveIntegerField(
verbose_name=_('Apply discount only to this number of matching products'),
help_text=_(
'This option allows you to create discounts of the type "buy X get Y reduced/for free". For example, if '
'you set "Minimum number of matching products" to four and this value to two, the customer\'s cart will be '
'split into groups of four tickets and the cheapest two tickets within every group will be discounted. If '
'you want to grant the discount on all matching products, keep this field empty.'
),
null=True,
blank=True,
validators=[MinValueValidator(1)],
)
benefit_apply_to_addons = models.BooleanField(
default=True,
verbose_name=_("Apply to add-on products"),
help_text=_("Discounts never apply to bundled products"),
)
benefit_ignore_voucher_discounted = models.BooleanField(
default=False,
verbose_name=_("Ignore products discounted by a voucher"),
help_text=_("If this option is checked, products that already received a discount through a voucher will not "
"be discounted. However, products that use a voucher only to e.g. unlock a hidden product or gain "
"access to sold-out quota will still receive the discount."),
)
# more feature ideas:
# - max_usages_per_order
# - promote_to_user_if_almost_satisfied
# - require_customer_account
objects = ScopedManager(organizer='event__organizer')
class Meta:
ordering = ('position', 'id')
def __str__(self):
return self.internal_name
@property
def sortkey(self):
return self.position, self.id
def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey
@classmethod
def validate_config(cls, data):
# We forbid a few combinations of settings, because we don't think they are neccessary and at the same
# time they introduce edge cases, in which it becomes almost impossible to compute the discount optimally
# and also very hard to understand for the user what is going on.
if data.get('condition_min_count') and data.get('condition_min_value'):
raise ValidationError(
_('You can either set a minimum number of matching products or a minimum value, not both.')
)
if not data.get('condition_min_count') and not data.get('condition_min_value'):
raise ValidationError(
_('You need to either set a minimum number of matching products or a minimum value.')
)
if data.get('condition_min_value') and data.get('benefit_only_apply_to_cheapest_n_matches'):
raise ValidationError(
_('You cannot apply the discount only to some of the matched products if you are matching '
'on a minimum value.')
)
if data.get('subevent_mode') == cls.SUBEVENT_MODE_DISTINCT and data.get('condition_min_value'):
raise ValidationError(
_('You cannot apply the discount only to bookings of different dates if you are matching '
'on a minimum value.')
)
if data.get('subevent_mode') == cls.SUBEVENT_MODE_DISTINCT and not data.get('benefit_same_products'):
raise ValidationError(
{'benefit_same_products': [
_('You cannot apply the discount to a different set of products if the discount is only valid '
'for bookings of different dates.')
]}
)
def allow_delete(self):
return not self.orderposition_set.exists()
def clean(self):
super().clean()
Discount.validate_config({
'condition_min_count': self.condition_min_count,
'condition_min_value': self.condition_min_value,
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
'subevent_mode': self.subevent_mode,
'benefit_same_products': self.benefit_same_products,
})
def is_available_by_time(self, now_dt=None) -> bool:
now_dt = now_dt or now()
if self.available_from and self.available_from > now_dt:
return False
if self.available_until and self.available_until < now_dt:
return False
return True
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
if self.condition_min_value and sum(positions[idx].line_price_gross for idx in condition_idx_group) < self.condition_min_value:
return
if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches:
raise ValueError('Validation invariant violated.')
for idx in benefit_idx_group:
previous_price = positions[idx].line_price_gross
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
if collect_potential_discounts is not None:
for idx in condition_idx_group:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
if len(condition_idx_group) < self.condition_min_count:
return
if not self.condition_min_count or self.condition_min_value:
raise ValueError('Validation invariant violated.')
if self.benefit_only_apply_to_cheapest_n_matches:
# sort by line_price
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx))
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3
# how many discount applications are allowed according to condition products in cart
possible_applications_cond = len(condition_idx_group) // self.condition_min_count
# how many discount applications are possible according to benefitting products in cart
possible_applications_benefit = ceil(len(benefit_idx_group) / self.benefit_only_apply_to_cheapest_n_matches)
n_groups = min(possible_applications_cond, possible_applications_benefit)
consume_idx = condition_idx_group[:n_groups * self.condition_min_count]
benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches]
if collect_potential_discounts is not None:
if n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
# partially used discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
# but only 1 t-shirt) -> 1 shirt definitiv potential discount
for idx in consume_idx:
collect_potential_discounts[idx] = [
(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1, subevent_id)
]
if possible_applications_cond * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
# unused discount ("for each 1 ticket you buy, get 50% on 2 t-shirts", cart content: 1 ticket
# but 0 t-shirts) -> 2 shirt maybe potential discount (if the 1 ticket is not consumed by a later discount)
for i, idx in enumerate(condition_idx_group[
n_groups * self.condition_min_count:
possible_applications_cond * self.condition_min_count
]):
collect_potential_discounts[idx] += [
(self, self.benefit_only_apply_to_cheapest_n_matches, i // self.condition_min_count, subevent_id)
]
else:
consume_idx = condition_idx_group
benefit_idx = benefit_idx_group
if collect_potential_discounts is not None:
for idx in consume_idx:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
for idx in benefit_idx:
previous_price = positions[idx].line_price_gross
new_price = round_decimal(
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
self.event.currency,
)
result[idx] = new_price
for idx in consume_idx:
result.setdefault(idx, positions[idx].line_price_gross)
def apply(self, positions: Dict[int, PositionInfo],
collect_potential_discounts=None) -> Dict[int, Decimal]:
"""
Tries to apply this discount to a cart
:param positions: Dictionary mapping IDs to PositionInfo tuples.
Bundled positions may not be included.
:param collect_potential_discounts: For detailed description, see pretix.base.services.pricing.apply_discounts
:return: A dictionary mapping keys from the input dictionary to new prices. All positions
contained in this dictionary are considered "consumed" and should not be considered
by other discounts.
"""
result = {}
if not self.active:
return result
limit_products = set()
if not self.condition_all_products:
limit_products = {p.pk for p in self.condition_limit_products.all()}
# First, filter out everything not even covered by our product scope
condition_candidates = [
idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
(self.condition_all_products or item_id in limit_products) and
(self.condition_apply_to_addons or not is_addon_to) and
(not self.condition_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
)
]
if self.benefit_same_products:
benefit_candidates = list(condition_candidates)
else:
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
benefit_candidates = [
idx
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
if (
item_id in benefit_products and
(self.benefit_apply_to_addons or not is_addon_to) and
(not self.benefit_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
)
]
if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events
if self.condition_min_count:
self._apply_min_count(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None)
else:
self._apply_min_value(positions, condition_candidates, benefit_candidates, result, collect_potential_discounts, None)
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
def key(idx):
return positions[idx].subevent_id or 0
# Build groups of candidates with the same subevent, then apply our regular algorithm
# to each group
_groups = groupby(sorted(condition_candidates, key=key), key=key)
candidate_groups = [(k, list(g)) for k, g in _groups]
for subevent_id, g in candidate_groups:
benefit_g = [idx for idx in benefit_candidates if positions[idx].subevent_id == subevent_id]
if self.condition_min_count:
self._apply_min_count(positions, g, benefit_g, result, collect_potential_discounts, subevent_id)
else:
self._apply_min_value(positions, g, benefit_g, result, collect_potential_discounts, subevent_id)
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
if self.condition_min_value or not self.benefit_same_products:
raise ValueError('Validation invariant violated.')
# Build optimal groups of candidates with distinct subevents, then apply our regular algorithm
# to each group. Optimal, in this case, means:
# - First try to build as many groups of size condition_min_count as possible while trying to
# balance out the cheapest products so that they are not all in the same group
# - Then add remaining positions to existing groups if possible
candidate_groups = []
# Build a list of subevent IDs in descending order of frequency
subevent_to_idx = defaultdict(list)
for idx, p in positions.items():
subevent_to_idx[p.subevent_id].append(idx)
for v in subevent_to_idx.values():
v.sort(key=lambda idx: positions[idx].line_price_gross)
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents
current_group = []
while True:
# Build a list of candidates, which is a list of all positions belonging to a subevent of the
# maximum cardinality, where the cardinality of a subevent is defined as the number of tickets
# for that subevent that are not yet part of any group
candidates = []
cardinality = None
for se, l in subevent_to_idx.items():
l = [ll for ll in l if ll in condition_candidates and ll not in current_group]
if cardinality and len(l) != cardinality:
continue
if se not in {positions[idx].subevent_id for idx in current_group}:
candidates += l
cardinality = len(l)
if not candidates:
break
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross)
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0]
else:
candidate = candidates[-1]
current_group.append(candidate)
# Only add full groups to the list of groups
if len(current_group) >= max(self.condition_min_count, 1):
candidate_groups.append(current_group)
for c in current_group:
subevent_to_idx[positions[c].subevent_id].remove(c)
current_group = []
# Distribute "leftovers"
for se in subevent_order:
if subevent_to_idx[se]:
for group in candidate_groups:
if se not in {positions[idx].subevent_id for idx in group}:
group.append(subevent_to_idx[se].pop())
if not subevent_to_idx[se]:
break
for g in candidate_groups:
self._apply_min_count(
positions,
[idx for idx in g if idx in condition_candidates],
[idx for idx in g if idx in benefit_candidates],
result,
collect_potential_discounts
)
return result