Show (some) discounts in product list

Discounts are currently only shown in the cart, after products have been
selected. This was not a big problem since we invented the discount
machinery specifically for group discounts, which can only be applied in
the cart. However it *is* possible to define a Discount that applies to
a group of one. Then, one would expect that the discounted price would
be shown already on the front page of the shop.

Back then, I decided against this for complexity and performance
concerns, but after looking into it, it's not actually that bad.

On the user side, I think this is a game changer for the discount
feature. Now, I could use discount rules also to implement early-bird
pricing without duplicating producs/variations, or I can do a christmas
discount on all subevents in december without modifying all individual
subevents.
This commit is contained in:
Raphael Michel
2026-09-11 18:18:19 +02:00
parent d85e52c83e
commit 10aec6d8dc
3 changed files with 402 additions and 32 deletions
+3 -1
View File
@@ -510,7 +510,9 @@ class Item(LoggedModel):
verbose_name=_("Free price input"), verbose_name=_("Free price input"),
help_text=_("If this option is active, your users can choose the price themselves. The price configured above " help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect " "is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
"additional donations for your event.") "additional donations for your event. We recommend against combining this feature with automatic "
"discounts since discounts are applied as the last step of price computation, which means that the "
"price entered by the customer will be modified again.")
) )
free_price_suggestion = models.DecimalField( free_price_suggestion = models.DecimalField(
verbose_name=_("Suggested price"), verbose_name=_("Suggested price"),
+118 -31
View File
@@ -21,7 +21,8 @@
# #
import sys import sys
from datetime import datetime from datetime import datetime
from typing import Optional from decimal import Decimal
from typing import List, Optional, Union
from django.conf import settings from django.conf import settings
from django.db.models import ( from django.db.models import (
@@ -29,11 +30,13 @@ from django.db.models import (
) )
from django.db.models.lookups import Exact from django.db.models.lookups import Exact
from pretix.base.decimal import round_decimal
from pretix.base.models import ( from pretix.base.models import (
ItemVariation, Quota, SalesChannel, SeatCategoryMapping, Discount, Event, Item, ItemVariation, Quota, SalesChannel,
SeatCategoryMapping, SubEvent, Voucher,
) )
from pretix.base.models.items import ( from pretix.base.models.items import (
Item, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation,
) )
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
@@ -54,6 +57,35 @@ def item_group_by_category(items):
) )
def _single_item_discounts(event: Event, sales_channel: Union[str, SalesChannel],
subevent: SubEvent=None, voucher: Voucher=None, is_addons=False) -> List[Discount]:
discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=sales_channel),
active=True,
# Only discounts that can be applied before we know the full cart
benefit_same_products=True,
benefit_only_apply_to_cheapest_n_matches__isnull=True,
condition_min_value=Decimal("0.00"),
condition_min_count=1,
).prefetch_related('condition_limit_products').order_by('position', 'pk')
if subevent:
discount_qs = discount_qs.filter(
Q(subevent_date_from__isnull=True) | Q(subevent_date_from__lte=subevent.date_from),
Q(subevent_date_until__isnull=True) | Q(subevent_date_until__gte=subevent.date_from),
)
if is_addons:
discount_qs = discount_qs.filter(condition_apply_to_addons=True, benefit_apply_to_addons=True)
if voucher and voucher.price_mode != "none":
discount_qs = discount_qs.filter(condition_ignore_voucher_discounted=False, benefit_ignore_voucher_discounted=False)
return list(discount_qs)
def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None, def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None,
allow_addons=False, allow_cross_sell=False, allow_addons=False, allow_cross_sell=False,
quota_cache=None, filter_items=None, filter_categories=None, memberships=None, quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
@@ -190,6 +222,18 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
if filter_categories: if filter_categories:
items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()]) items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()])
# We pre-computate discounts that do not rely on specific combinations in the cart.
# This is not the same order of operations that is applied in the cart, so there could be some differences
# when it comes to tax rate handling, but we don't have that information in the product list anyway, so
# that is acceptable.
discounts = _single_item_discounts(
event=event,
sales_channel=channel,
voucher=voucher,
subevent=subevent,
is_addons=allow_addons,
)
display_add_to_cart = False display_add_to_cart = False
quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}' quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}'
quota_cache = quota_cache or event.cache.get(quota_cache_key) or {} quota_cache = quota_cache or event.cache.get(quota_cache_key) or {}
@@ -272,6 +316,10 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
if resp: if resp:
item.description += ("<br/>" if item.description else "") + resp item.description += ("<br/>" if item.description else "") + resp
matching_discounts = [
d for d in discounts if d.condition_all_products or item in d.condition_limit_products.all()
]
if not item.has_variations: if not item.has_variations:
item._remove = False item._remove = False
if not bool(item._subevent_quotas): if not bool(item._subevent_quotas):
@@ -299,30 +347,51 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
max_per_order max_per_order
) )
original_price = item_price_override.get(item.pk, item.default_price) configured_price = item_price_override.get(item.pk, item.default_price)
voucher_reduced = False voucher_reduced = False
if voucher: if voucher:
price = voucher.calculate_price(original_price) price = voucher.calculate_price(configured_price)
voucher_reduced = price < original_price voucher_reduced = price < configured_price
include_bundled = not voucher.all_bundles_included include_bundled = not voucher.all_bundles_included
else: else:
price = original_price price = configured_price
include_bundled = True include_bundled = True
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled) if matching_discounts and not item.free_price:
# Discounts and free prices are a non-recommended combination that behaves unintuitively, so we can
# accept it not being handled here.
discount = matching_discounts[0] # First matching discount rule always wins
# First handle taxes because discount is always computed on gross
taxed_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
price_without_bundles = taxed_price.gross - sum(b.count * b.designated_price for b in item.bundles.all())
discounted_price = round_decimal(
taxed_price.gross - price_without_bundles * discount.benefit_discount_matching_percent / Decimal('100.00'),
event.currency,
)
item.display_price = item.tax(discounted_price, currency=event.currency, include_bundled=include_bundled,
base_price_is='gross')
else:
discount = None
item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and item.free_price_suggestion is not None and not voucher_reduced: if item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled) item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled)
else: else:
item.suggested_price = item.display_price item.suggested_price = item.display_price
if price != original_price: if voucher_reduced or (discount and not item.original_price):
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True) # If a voucher is used, we use the non-voucher price as the "original price", even if a different
else: # original price is set, to highlight the voucher's impact. We also use the configured price as
item.original_price = ( # original price if a discount is applied and no explicit original price is set
item.tax(item.original_price, currency=event.currency, include_bundled=True, item.original_price = item.tax(configured_price, currency=event.currency, include_bundled=True)
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat elif item.original_price:
if item.original_price else None item.original_price = item.tax(
item.original_price, currency=event.currency, include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross' # backwards-compat
) )
else:
item.original_price = None
if not display_add_to_cart: if not display_add_to_cart:
display_add_to_cart = not item.requires_seat and item.order_max > 0 display_add_to_cart = not item.requires_seat and item.order_max > 0
else: else:
@@ -352,35 +421,53 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
max_per_order max_per_order
) )
original_price = var_price_override.get(var.pk, var.price) configured_price = var_price_override.get(var.pk, var.price)
voucher_reduced = False voucher_reduced = False
if voucher: if voucher:
price = voucher.calculate_price(original_price) price = voucher.calculate_price(configured_price)
voucher_reduced = price < original_price voucher_reduced = price < configured_price
include_bundled = not voucher.all_bundles_included include_bundled = not voucher.all_bundles_included
else: else:
price = original_price price = configured_price
include_bundled = True include_bundled = True
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled) if matching_discounts and not item.free_price:
# Discounts and free prices are a non-recommended combination that behaves unintuitively, so we can
# accept it not being handled here.
discount = matching_discounts[0] # First matching discount rule always wins
# First handle taxes because discount is always computed on gross
taxed_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
price_without_bundles = taxed_price.gross - sum(b.count * b.designated_price for b in item.bundles.all())
discounted_price = round_decimal(
taxed_price.gross - price_without_bundles * discount.benefit_discount_matching_percent / Decimal('100.00'),
event.currency,
)
var.display_price = var.tax(discounted_price, currency=event.currency,
include_bundled=include_bundled,
base_price_is='gross')
else:
discount = None
var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled)
if item.free_price and var.free_price_suggestion is not None and not voucher_reduced: if item.free_price and var.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency, var.suggested_price = var.tax(max(price, var.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled) include_bundled=include_bundled)
elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced: elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced:
var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, var.suggested_price = var.tax(max(price, item.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled) include_bundled=include_bundled)
else: else:
var.suggested_price = var.display_price var.suggested_price = var.display_price
if price != original_price: if voucher_reduced or (discount and not item.original_price and not var.original_price):
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True) var.original_price = var.tax(configured_price, currency=event.currency, include_bundled=True)
elif item.original_price or var.original_price:
var.original_price = var.tax(
var.original_price or item.original_price, currency=event.currency,
include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross' # backwards-compat
)
else: else:
var.original_price = ( var.original_price = None
var.tax(var.original_price or item.original_price, currency=event.currency,
include_bundled=True,
base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat
) if var.original_price or item.original_price else None
var.current_unavailability_reason = _get_variant_unavailability_reason(var, has_voucher=voucher, subevent=subevent) var.current_unavailability_reason = _get_variant_unavailability_reason(var, has_voucher=voucher, subevent=subevent)
+281
View File
@@ -0,0 +1,281 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix 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 datetime import UTC, datetime, timedelta
from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_scopes import scope
from pretix.base.models import Event, Organizer
from pretix.presale.productlist import prepare_item_list_for_shop
# Tests for prepare_item_list_for_shop are really incomplete since historically, most features are
# tested on the test_event or test_widget layer. We'll slowly add new tests here to test closer to the
# source.
@pytest.fixture
def event():
o = Organizer.objects.create(name='MRMCD', slug='mrmcd')
e = Event.objects.create(
organizer=o, name='MRMCD2015', slug='2015',
date_from=now(), live=True
)
with scope(organizer=o):
yield e
@pytest.fixture
def quota(event):
return event.quotas.create(name="Tickets", size=500)
@pytest.fixture
def item(event, quota):
i = event.items.create(name="Ticket", default_price=Decimal("42.00"))
quota.items.add(i)
return i
@pytest.fixture
def variation(event, quota):
i = event.items.create(name="Ticket with variants", default_price=Decimal("99.00"))
v = i.variations.create(value="Default", default_price=Decimal("42.00"))
quota.items.add(i)
quota.variations.add(v)
return v
@pytest.fixture
def discount(event):
return event.discounts.create(
internal_name="Early-Bird-Discount",
all_sales_channels=True,
available_from=now() - timedelta(days=2),
available_until=now() + timedelta(days=2),
condition_all_products=True,
condition_min_count=1,
benefit_discount_matching_percent=Decimal("10.00"),
)
@pytest.fixture
def channel(event):
return event.organizer.sales_channels.get(identifier="web")
@pytest.mark.django_db
def test_default_price(event, item, variation, channel):
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42")
assert items[1].available_variations[0].display_price.gross == Decimal("42")
def _test_no_discount(event, channel):
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42")
assert items[1].available_variations[0].display_price.gross == Decimal("42")
@pytest.mark.django_db
def test_discount_applied(event, item, variation, channel, discount):
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[0].original_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].original_price.gross == Decimal("42.00")
@pytest.mark.django_db
def test_discount_for_groups_ignored(event, item, variation, channel, discount):
discount.condition_min_count = 2
discount.save()
_test_no_discount(event, channel)
@pytest.mark.django_db
def test_discount_original_price_kept(event, item, variation, channel, discount):
item.original_price = Decimal("46.00")
item.save()
variation.original_price = Decimal("46.00")
variation.save()
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[0].original_price.gross == Decimal("46.00")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].original_price.gross == Decimal("46.00")
@pytest.mark.django_db
def test_discount_out_of_timeframe(event, item, variation, channel, discount):
discount.available_from = now() + timedelta(days=2)
discount.save()
_test_no_discount(event, channel)
@pytest.mark.django_db
def test_discount_wrong_channel(event, item, variation, channel, discount):
discount.all_sales_channels = False
discount.save()
_test_no_discount(event, channel)
@pytest.mark.django_db
def test_discount_benefits_other_products_ignored(event, item, variation, channel, discount):
discount.benefit_same_products = False
discount.save()
_test_no_discount(event, channel)
@pytest.mark.django_db
def test_discounts_for_addons(event, item, variation, channel, discount):
discount.condition_apply_to_addons = False
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, allow_addons=True)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("42.00")
discount.condition_apply_to_addons = True
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, allow_addons=True)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
@pytest.mark.django_db
def test_discounts_with_voucher(event, item, variation, channel, discount):
voucher = event.vouchers.create(code="FOO", price_mode="subtract", value=Decimal("10.00"))
voucher2 = event.vouchers.create(code="BAR")
discount.condition_ignore_voucher_discounted = True
discount.save()
item.original_price = Decimal("46.00")
item.save()
variation.item.original_price = Decimal("46.00")
variation.item.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("32.00")
assert items[0].original_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("32.00")
assert items[1].available_variations[0].original_price.gross == Decimal("42.00")
items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher2)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[0].original_price.gross == Decimal("46.00")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].original_price.gross == Decimal("46.00")
discount.condition_ignore_voucher_discounted = False
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("28.80")
assert items[0].original_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("28.80")
assert items[1].available_variations[0].original_price.gross == Decimal("42.00")
@pytest.mark.django_db
def test_discounts_for_subevent_timeframe(event, quota, item, variation, channel, discount):
event.has_subevents = True
event.save()
se = event.subevents.create(
name="Foobar", date_from=datetime(2028, 12, 27, 10, 0, 0, tzinfo=UTC)
)
quota.subevent = se
quota.save()
discount.subevent_date_from = se.date_from + timedelta(days=1)
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, subevent=se)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("42.00")
discount.subevent_date_from = se.date_from - timedelta(days=1)
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel, subevent=se)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
@pytest.mark.django_db
def test_discounts_for_products(event, quota, item, variation, channel, discount):
discount.condition_all_products = False
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("42.00")
discount.condition_limit_products.add(item)
discount.save()
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("37.80")
assert items[1].available_variations[0].display_price.gross == Decimal("37.80")
@pytest.mark.django_db
def test_discounts_for_products_tax_additive(event, quota, item, variation, channel, discount):
tr = event.tax_rules.create(rate=Decimal("19.00"), price_includes_tax=False)
item.tax_rule = tr
item.save()
variation.item.tax_rule = tr
variation.item.save()
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("44.98")
assert items[0].original_price.gross == Decimal("49.98")
assert items[0].original_price.net == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("44.98")
assert items[1].available_variations[0].original_price.gross == Decimal("49.98")
assert items[1].available_variations[0].original_price.net == Decimal("42.00")
@pytest.mark.django_db
def test_discounts_for_products_tax_additive_bundle_included(event, quota, item, variation, channel, discount):
tr = event.tax_rules.create(rate=Decimal("19.00"), price_includes_tax=False)
b = event.items.create(name="Bundled product", default_price=Decimal("10.00"), tax_rule=tr, require_bundling=True)
quota.items.add(b)
item.tax_rule = tr
item.save()
variation.item.tax_rule = tr
variation.item.save()
item.bundles.create(bundled_item=b, count=2, designated_price=Decimal("5.00"))
items, _ = prepare_item_list_for_shop(event, channel=channel)
assert len(items) == 2
assert items[0].display_price.gross == Decimal("45.98")
assert items[0].original_price.gross == Decimal("49.98")
assert items[0].original_price.net == Decimal("42.00")
assert items[1].available_variations[0].display_price.gross == Decimal("45.98")
assert items[1].available_variations[0].original_price.gross == Decimal("49.98")
assert items[1].available_variations[0].original_price.net == Decimal("42.00")