Compare commits

...
Author SHA1 Message Date
Raphael Michel c13ed9b7a8 Discounts: Add restriction to memberships (Z#23229305) 2026-09-13 22:25:48 +02:00
Raphael Michel 0f504fa9e1 Fix query 2026-09-13 20:34:49 +02:00
Raphael Michel 8159a039c8 . 2026-09-13 20:32:39 +02:00
Raphael Michel ea40a670f4 Fix a typo 2026-09-13 20:31:50 +02:00
Raphael Michel 96576b8235 Fix query count 2026-09-13 16:43:22 +02:00
Raphael Michel 10aec6d8dc 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.
2026-09-11 18:18:19 +02:00
24 changed files with 818 additions and 116 deletions
+12
View File
@@ -30,6 +30,8 @@ available_from datetime The first da
(or ``null``).
available_until datetime The last date time at which this discount can be applied
(or ``null``).
require_membership boolean If ``true``, booking this variation requires an active membership.
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
subevent_mode strings Determines how the discount is handled when used in an
event series. Can be ``"mixed"`` (no special effect),
``"same"`` (discount is only applied for groups within
@@ -108,6 +110,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
@@ -168,6 +172,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
@@ -214,6 +220,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
@@ -249,6 +257,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
@@ -313,6 +323,8 @@ Endpoints
"sales_channels": ["web"],
"available_from": null,
"available_until": null,
"require_membership_hidden": false,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": null,
"subevent_date_until": null,
+5 -1
View File
@@ -19,6 +19,8 @@
# 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 django.db.models import QuerySet
from django.utils.functional import lazy
from rest_framework import serializers
from pretix.api.serializers import SalesChannelMigrationMixin
@@ -38,7 +40,8 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
class Meta:
model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels',
'available_from', 'available_until', 'subevent_mode', 'subevent_date_from', 'subevent_date_until',
'available_from', 'available_until', 'require_membership', 'require_membership_types',
'subevent_mode', 'subevent_date_from', 'subevent_date_until',
'condition_all_products', 'condition_limit_products', 'condition_apply_to_addons',
'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent',
'benefit_only_apply_to_cheapest_n_matches', 'benefit_same_products', 'benefit_limit_products',
@@ -50,6 +53,7 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
self.fields['benefit_limit_products'].queryset = self.context['event'].items.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet)
def validate(self, data):
data = super().validate(data)
+11 -1
View File
@@ -1585,6 +1585,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.invoice_address = ia
ia.last_modified = now()
if order.customer_id:
memberships = order.customer.memberships.filter(testmode=order.testmode).select_related('membership_type')
else:
memberships = []
# Generate position objects
pos_map = {}
for pos_data in positions_data:
@@ -1655,7 +1660,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.sales_channel,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
cp.addon_to, cp.is_bundled, pos._voucher_discount)
cp.addon_to, cp.is_bundled, pos._voucher_discount, [
m for m in memberships if m.is_valid(
cp.subevent or self.context['event'],
cp.valid_from,
)
])
for cp in order_positions
]
)
+1 -1
View File
@@ -62,7 +62,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.discounts.prefetch_related(
'limit_sales_channels',
'limit_sales_channels', 'require_membership_types',
)
@transaction.atomic()
@@ -0,0 +1,24 @@
# Generated by Django 5.2.17 on 2026-09-13 18:32
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0310_question_valid_string_length_min"),
]
operations = [
migrations.AddField(
model_name="discount",
name="require_membership",
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name="discount",
name="require_membership_types",
field=models.ManyToManyField(to="pretixbase.membershiptype"),
),
]
+1 -1
View File
@@ -296,7 +296,7 @@ class Customer(LoggedModel):
return self.memberships.active(for_event).with_usages().filter(
Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages')),
testmode=testmode,
)
).select_related('membership_type')
def send_activation_mail(self):
from pretix.base.services.mail import mail
+123 -18
View File
@@ -19,12 +19,12 @@
# 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
import datetime
from collections import defaultdict
from decimal import Decimal
from itertools import groupby
from math import ceil, inf
from typing import Dict
from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
@@ -35,10 +35,20 @@ from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel
from pretix.base.timemachine import time_machine_now
PositionInfo = namedtuple('PositionInfo',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
'voucher_discount'])
if TYPE_CHECKING:
from pretix.base.models import Membership
class PositionInfo(NamedTuple):
item_id: int
subevent_id: Optional[int]
subevent_date_from: Optional[datetime.datetime]
line_price_gross: Decimal
addon_to: bool
voucher_discount: Decimal
memberships_valid_by_time: Optional[List["Membership"]] = None
class Discount(LoggedModel):
@@ -96,6 +106,16 @@ class Discount(LoggedModel):
choices=SUBEVENT_MODE_CHOICES,
)
require_membership = models.BooleanField(
verbose_name=_('Require a valid membership'),
default=False,
)
require_membership_types = models.ManyToManyField(
'MembershipType',
verbose_name=_('Allowed membership types'),
blank=True,
)
condition_all_products = models.BooleanField(
default=True,
verbose_name=_("Apply to all products (including newly created ones)")
@@ -239,6 +259,46 @@ class Discount(LoggedModel):
]}
)
if data.get('require_membership'):
if not data.get('require_membership_types'):
raise ValidationError(
{'require_membership_types': [
_("If a valid membership is required, at least one valid membership type needs to be selected.")
]}
)
for mt in data.get('require_membership_types', []):
# These conditions are a bit annoying/surprising, we could also just "ignore" the
# transferable/allow_parallel_usage/max_usages flags, but maybe we DO want to honor them for discounts
# some day for use cases like "every member can get one discounted ticket per show for themselves", and
# then we'd be unable to move forward if we historically ignored them.
if not mt.transferable:
raise ValidationError(
{'require_membership_types': [
_('The membership type "{type}" cannot be used for discounts since it is not transferable, '
'which is currently not supported for discounts.').format(
type=mt.name,
)
]}
)
elif not mt.allow_parallel_usage:
raise ValidationError(
{'require_membership_types': [
_('The membership type "{type}" cannot be used for discounts since it does not allow parallel '
'usage, which is currently not supported for discounts.').format(
type=mt.name,
)
]}
)
elif mt.max_usages is not None:
raise ValidationError(
{'require_membership_types': [
_('The membership type "{type}" cannot be used for discounts since it has a maximum number of '
'usages, which is currently not supported for discounts.').format(
type=mt.name,
)
]}
)
def allow_delete(self):
return not self.orderposition_set.exists()
@@ -250,6 +310,7 @@ class Discount(LoggedModel):
'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,
# many-to-many is not passed here, therefore we also call validate_config in form and serializer
})
def is_available_by_time(self, now_dt=None) -> bool:
@@ -343,7 +404,7 @@ class Discount(LoggedModel):
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
# but only 1 t-shirt) -> 1 shirt definitively 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)
@@ -400,19 +461,25 @@ class Discount(LoggedModel):
limit_products = set()
if not self.condition_all_products:
limit_products = {p.pk for p in self.condition_limit_products.all()}
membership_types = set()
if self.require_membership and self.require_membership_types:
membership_types = {mt.pk for mt in self.require_membership_types.all()}
# First, filter out everything not even covered by our product scope
condition_candidates = [
idx
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
for idx, p 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'))
and (not subevent_id or (
self.subevent_date_from is None or subevent_date_from >= self.subevent_date_from)) and (
self.subevent_date_until is None or subevent_date_from <= self.subevent_date_until)
(self.condition_all_products or p.item_id in limit_products) and
(self.condition_apply_to_addons or not p.addon_to) and
(not self.condition_ignore_voucher_discounted or p.voucher_discount is None or p.voucher_discount == Decimal('0.00')) and
(not p.subevent_id or (
self.subevent_date_from is None or p.subevent_date_from >= self.subevent_date_from)) and (
self.subevent_date_until is None or p.subevent_date_from <= self.subevent_date_until) and
(not self.require_membership or (
p.memberships_valid_by_time and any(m.membership_type_id in membership_types for m in p.memberships_valid_by_time)
))
)
]
@@ -422,12 +489,15 @@ class Discount(LoggedModel):
benefit_products = {p.pk for p in self.benefit_limit_products.all()}
benefit_candidates = [
idx
for idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) in
for idx, p 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'))
p.item_id in benefit_products and
(self.benefit_apply_to_addons or not p.addon_to) and
(not self.benefit_ignore_voucher_discounted or p.voucher_discount is None or p.voucher_discount == Decimal('0.00')) and
(not self.require_membership or (
p.memberships_valid_by_time and any(m.membership_type_id in membership_types for m in p.memberships_valid_by_time)
))
)
]
@@ -528,3 +598,38 @@ class Discount(LoggedModel):
None
)
return result
def condition_matches_product(self, item) -> bool:
return self.condition_all_products or item in self.condition_limit_products.all()
def valid_for_memberships(self, memberships, event, subevent, item, requested_valid_from=None) -> bool:
if not self.require_membership_types:
return True
if not memberships:
return False
memberships = [
m for m in memberships
# Filter currently unsupported configurations
if (
m.membership_type.max_usages is None and
m.membership_type.transferable and
m.membership_type.allow_parallel_usage and
# Filter list of membership types
m.membership_type in self.require_membership_types.all()
)
]
valid_from, _ = item.compute_validity(
requested_start=(
max(requested_valid_from, time_machine_now())
if requested_valid_from and item.validity_dynamic_start_choice
else time_machine_now()
),
override_tz=self.event.timezone,
)
return any(
m.is_valid(
subevent or event,
valid_from,
valid_from_not_chosen=item.validity_dynamic_start_choice and not requested_valid_from
) for m in memberships
)
+3 -1
View File
@@ -1081,6 +1081,7 @@ class Event(EventMixin, LoggedModel):
c_items = list(d.condition_limit_products.all())
b_items = list(d.benefit_limit_products.all())
limit_sales_channels = list(d.limit_sales_channels.all())
require_membership_types = list(d.require_membership_types.all())
d.pk = None
d.event = self
d._prefetched_objects_cache = {}
@@ -1092,9 +1093,10 @@ class Event(EventMixin, LoggedModel):
for i in b_items:
if i.pk in item_map:
d.benefit_limit_products.add(item_map[i.pk])
if not d.all_sales_channels:
d.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
if require_membership_types and not is_cross_organizer:
d.require_membership_types.set(require_membership_types)
question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
+3 -1
View File
@@ -510,7 +510,9 @@ class Item(LoggedModel):
verbose_name=_("Free price input"),
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 "
"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(
verbose_name=_("Suggested price"),
+9
View File
@@ -3413,6 +3413,15 @@ class CartPosition(AbstractPosition):
def valid_until(self):
return self.predicted_validity[1]
def memberships_valid_by_time(self, memberships):
return [
m for m in memberships if m.is_valid(
self.subevent or self.event,
self.valid_from,
valid_from_not_chosen=self.item.validity_dynamic_start_choice and not self.requested_valid_from
) for m in memberships
]
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
+59 -16
View File
@@ -47,6 +47,7 @@ from django.db import DatabaseError, transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
from django.db.models.aggregates import Min
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import (
gettext as _, gettext_lazy, ngettext_lazy, pgettext_lazy,
@@ -57,8 +58,8 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel,
Seat, SeatCategoryMapping, Voucher,
CartPosition, Customer, Event, InvoiceAddress, Item, ItemVariation,
SalesChannel, Seat, SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import CheckoutSession, OrderFee
@@ -417,13 +418,15 @@ class CartManager:
}
def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel,
invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None):
invoice_address: InvoiceAddress=None, widget_data=None, reservation_time: timedelta=None,
customer: Customer=None):
"""
Creates a new CartManager for an event.
"""
self.event = event
self.cart_id = cart_id
self.real_now_dt = now()
self.customer = customer
self._operations = []
self._quota_diff = Counter()
self._voucher_use_diff = Counter()
@@ -1544,7 +1547,7 @@ class CartManager:
self._sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher, cp.memberships_valid_by_time(self.memberships))
for cp in positions
]
)
@@ -1559,6 +1562,13 @@ class CartManager:
return diff
@cached_property
def memberships(self):
if self.customer:
return self.customer.memberships.filter(testmode=self.event.testmode).select_related('membership_type')
else:
return []
def _remove_parents_if_bundles_are_removed(self):
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
for op in self._operations:
@@ -1712,7 +1722,8 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None) -> None:
invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None,
customer: int=None) -> None:
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
@@ -1734,10 +1745,16 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
sales_channel=sales_channel)
sales_channel=sales_channel, customer=customer)
cm.add_new_items(items)
cm.commit()
except LockTimeoutException:
@@ -1747,7 +1764,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
:param event: The event ID in question
:param voucher: A voucher code
@@ -1758,9 +1775,14 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.apply_voucher(voucher)
cm.commit()
except LockTimeoutException:
@@ -1770,7 +1792,8 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en',
sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
Removes an item specified by its position ID from a user's cart.
:param event: The event ID in question
@@ -1782,9 +1805,14 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.remove_item(position)
cm.commit()
except LockTimeoutException:
@@ -1794,7 +1822,7 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None:
def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
Removes all items from a user's cart.
:param event: The event ID in question
@@ -1805,9 +1833,14 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.clear()
cm.commit()
except LockTimeoutException:
@@ -1817,7 +1850,7 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> dict:
def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None, customer=None) -> dict:
"""
Resets the expiry time of a cart to the configured reservation time of this event.
Limited to 11x the reservation time.
@@ -1830,9 +1863,14 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel, customer=customer)
cm.commit()
return {
"success": cm.num_extended_positions,
@@ -1848,7 +1886,7 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None:
invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None, customer=None) -> None:
"""
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to
ensure the requested addon state.
@@ -1869,9 +1907,14 @@ def set_cart_addons(self, event: Event, addons: List[dict], add_to_cart_items: L
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
if customer:
try:
customer = event.organizer.customers.get(pk=customer)
except Customer.DoesNotExist:
raise CartError("Invalid customer.")
try:
try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel, customer=customer)
cm.set_addons(addons)
cm.add_new_items(add_to_cart_items)
cm.commit()
+11 -1
View File
@@ -51,6 +51,7 @@ class CrossSellingService:
self.sales_channel = sales_channel
self.cartpositions = cartpositions
self.customer = customer
self._discount_cache = {}
def get_data(self):
if self.event.has_subevents:
@@ -111,6 +112,13 @@ class CrossSellingService:
}
return category.items.filter(pk__in=potential_discount_items), potential_discount_items
@cached_property
def memberships(self):
if self.customer:
return self.customer.memberships.filter(testmode=self.event.testmode).select_related('membership_type')
else:
return []
@cached_property
def _potential_discounts_by_subevent_and_item_for_current_cart(self):
potential_discounts_by_cartpos = defaultdict(list)
@@ -122,7 +130,8 @@ class CrossSellingService:
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled,
cp.listed_price - cp.price_after_voucher)
cp.listed_price - cp.price_after_voucher,
cp.memberships_valid_by_time(self.memberships))
for cp in self.cartpositions
],
collect_potential_discounts=potential_discounts_by_cartpos
@@ -177,6 +186,7 @@ class CrossSellingService:
)
if self.customer else None
),
_discount_cache=self._discount_cache,
)
new_items = list()
for item in items:
+1 -1
View File
@@ -86,7 +86,7 @@ def create_membership(customer: Customer, position: OrderPosition):
def validate_memberships_in_order(customer: Customer, positions: List[AbstractPosition], event: Event, lock=False, ignored_order: Order = None, testmode=False,
valid_from_not_chosen=False):
"""
Validate that a set of cart or order positions. This currently does not validate
Validate that a set of cart or order positions.
:param customer: Customer to validate for
:param positions: List of order or cart positions
+8 -2
View File
@@ -56,7 +56,7 @@ from django.db.models import (
from django.db.models.functions import Cast, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.functional import cached_property, lazy
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
from django_scopes import scopes_disabled
@@ -923,12 +923,18 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
continue
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
if customer:
memberships = lazy(lambda: list(customer.memberships.filter(testmode=event.testmode).select_related('membership_type')), list)()
else:
memberships = []
discount_results = apply_discounts(
event,
sales_channel.identifier,
[
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher, cp.memberships_valid_by_time(memberships))
for cp in sorted_positions
]
)
+19 -5
View File
@@ -33,7 +33,7 @@ from django.db.models import Q
from pretix.base.decimal import round_decimal
from pretix.base.models import (
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
ItemVariation, Membership, OrderFee, OrderPosition, SalesChannel, Voucher,
)
from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent
@@ -167,14 +167,15 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal, List[Membership]]],
collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
"""
Applies any dynamic discounts to a cart
:param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled,
voucher_discount, memberships_valid_by_time)``
``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but
a better result may be given if addons to the same main product have the same distinct value.
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
@@ -190,6 +191,17 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
sales_channel = sales_channel.identifier
new_prices = {}
def _filter_memberships(memberships: List[Membership]) -> List[Membership]:
return [
# Currently unsupported configurations
m for m in memberships
if (
m.membership_type.max_usages is None and
m.membership_type.transferable and
m.membership_type.allow_parallel_usage
)
]
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()),
@@ -198,9 +210,11 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs:
result = discount.apply({
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount)
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount,
_filter_memberships(memberships_valid_by_time))
for
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount,
memberships_valid_by_time)
in enumerate(positions)
if not is_bundled and idx not in new_prices
}, collect_potential_discounts)
+15
View File
@@ -45,6 +45,8 @@ class DiscountForm(I18nModelForm):
'limit_sales_channels',
'available_from',
'available_until',
'require_membership',
'require_membership_types',
'subevent_date_from',
'subevent_date_until',
'subevent_mode',
@@ -69,6 +71,7 @@ class DiscountForm(I18nModelForm):
'condition_limit_products': ItemMultipleChoiceField,
'benefit_limit_products': ItemMultipleChoiceField,
'limit_sales_channels': SafeModelMultipleChoiceField,
'require_membership_types': SafeModelMultipleChoiceField,
}
widgets = {
'subevent_mode': forms.RadioSelect,
@@ -88,6 +91,9 @@ class DiscountForm(I18nModelForm):
'data-display-dependency': '#id_condition_min_count',
}
),
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice'
}),
}
def __init__(self, *args, **kwargs):
@@ -107,6 +113,13 @@ class DiscountForm(I18nModelForm):
self.fields['condition_min_value'].required = False
self.fields['condition_min_value'].widget.is_required = False
qs = self.event.organizer.membership_types.all()
if qs:
self.fields['require_membership_types'].queryset = qs
else:
del self.fields['require_membership']
del self.fields['require_membership_types']
if not self.event.has_subevents:
del self.fields['subevent_mode']
@@ -123,4 +136,6 @@ class DiscountForm(I18nModelForm):
d['condition_min_count'] = 0
if d.get('condition_min_value') is None:
d['condition_min_value'] = Decimal('0.00')
Discount.validate_config(d)
return d
@@ -17,6 +17,12 @@
{% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.all_sales_channels layout="control" %}
{% bootstrap_field form.limit_sales_channels layout="control" %}
{% if form.require_membership %}
{% bootstrap_field form.require_membership layout="control" %}
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
{% bootstrap_field form.require_membership_types layout="control" %}
</div>
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Condition" context "discount" %}</legend>
@@ -9,7 +9,23 @@
{% endif %}
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Usage restrictions" %}</legend>
<p>
{% blocktrans trimmed %}
The following restrictions apply whenever the membership is used to buy a product or variation that
is only available to membership holders.
{% endblocktrans %}
</p>
{% bootstrap_field form.transferable layout="control" %}
{% bootstrap_field form.allow_parallel_usage layout="control" %}
{% bootstrap_field form.max_usages layout="control" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
+47 -19
View File
@@ -248,7 +248,19 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
raise NotImplementedError()
class CustomerStep(CartMixin, TemplateFlowStep):
class RecomputeMixin:
def recompute_cart(self):
cm = CartManager(
event=self.request.event,
cart_id=get_or_create_cart_id(self.request),
invoice_address=self.invoice_address,
sales_channel=self.request.sales_channel,
customer=self.cart_customer,
)
return cm.recompute_final_prices_and_taxes()
class CustomerStep(CartMixin, RecomputeMixin, TemplateFlowStep):
priority = 45
identifier = "customer"
template_name = "pretixpresale/event/checkout_customer.html"
@@ -326,28 +338,34 @@ class CustomerStep(CartMixin, TemplateFlowStep):
customer_login(self.request, customer)
return True
def _proceed_to_next(self):
diff = self.recompute_cart()
if abs(diff) > Decimal('0.001'):
messages.info(self.request, _('Due to the information you entered, the total of your cart has changed.'))
return redirect_to_url(self.get_next_url(self.request))
def post(self, request):
self.request = request
if request.POST.get("customer_mode") == 'login':
if self.cart_session.get('customer'):
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
elif request.customer:
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = request.customer.pk
self.cart_session['customer_cart_tied_to_login'] = True
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
elif self.request.POST.get("login-sso-data"):
if not self._handle_sso_login():
messages.error(request, _('We failed to process your authentication request, please try again.'))
return self.render()
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
elif self.event.settings.customer_accounts_native and self.login_form.is_valid():
customer_login(self.request, self.login_form.get_customer())
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = self.login_form.get_customer().pk
self.cart_session['customer_cart_tied_to_login'] = True
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
else:
return self.render()
elif request.POST.get("customer_mode") == 'register' and self.signup_allowed:
@@ -356,13 +374,13 @@ class CustomerStep(CartMixin, TemplateFlowStep):
self.cart_session['customer_mode'] = 'login'
self.cart_session['customer'] = customer.pk
self.cart_session['customer_cart_tied_to_login'] = False
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
else:
return self.render()
elif request.POST.get("customer_mode") == 'guest' and self.guest_allowed:
self.cart_session['customer'] = None
self.cart_session['customer_mode'] = 'guest'
return redirect_to_url(self.get_next_url(request))
return self._proceed_to_next()
else:
return self.render()
@@ -770,12 +788,18 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
add_to_cart_items = _items_from_post_data(self.request, warn_if_empty=False)
if 'customer_mode' in self.cart_session:
cart_customer = self.cart_customer
else:
cart_customer = getattr(self.request, 'customer', None)
return self.do(self.request.event.id, addons, add_to_cart_items, get_or_create_cart_id(self.request),
invoice_address=self.invoice_address.pk, locale=get_language(),
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))
sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None),
customer=cart_customer.pk if cart_customer else None)
class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
class QuestionsStep(CartQuestionsViewMixin, RecomputeMixin, CartMixin, TemplateFlowStep):
priority = 50
identifier = "questions"
template_name = "pretixpresale/event/checkout_questions.html"
@@ -986,6 +1010,7 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
def post(self, request):
self.request = request
failed = not self.save() or not self.contact_form.is_valid()
recomputed = False
if self.address_asked or self.request.event.settings.invoice_name_required:
failed = failed or not self.invoice_form.is_valid()
if failed:
@@ -1030,13 +1055,8 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
self.cart_session['saved_invoice_address'] = saved.pk
try:
cm = CartManager(
event=self.request.event,
cart_id=get_or_create_cart_id(request),
invoice_address=addr,
sales_channel=request.sales_channel,
)
diff = cm.recompute_final_prices_and_taxes()
diff = self.recompute_cart()
recomputed = True
except TaxRule.SaleNotAllowed:
messages.error(request,
_("Unfortunately, based on the invoice address you entered, we're not able to sell you "
@@ -1045,15 +1065,23 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
self.cart_session['invoice_address'] = addr.pk
if abs(diff) > Decimal('0.001'):
messages.info(request, _('Due to the invoice address you entered, we need to apply a different tax '
'rate to your purchase and the price of the products in your cart has '
'changed accordingly.'))
messages.info(request, _('Due to the information you entered, tax rates or discounts on your purchase '
'have been changed and the price of the products in your cart has changed '
'accordingly.'))
return redirect_to_url(self.get_next_url(request) + '?open_cart=true')
elif 'invoice_address' in self.cart_session:
# Invoice address was there, but is no longer asked for
self.invoice_address.delete()
del self.cart_session['invoice_address']
if self.cart_customer and not recomputed and any(cp.item.validity_dynamic_start_choice for cp in self.positions):
# Discounts might have changed
diff = self.recompute_cart()
if abs(diff) > Decimal('0.001'):
messages.info(request, _('Due to the information you entered, tax rates or discounts on your purchase '
'have been changed and the price of the products in your cart has changed '
'accordingly.'))
try:
validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False,
testmode=self.request.event.testmode, valid_from_not_chosen=False)
+131 -32
View File
@@ -21,7 +21,8 @@
#
import sys
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.db.models import (
@@ -29,11 +30,13 @@ from django.db.models import (
)
from django.db.models.lookups import Exact
from pretix.base.decimal import round_decimal
from pretix.base.models import (
ItemVariation, Quota, SalesChannel, SeatCategoryMapping,
Discount, Event, Item, ItemVariation, Membership, Quota, SalesChannel,
SeatCategoryMapping, SubEvent, Voucher,
)
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.timemachine import time_machine_now
@@ -54,10 +57,44 @@ 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,
memberships: List[Membership]=None) -> List[Discount]:
if memberships is not None:
prefetch_membership_types = ['require_membership_types']
else:
prefetch_membership_types = []
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),
Q(active=True),
# Only discounts that can be applied before we know the full cart
Q(benefit_only_apply_to_cheapest_n_matches__isnull=True) | Q(benefit_only_apply_to_cheapest_n_matches=1),
benefit_same_products=True,
condition_min_value=Decimal("0.00"),
condition_min_count=1,
).prefetch_related('condition_limit_products', *prefetch_membership_types).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,
allow_addons=False, allow_cross_sell=False,
quota_cache=None, filter_items=None, filter_categories=None, memberships=None,
ignore_hide_sold_out_for_item_ids=None):
ignore_hide_sold_out_for_item_ids=None, _discount_cache=None):
base_qs_set = base_qs is not None
base_qs = base_qs if base_qs is not None else event.items
@@ -190,6 +227,24 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
if filter_categories:
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.
cache_key = (event, channel, voucher, subevent, allow_addons)
if _discount_cache is None:
_discount_cache = {}
if cache_key not in _discount_cache:
_discount_cache[cache_key] = list(_single_item_discounts(
event=event,
sales_channel=channel,
voucher=voucher,
subevent=subevent,
is_addons=allow_addons,
memberships=memberships,
))
discounts = _discount_cache[cache_key]
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 = quota_cache or event.cache.get(quota_cache_key) or {}
@@ -272,6 +327,11 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
if resp:
item.description += ("<br/>" if item.description else "") + resp
matching_discounts = [
d for d in discounts
if d.condition_matches_product(item) and d.valid_for_memberships(memberships, event, subevent, item)
]
if not item.has_variations:
item._remove = False
if not bool(item._subevent_quotas):
@@ -299,30 +359,51 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
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
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
price = voucher.calculate_price(configured_price)
voucher_reduced = price < configured_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
price = configured_price
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:
item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled)
else:
item.suggested_price = item.display_price
if price != original_price:
item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True)
else:
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
if item.original_price else None
if voucher_reduced or (discount and not item.original_price):
# If a voucher is used, we use the non-voucher price as the "original price", even if a different
# original price is set, to highlight the voucher's impact. We also use the configured price as
# original price if a discount is applied and no explicit original price is set
item.original_price = item.tax(configured_price, currency=event.currency, include_bundled=True)
elif item.original_price:
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:
display_add_to_cart = not item.requires_seat and item.order_max > 0
else:
@@ -352,35 +433,53 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v
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
if voucher:
price = voucher.calculate_price(original_price)
voucher_reduced = price < original_price
price = voucher.calculate_price(configured_price)
voucher_reduced = price < configured_price
include_bundled = not voucher.all_bundles_included
else:
price = original_price
price = configured_price
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:
var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
var.suggested_price = var.tax(max(price, var.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
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,
include_bundled=include_bundled)
var.suggested_price = var.tax(max(price, item.free_price_suggestion), currency=event.currency,
include_bundled=include_bundled)
else:
var.suggested_price = var.display_price
if price != original_price:
var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True)
if voucher_reduced or (discount and not item.original_price and not var.original_price):
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:
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
) if var.original_price or item.original_price else None
var.original_price = None
var.current_unavailability_reason = _get_variant_unavailability_reason(var, has_voucher=voucher, subevent=subevent)
+18 -6
View File
@@ -58,7 +58,8 @@ from django.views.generic import TemplateView, View
from django_scopes import scopes_disabled
from pretix.base.models import (
CartPosition, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent, Voucher,
CartPosition, Customer, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent,
Voucher,
)
from pretix.base.services.cart import (
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
@@ -153,6 +154,17 @@ class CartActionMixin:
except InvoiceAddress.DoesNotExist:
return InvoiceAddress()
@cached_property
def cart_customer(self):
if 'customer_mode' in self.cart_session:
if self.cart_session['customer_mode'] == 'login':
try:
return self.request.organizer.customers.get(pk=self.cart_session.get('customer', -1))
except Customer.DoesNotExist:
return
else:
return getattr(self.request, 'customer', None)
def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_redeemed=False):
if value.strip() == '' or '_' not in key:
@@ -505,7 +517,7 @@ class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
return self.do(self.request.event.id, code, get_or_create_cart_id(self.request),
translation.get_language(), request.sales_channel.identifier,
time_machine_now(default=None))
time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
@@ -532,7 +544,7 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
try:
return self.do(self.request.event.id, int(request.POST.get('id')), get_or_create_cart_id(self.request),
translation.get_language(), request.sales_channel.identifier,
time_machine_now(default=None))
time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
except ValueError:
return redirect_to_url(self.get_error_url())
else:
@@ -555,7 +567,7 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
def post(self, request, *args, **kwargs):
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
request.sales_channel.identifier, time_machine_now(default=None))
request.sales_channel.identifier, time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
@method_decorator(allow_cors_if_namespaced, 'dispatch')
@@ -591,7 +603,7 @@ class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
def post(self, request, *args, **kwargs):
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
request.sales_channel.identifier, time_machine_now(default=None))
request.sales_channel.identifier, time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
@method_decorator(allow_cors_if_namespaced, 'dispatch')
@@ -648,7 +660,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
if items:
return self.do(self.request.event.id, items, cart_id, translation.get_language(),
self.invoice_address.pk, widget_data, self.request.sales_channel.identifier,
time_machine_now(default=None))
time_machine_now(default=None), self.cart_customer.pk if self.cart_customer else None)
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
+2
View File
@@ -46,6 +46,8 @@ TEST_DISCOUNT_RES = {
"sales_channels": ["bar", "baz", "web"],
"available_from": None,
"available_until": None,
"require_membership_hidden": False,
"require_membership_types": [],
"subevent_mode": "mixed",
"subevent_date_from": None,
"subevent_date_until": None,
+9 -9
View File
@@ -749,7 +749,7 @@ def test_query_count_many_items(event, itemcount):
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
expect_num_queries=9,
)
check_cart_behaviour(
event,
@@ -763,7 +763,7 @@ def test_query_count_many_items(event, itemcount):
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Ticket 2 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
check_cart_behaviour(
event,
@@ -779,7 +779,7 @@ def test_query_count_many_items(event, itemcount):
recommendations=''' Price Discounted Price Max Count Prefix
Tickets Ticket 2 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
@@ -803,7 +803,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
expect_num_queries=9,
)
check_cart_behaviour(
event,
@@ -817,7 +817,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
check_cart_behaviour(
event,
@@ -833,7 +833,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
@@ -857,7 +857,7 @@ def test_query_count_many_cartpos(event, catcount):
''',
recommendations=''' Price Discounted Price Max Count Prefix
''',
expect_num_queries=8,
expect_num_queries=9,
)
check_cart_behaviour(
event,
@@ -871,7 +871,7 @@ def test_query_count_many_cartpos(event, catcount):
recommendations=''' Price Discounted Price Max Count Prefix
Category 1 Ticket 1-B 42.00 0.00 1 -
''',
expect_num_queries=9,
expect_num_queries=10,
)
check_cart_behaviour(
event,
@@ -893,5 +893,5 @@ def test_query_count_many_cartpos(event, catcount):
Category 1 Ticket 1-B 42.00 0.00 1 -
Category 2 Ticket 2-B 42.00 0.00 1 -
''',
expect_num_queries=13,
expect_num_queries=14,
)
+283
View File
@@ -0,0 +1,283 @@
#
# 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.condition_limit_products.add(variation.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"))
variation.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")