mirror of
https://github.com/pretix/pretix.git
synced 2026-05-07 15:34:02 +00:00
Discounts: Allow "buy X to get Y" with different product sets for X and Y (#3543)
This commit is contained in:
34
src/pretix/base/migrations/0245_discount_benefit_products.py
Normal file
34
src/pretix/base/migrations/0245_discount_benefit_products.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 4.2.4 on 2023-08-28 12:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("pretixbase", "0244_mediumkeyset"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="benefit_apply_to_addons",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="benefit_ignore_voucher_discounted",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="benefit_limit_products",
|
||||
field=models.ManyToManyField(
|
||||
related_name="benefit_discounts", to="pretixbase.item"
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="discount",
|
||||
name="benefit_same_products",
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
]
|
||||
@@ -99,7 +99,7 @@ class Discount(LoggedModel):
|
||||
)
|
||||
condition_apply_to_addons = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Apply to add-on products"),
|
||||
verbose_name=_("Count add-on products"),
|
||||
help_text=_("Discounts never apply to bundled products"),
|
||||
)
|
||||
condition_ignore_voucher_discounted = models.BooleanField(
|
||||
@@ -107,7 +107,7 @@ class Discount(LoggedModel):
|
||||
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 receive the discount."),
|
||||
"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'),
|
||||
@@ -120,6 +120,19 @@ class Discount(LoggedModel):
|
||||
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,
|
||||
@@ -139,6 +152,18 @@ class Discount(LoggedModel):
|
||||
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
|
||||
@@ -187,6 +212,14 @@ class Discount(LoggedModel):
|
||||
'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()
|
||||
|
||||
@@ -197,6 +230,7 @@ class Discount(LoggedModel):
|
||||
'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:
|
||||
@@ -207,14 +241,14 @@ class Discount(LoggedModel):
|
||||
return False
|
||||
return True
|
||||
|
||||
def _apply_min_value(self, positions, idx_group, result):
|
||||
if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value:
|
||||
def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result):
|
||||
if self.condition_min_value and sum(positions[idx][2] 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 idx_group:
|
||||
for idx in benefit_idx_group:
|
||||
previous_price = positions[idx][2]
|
||||
new_price = round_decimal(
|
||||
previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'),
|
||||
@@ -222,8 +256,8 @@ class Discount(LoggedModel):
|
||||
)
|
||||
result[idx] = new_price
|
||||
|
||||
def _apply_min_count(self, positions, idx_group, result):
|
||||
if len(idx_group) < self.condition_min_count:
|
||||
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result):
|
||||
if len(condition_idx_group) < self.condition_min_count:
|
||||
return
|
||||
|
||||
if not self.condition_min_count or self.condition_min_value:
|
||||
@@ -233,15 +267,17 @@ class Discount(LoggedModel):
|
||||
if not self.condition_min_count:
|
||||
raise ValueError('Validation invariant violated.')
|
||||
|
||||
idx_group = sorted(idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
|
||||
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
|
||||
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price
|
||||
|
||||
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
|
||||
# want to match multiples of 3
|
||||
consume_idx = idx_group[:len(idx_group) // self.condition_min_count * self.condition_min_count]
|
||||
benefit_idx = idx_group[:len(idx_group) // self.condition_min_count * self.benefit_only_apply_to_cheapest_n_matches]
|
||||
n_groups = min(len(condition_idx_group) // self.condition_min_count, len(benefit_idx_group))
|
||||
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]
|
||||
else:
|
||||
consume_idx = idx_group
|
||||
benefit_idx = idx_group
|
||||
consume_idx = condition_idx_group
|
||||
benefit_idx = benefit_idx_group
|
||||
|
||||
for idx in benefit_idx:
|
||||
previous_price = positions[idx][2]
|
||||
@@ -276,7 +312,7 @@ class Discount(LoggedModel):
|
||||
limit_products = {p.pk for p in self.condition_limit_products.all()}
|
||||
|
||||
# First, filter out everything not even covered by our product scope
|
||||
initial_candidates = [
|
||||
condition_candidates = [
|
||||
idx
|
||||
for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items()
|
||||
if (
|
||||
@@ -286,11 +322,25 @@ class Discount(LoggedModel):
|
||||
)
|
||||
]
|
||||
|
||||
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, initial_candidates, result)
|
||||
self._apply_min_count(positions, condition_candidates, benefit_candidates, result)
|
||||
else:
|
||||
self._apply_min_value(positions, initial_candidates, result)
|
||||
self._apply_min_value(positions, condition_candidates, benefit_candidates, result)
|
||||
|
||||
elif self.subevent_mode == self.SUBEVENT_MODE_SAME:
|
||||
def key(idx):
|
||||
@@ -299,17 +349,18 @@ class Discount(LoggedModel):
|
||||
# Build groups of candidates with the same subevent, then apply our regular algorithm
|
||||
# to each group
|
||||
|
||||
_groups = groupby(sorted(initial_candidates, key=key), key=key)
|
||||
candidate_groups = [list(g) for k, g in _groups]
|
||||
_groups = groupby(sorted(condition_candidates, key=key), key=key)
|
||||
candidate_groups = [(k, list(g)) for k, g in _groups]
|
||||
|
||||
for g in candidate_groups:
|
||||
for subevent_id, g in candidate_groups:
|
||||
benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id]
|
||||
if self.condition_min_count:
|
||||
self._apply_min_count(positions, g, result)
|
||||
self._apply_min_count(positions, g, benefit_g, result)
|
||||
else:
|
||||
self._apply_min_value(positions, g, result)
|
||||
self._apply_min_value(positions, g, benefit_g, result)
|
||||
|
||||
elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT:
|
||||
if self.condition_min_value:
|
||||
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
|
||||
@@ -336,7 +387,7 @@ class Discount(LoggedModel):
|
||||
candidates = []
|
||||
cardinality = None
|
||||
for se, l in subevent_to_idx.items():
|
||||
l = [ll for ll in l if ll in initial_candidates and ll not in current_group]
|
||||
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][1] for idx in current_group}:
|
||||
@@ -373,5 +424,5 @@ class Discount(LoggedModel):
|
||||
break
|
||||
|
||||
for g in candidate_groups:
|
||||
self._apply_min_count(positions, g, result)
|
||||
self._apply_min_count(positions, g, g, result)
|
||||
return result
|
||||
|
||||
@@ -907,14 +907,18 @@ class Event(EventMixin, LoggedModel):
|
||||
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
|
||||
|
||||
for d in Discount.objects.filter(event=other).prefetch_related('condition_limit_products'):
|
||||
items = list(d.condition_limit_products.all())
|
||||
c_items = list(d.condition_limit_products.all())
|
||||
b_items = list(d.benefit_limit_products.all())
|
||||
d.pk = None
|
||||
d.event = self
|
||||
d.save(force_insert=True)
|
||||
d.log_action('pretix.object.cloned')
|
||||
for i in items:
|
||||
for i in c_items:
|
||||
if i.pk in item_map:
|
||||
d.condition_limit_products.add(item_map[i.pk])
|
||||
for i in b_items:
|
||||
if i.pk in item_map:
|
||||
d.benefit_limit_products.add(item_map[i.pk])
|
||||
|
||||
question_map = {}
|
||||
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
|
||||
|
||||
@@ -171,7 +171,7 @@ def apply_discounts(event: Event, sales_channel: str,
|
||||
Q(available_until__isnull=True) | Q(available_until__gte=now()),
|
||||
sales_channels__contains=sales_channel,
|
||||
active=True,
|
||||
).prefetch_related('condition_limit_products').order_by('position', 'pk')
|
||||
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
||||
for discount in discount_qs:
|
||||
result = discount.apply({
|
||||
idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount)
|
||||
|
||||
Reference in New Issue
Block a user