mirror of
https://github.com/pretix/pretix.git
synced 2026-10-02 19:24:42 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c13ed9b7a8 | ||
|
|
0f504fa9e1 | ||
|
|
8159a039c8 | ||
|
|
ea40a670f4 | ||
|
|
96576b8235 | ||
|
|
10aec6d8dc |
@@ -30,6 +30,8 @@ available_from datetime The first da
|
|||||||
(or ``null``).
|
(or ``null``).
|
||||||
available_until datetime The last date time at which this discount can be applied
|
available_until datetime The last date time at which this discount can be applied
|
||||||
(or ``null``).
|
(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
|
subevent_mode strings Determines how the discount is handled when used in an
|
||||||
event series. Can be ``"mixed"`` (no special effect),
|
event series. Can be ``"mixed"`` (no special effect),
|
||||||
``"same"`` (discount is only applied for groups within
|
``"same"`` (discount is only applied for groups within
|
||||||
@@ -108,6 +110,8 @@ Endpoints
|
|||||||
"sales_channels": ["web"],
|
"sales_channels": ["web"],
|
||||||
"available_from": null,
|
"available_from": null,
|
||||||
"available_until": null,
|
"available_until": null,
|
||||||
|
"require_membership_hidden": false,
|
||||||
|
"require_membership_types": [],
|
||||||
"subevent_mode": "mixed",
|
"subevent_mode": "mixed",
|
||||||
"subevent_date_from": null,
|
"subevent_date_from": null,
|
||||||
"subevent_date_until": null,
|
"subevent_date_until": null,
|
||||||
@@ -168,6 +172,8 @@ Endpoints
|
|||||||
"sales_channels": ["web"],
|
"sales_channels": ["web"],
|
||||||
"available_from": null,
|
"available_from": null,
|
||||||
"available_until": null,
|
"available_until": null,
|
||||||
|
"require_membership_hidden": false,
|
||||||
|
"require_membership_types": [],
|
||||||
"subevent_mode": "mixed",
|
"subevent_mode": "mixed",
|
||||||
"subevent_date_from": null,
|
"subevent_date_from": null,
|
||||||
"subevent_date_until": null,
|
"subevent_date_until": null,
|
||||||
@@ -214,6 +220,8 @@ Endpoints
|
|||||||
"sales_channels": ["web"],
|
"sales_channels": ["web"],
|
||||||
"available_from": null,
|
"available_from": null,
|
||||||
"available_until": null,
|
"available_until": null,
|
||||||
|
"require_membership_hidden": false,
|
||||||
|
"require_membership_types": [],
|
||||||
"subevent_mode": "mixed",
|
"subevent_mode": "mixed",
|
||||||
"subevent_date_from": null,
|
"subevent_date_from": null,
|
||||||
"subevent_date_until": null,
|
"subevent_date_until": null,
|
||||||
@@ -249,6 +257,8 @@ Endpoints
|
|||||||
"sales_channels": ["web"],
|
"sales_channels": ["web"],
|
||||||
"available_from": null,
|
"available_from": null,
|
||||||
"available_until": null,
|
"available_until": null,
|
||||||
|
"require_membership_hidden": false,
|
||||||
|
"require_membership_types": [],
|
||||||
"subevent_mode": "mixed",
|
"subevent_mode": "mixed",
|
||||||
"subevent_date_from": null,
|
"subevent_date_from": null,
|
||||||
"subevent_date_until": null,
|
"subevent_date_until": null,
|
||||||
@@ -313,6 +323,8 @@ Endpoints
|
|||||||
"sales_channels": ["web"],
|
"sales_channels": ["web"],
|
||||||
"available_from": null,
|
"available_from": null,
|
||||||
"available_until": null,
|
"available_until": null,
|
||||||
|
"require_membership_hidden": false,
|
||||||
|
"require_membership_types": [],
|
||||||
"subevent_mode": "mixed",
|
"subevent_mode": "mixed",
|
||||||
"subevent_date_from": null,
|
"subevent_date_from": null,
|
||||||
"subevent_date_until": null,
|
"subevent_date_until": null,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# 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/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
from django.db.models import QuerySet
|
||||||
|
from django.utils.functional import lazy
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from pretix.api.serializers import SalesChannelMigrationMixin
|
from pretix.api.serializers import SalesChannelMigrationMixin
|
||||||
@@ -38,7 +40,8 @@ class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Discount
|
model = Discount
|
||||||
fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels',
|
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_all_products', 'condition_limit_products', 'condition_apply_to_addons',
|
||||||
'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent',
|
'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent',
|
||||||
'benefit_only_apply_to_cheapest_n_matches', 'benefit_same_products', 'benefit_limit_products',
|
'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['condition_limit_products'].queryset = self.context['event'].items.all()
|
||||||
self.fields['benefit_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['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):
|
def validate(self, data):
|
||||||
data = super().validate(data)
|
data = super().validate(data)
|
||||||
|
|||||||
@@ -1585,6 +1585,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
order.invoice_address = ia
|
order.invoice_address = ia
|
||||||
ia.last_modified = now()
|
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
|
# Generate position objects
|
||||||
pos_map = {}
|
pos_map = {}
|
||||||
for pos_data in positions_data:
|
for pos_data in positions_data:
|
||||||
@@ -1655,7 +1660,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
|||||||
order.sales_channel,
|
order.sales_channel,
|
||||||
[
|
[
|
||||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
|
(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
|
for cp in order_positions
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
|
|||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return self.request.event.discounts.prefetch_related(
|
return self.request.event.discounts.prefetch_related(
|
||||||
'limit_sales_channels',
|
'limit_sales_channels', 'require_membership_types',
|
||||||
)
|
)
|
||||||
|
|
||||||
@transaction.atomic()
|
@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"),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -296,7 +296,7 @@ class Customer(LoggedModel):
|
|||||||
return self.memberships.active(for_event).with_usages().filter(
|
return self.memberships.active(for_event).with_usages().filter(
|
||||||
Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages')),
|
Q(membership_type__max_usages__isnull=True) | Q(usages__lt=F('membership_type__max_usages')),
|
||||||
testmode=testmode,
|
testmode=testmode,
|
||||||
)
|
).select_related('membership_type')
|
||||||
|
|
||||||
def send_activation_mail(self):
|
def send_activation_mail(self):
|
||||||
from pretix.base.services.mail import mail
|
from pretix.base.services.mail import mail
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
# 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/>.
|
# <https://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
import datetime
|
||||||
from collections import defaultdict, namedtuple
|
from collections import defaultdict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from itertools import groupby
|
from itertools import groupby
|
||||||
from math import ceil, inf
|
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.exceptions import ValidationError
|
||||||
from django.core.validators import MinValueValidator
|
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.decimal import round_decimal
|
||||||
from pretix.base.models.base import LoggedModel
|
from pretix.base.models.base import LoggedModel
|
||||||
|
from pretix.base.timemachine import time_machine_now
|
||||||
|
|
||||||
PositionInfo = namedtuple('PositionInfo',
|
if TYPE_CHECKING:
|
||||||
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
|
from pretix.base.models import Membership
|
||||||
'voucher_discount'])
|
|
||||||
|
|
||||||
|
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):
|
class Discount(LoggedModel):
|
||||||
@@ -96,6 +106,16 @@ class Discount(LoggedModel):
|
|||||||
choices=SUBEVENT_MODE_CHOICES,
|
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(
|
condition_all_products = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
verbose_name=_("Apply to all products (including newly created ones)")
|
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):
|
def allow_delete(self):
|
||||||
return not self.orderposition_set.exists()
|
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,
|
'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches,
|
||||||
'subevent_mode': self.subevent_mode,
|
'subevent_mode': self.subevent_mode,
|
||||||
'benefit_same_products': self.benefit_same_products,
|
'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:
|
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 collect_potential_discounts is not None:
|
||||||
if n_groups * self.benefit_only_apply_to_cheapest_n_matches > len(benefit_idx_group):
|
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
|
# 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:
|
for idx in consume_idx:
|
||||||
collect_potential_discounts[idx] = [
|
collect_potential_discounts[idx] = [
|
||||||
(self, n_groups * self.benefit_only_apply_to_cheapest_n_matches - len(benefit_idx_group), -1, subevent_id)
|
(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()
|
limit_products = set()
|
||||||
if not self.condition_all_products:
|
if not self.condition_all_products:
|
||||||
limit_products = {p.pk for p in self.condition_limit_products.all()}
|
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
|
# First, filter out everything not even covered by our product scope
|
||||||
condition_candidates = [
|
condition_candidates = [
|
||||||
idx
|
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()
|
positions.items()
|
||||||
if (
|
if (
|
||||||
(self.condition_all_products or item_id in limit_products) and
|
(self.condition_all_products or p.item_id in limit_products) and
|
||||||
(self.condition_apply_to_addons or not is_addon_to) and
|
(self.condition_apply_to_addons or not p.addon_to) and
|
||||||
(not self.condition_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
|
(not self.condition_ignore_voucher_discounted or p.voucher_discount is None or p.voucher_discount == Decimal('0.00')) and
|
||||||
and (not subevent_id or (
|
(not p.subevent_id or (
|
||||||
self.subevent_date_from is None or subevent_date_from >= self.subevent_date_from)) and (
|
self.subevent_date_from is None or p.subevent_date_from >= self.subevent_date_from)) and (
|
||||||
self.subevent_date_until is None or subevent_date_from <= self.subevent_date_until)
|
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_products = {p.pk for p in self.benefit_limit_products.all()}
|
||||||
benefit_candidates = [
|
benefit_candidates = [
|
||||||
idx
|
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()
|
positions.items()
|
||||||
if (
|
if (
|
||||||
item_id in benefit_products and
|
p.item_id in benefit_products and
|
||||||
(self.benefit_apply_to_addons or not is_addon_to) and
|
(self.benefit_apply_to_addons or not p.addon_to) and
|
||||||
(not self.benefit_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00'))
|
(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
|
None
|
||||||
)
|
)
|
||||||
return result
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1081,6 +1081,7 @@ class Event(EventMixin, LoggedModel):
|
|||||||
c_items = list(d.condition_limit_products.all())
|
c_items = list(d.condition_limit_products.all())
|
||||||
b_items = list(d.benefit_limit_products.all())
|
b_items = list(d.benefit_limit_products.all())
|
||||||
limit_sales_channels = list(d.limit_sales_channels.all())
|
limit_sales_channels = list(d.limit_sales_channels.all())
|
||||||
|
require_membership_types = list(d.require_membership_types.all())
|
||||||
d.pk = None
|
d.pk = None
|
||||||
d.event = self
|
d.event = self
|
||||||
d._prefetched_objects_cache = {}
|
d._prefetched_objects_cache = {}
|
||||||
@@ -1092,9 +1093,10 @@ class Event(EventMixin, LoggedModel):
|
|||||||
for i in b_items:
|
for i in b_items:
|
||||||
if i.pk in item_map:
|
if i.pk in item_map:
|
||||||
d.benefit_limit_products.add(item_map[i.pk])
|
d.benefit_limit_products.add(item_map[i.pk])
|
||||||
|
|
||||||
if not d.all_sales_channels:
|
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]))
|
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 = {}
|
question_map = {}
|
||||||
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
|
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -3413,6 +3413,15 @@ class CartPosition(AbstractPosition):
|
|||||||
def valid_until(self):
|
def valid_until(self):
|
||||||
return self.predicted_validity[1]
|
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):
|
class InvoiceAddress(models.Model):
|
||||||
last_modified = models.DateTimeField(auto_now=True)
|
last_modified = models.DateTimeField(auto_now=True)
|
||||||
|
|||||||
@@ -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 import Count, Exists, IntegerField, OuterRef, Q, Value
|
||||||
from django.db.models.aggregates import Min
|
from django.db.models.aggregates import Min
|
||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
|
from django.utils.functional import cached_property
|
||||||
from django.utils.timezone import make_aware, now
|
from django.utils.timezone import make_aware, now
|
||||||
from django.utils.translation import (
|
from django.utils.translation import (
|
||||||
gettext as _, gettext_lazy, ngettext_lazy, pgettext_lazy,
|
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.i18n import language
|
||||||
from pretix.base.media import MEDIA_TYPES
|
from pretix.base.media import MEDIA_TYPES
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel,
|
CartPosition, Customer, Event, InvoiceAddress, Item, ItemVariation,
|
||||||
Seat, SeatCategoryMapping, Voucher,
|
SalesChannel, Seat, SeatCategoryMapping, Voucher,
|
||||||
)
|
)
|
||||||
from pretix.base.models.event import SubEvent
|
from pretix.base.models.event import SubEvent
|
||||||
from pretix.base.models.orders import CheckoutSession, OrderFee
|
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,
|
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.
|
Creates a new CartManager for an event.
|
||||||
"""
|
"""
|
||||||
self.event = event
|
self.event = event
|
||||||
self.cart_id = cart_id
|
self.cart_id = cart_id
|
||||||
self.real_now_dt = now()
|
self.real_now_dt = now()
|
||||||
|
self.customer = customer
|
||||||
self._operations = []
|
self._operations = []
|
||||||
self._quota_diff = Counter()
|
self._quota_diff = Counter()
|
||||||
self._voucher_use_diff = Counter()
|
self._voucher_use_diff = Counter()
|
||||||
@@ -1544,7 +1547,7 @@ class CartManager:
|
|||||||
self._sales_channel.identifier,
|
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.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
|
for cp in positions
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -1559,6 +1562,13 @@ class CartManager:
|
|||||||
|
|
||||||
return diff
|
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):
|
def _remove_parents_if_bundles_are_removed(self):
|
||||||
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
|
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
|
||||||
for op in self._operations:
|
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,))
|
@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',
|
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.
|
Adds a list of items to a user's cart.
|
||||||
:param event: The event ID in question
|
: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:
|
except SalesChannel.DoesNotExist:
|
||||||
raise CartError("Invalid sales channel.")
|
raise CartError("Invalid sales channel.")
|
||||||
|
|
||||||
|
if customer:
|
||||||
|
try:
|
||||||
|
customer = event.organizer.customers.get(pk=customer)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
raise CartError("Invalid customer.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
try:
|
try:
|
||||||
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
|
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.add_new_items(items)
|
||||||
cm.commit()
|
cm.commit()
|
||||||
except LockTimeoutException:
|
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,))
|
@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 event: The event ID in question
|
||||||
:param voucher: A voucher code
|
: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)
|
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||||
except SalesChannel.DoesNotExist:
|
except SalesChannel.DoesNotExist:
|
||||||
raise CartError("Invalid sales channel.")
|
raise CartError("Invalid sales channel.")
|
||||||
|
if customer:
|
||||||
|
try:
|
||||||
|
customer = event.organizer.customers.get(pk=customer)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
raise CartError("Invalid customer.")
|
||||||
try:
|
try:
|
||||||
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.apply_voucher(voucher)
|
||||||
cm.commit()
|
cm.commit()
|
||||||
except LockTimeoutException:
|
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,))
|
@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.
|
Removes an item specified by its position ID from a user's cart.
|
||||||
:param event: The event ID in question
|
: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)
|
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||||
except SalesChannel.DoesNotExist:
|
except SalesChannel.DoesNotExist:
|
||||||
raise CartError("Invalid sales channel.")
|
raise CartError("Invalid sales channel.")
|
||||||
|
if customer:
|
||||||
|
try:
|
||||||
|
customer = event.organizer.customers.get(pk=customer)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
raise CartError("Invalid customer.")
|
||||||
try:
|
try:
|
||||||
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.remove_item(position)
|
||||||
cm.commit()
|
cm.commit()
|
||||||
except LockTimeoutException:
|
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,))
|
@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.
|
Removes all items from a user's cart.
|
||||||
:param event: The event ID in question
|
: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)
|
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||||
except SalesChannel.DoesNotExist:
|
except SalesChannel.DoesNotExist:
|
||||||
raise CartError("Invalid sales channel.")
|
raise CartError("Invalid sales channel.")
|
||||||
|
if customer:
|
||||||
|
try:
|
||||||
|
customer = event.organizer.customers.get(pk=customer)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
raise CartError("Invalid customer.")
|
||||||
try:
|
try:
|
||||||
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.clear()
|
||||||
cm.commit()
|
cm.commit()
|
||||||
except LockTimeoutException:
|
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,))
|
@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.
|
Resets the expiry time of a cart to the configured reservation time of this event.
|
||||||
Limited to 11x the reservation time.
|
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)
|
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||||
except SalesChannel.DoesNotExist:
|
except SalesChannel.DoesNotExist:
|
||||||
raise CartError("Invalid sales channel.")
|
raise CartError("Invalid sales channel.")
|
||||||
|
if customer:
|
||||||
|
try:
|
||||||
|
customer = event.organizer.customers.get(pk=customer)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
raise CartError("Invalid customer.")
|
||||||
try:
|
try:
|
||||||
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()
|
cm.commit()
|
||||||
return {
|
return {
|
||||||
"success": cm.num_extended_positions,
|
"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,))
|
@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',
|
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
|
Assigns addons to eligible products in a user's cart, adding and removing the addon products as necessary to
|
||||||
ensure the requested addon state.
|
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)
|
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
|
||||||
except SalesChannel.DoesNotExist:
|
except SalesChannel.DoesNotExist:
|
||||||
raise CartError("Invalid sales channel.")
|
raise CartError("Invalid sales channel.")
|
||||||
|
if customer:
|
||||||
|
try:
|
||||||
|
customer = event.organizer.customers.get(pk=customer)
|
||||||
|
except Customer.DoesNotExist:
|
||||||
|
raise CartError("Invalid customer.")
|
||||||
try:
|
try:
|
||||||
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.set_addons(addons)
|
||||||
cm.add_new_items(add_to_cart_items)
|
cm.add_new_items(add_to_cart_items)
|
||||||
cm.commit()
|
cm.commit()
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class CrossSellingService:
|
|||||||
self.sales_channel = sales_channel
|
self.sales_channel = sales_channel
|
||||||
self.cartpositions = cartpositions
|
self.cartpositions = cartpositions
|
||||||
self.customer = customer
|
self.customer = customer
|
||||||
|
self._discount_cache = {}
|
||||||
|
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
if self.event.has_subevents:
|
if self.event.has_subevents:
|
||||||
@@ -111,6 +112,13 @@ class CrossSellingService:
|
|||||||
}
|
}
|
||||||
return category.items.filter(pk__in=potential_discount_items), potential_discount_items
|
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
|
@cached_property
|
||||||
def _potential_discounts_by_subevent_and_item_for_current_cart(self):
|
def _potential_discounts_by_subevent_and_item_for_current_cart(self):
|
||||||
potential_discounts_by_cartpos = defaultdict(list)
|
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.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.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
|
for cp in self.cartpositions
|
||||||
],
|
],
|
||||||
collect_potential_discounts=potential_discounts_by_cartpos
|
collect_potential_discounts=potential_discounts_by_cartpos
|
||||||
@@ -177,6 +186,7 @@ class CrossSellingService:
|
|||||||
)
|
)
|
||||||
if self.customer else None
|
if self.customer else None
|
||||||
),
|
),
|
||||||
|
_discount_cache=self._discount_cache,
|
||||||
)
|
)
|
||||||
new_items = list()
|
new_items = list()
|
||||||
for item in items:
|
for item in items:
|
||||||
|
|||||||
@@ -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,
|
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):
|
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 customer: Customer to validate for
|
||||||
:param positions: List of order or cart positions
|
:param positions: List of order or cart positions
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ from django.db.models import (
|
|||||||
from django.db.models.functions import Cast, Greatest
|
from django.db.models.functions import Cast, Greatest
|
||||||
from django.db.transaction import get_connection
|
from django.db.transaction import get_connection
|
||||||
from django.dispatch import receiver
|
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.timezone import make_aware, now
|
||||||
from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
|
from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
|
||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
@@ -923,12 +923,18 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
|
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(
|
discount_results = apply_discounts(
|
||||||
event,
|
event,
|
||||||
sales_channel.identifier,
|
sales_channel.identifier,
|
||||||
[
|
[
|
||||||
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
|
(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
|
for cp in sorted_positions
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from django.db.models import Q
|
|||||||
from pretix.base.decimal import round_decimal
|
from pretix.base.decimal import round_decimal
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
|
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.discount import Discount, PositionInfo
|
||||||
from pretix.base.models.event import Event, SubEvent
|
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],
|
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]]]:
|
collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
|
||||||
"""
|
"""
|
||||||
Applies any dynamic discounts to a cart
|
Applies any dynamic discounts to a cart
|
||||||
|
|
||||||
:param event: Event the cart belongs to
|
:param event: Event the cart belongs to
|
||||||
:param sales_channel: Sales channel the cart was created with
|
: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
|
``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.
|
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
|
: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
|
sales_channel = sales_channel.identifier
|
||||||
new_prices = {}
|
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(
|
discount_qs = event.discounts.filter(
|
||||||
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
|
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(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')
|
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
|
||||||
for discount in discount_qs:
|
for discount in discount_qs:
|
||||||
result = discount.apply({
|
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
|
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)
|
in enumerate(positions)
|
||||||
if not is_bundled and idx not in new_prices
|
if not is_bundled and idx not in new_prices
|
||||||
}, collect_potential_discounts)
|
}, collect_potential_discounts)
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ class DiscountForm(I18nModelForm):
|
|||||||
'limit_sales_channels',
|
'limit_sales_channels',
|
||||||
'available_from',
|
'available_from',
|
||||||
'available_until',
|
'available_until',
|
||||||
|
'require_membership',
|
||||||
|
'require_membership_types',
|
||||||
'subevent_date_from',
|
'subevent_date_from',
|
||||||
'subevent_date_until',
|
'subevent_date_until',
|
||||||
'subevent_mode',
|
'subevent_mode',
|
||||||
@@ -69,6 +71,7 @@ class DiscountForm(I18nModelForm):
|
|||||||
'condition_limit_products': ItemMultipleChoiceField,
|
'condition_limit_products': ItemMultipleChoiceField,
|
||||||
'benefit_limit_products': ItemMultipleChoiceField,
|
'benefit_limit_products': ItemMultipleChoiceField,
|
||||||
'limit_sales_channels': SafeModelMultipleChoiceField,
|
'limit_sales_channels': SafeModelMultipleChoiceField,
|
||||||
|
'require_membership_types': SafeModelMultipleChoiceField,
|
||||||
}
|
}
|
||||||
widgets = {
|
widgets = {
|
||||||
'subevent_mode': forms.RadioSelect,
|
'subevent_mode': forms.RadioSelect,
|
||||||
@@ -88,6 +91,9 @@ class DiscountForm(I18nModelForm):
|
|||||||
'data-display-dependency': '#id_condition_min_count',
|
'data-display-dependency': '#id_condition_min_count',
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
'require_membership_types': forms.CheckboxSelectMultiple(attrs={
|
||||||
|
'class': 'scrolling-multiple-choice'
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
@@ -107,6 +113,13 @@ class DiscountForm(I18nModelForm):
|
|||||||
self.fields['condition_min_value'].required = False
|
self.fields['condition_min_value'].required = False
|
||||||
self.fields['condition_min_value'].widget.is_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:
|
if not self.event.has_subevents:
|
||||||
del self.fields['subevent_mode']
|
del self.fields['subevent_mode']
|
||||||
|
|
||||||
@@ -123,4 +136,6 @@ class DiscountForm(I18nModelForm):
|
|||||||
d['condition_min_count'] = 0
|
d['condition_min_count'] = 0
|
||||||
if d.get('condition_min_value') is None:
|
if d.get('condition_min_value') is None:
|
||||||
d['condition_min_value'] = Decimal('0.00')
|
d['condition_min_value'] = Decimal('0.00')
|
||||||
|
|
||||||
|
Discount.validate_config(d)
|
||||||
return d
|
return d
|
||||||
|
|||||||
@@ -17,6 +17,12 @@
|
|||||||
{% bootstrap_field form.available_until layout="control" %}
|
{% bootstrap_field form.available_until layout="control" %}
|
||||||
{% bootstrap_field form.all_sales_channels layout="control" %}
|
{% bootstrap_field form.all_sales_channels layout="control" %}
|
||||||
{% bootstrap_field form.limit_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>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>{% trans "Condition" context "discount" %}</legend>
|
<legend>{% trans "Condition" context "discount" %}</legend>
|
||||||
|
|||||||
@@ -9,7 +9,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="form-horizontal" action="" method="post">
|
<form class="form-horizontal" action="" method="post">
|
||||||
{% csrf_token %}
|
{% 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">
|
<div class="form-group submit-group">
|
||||||
<button type="submit" class="btn btn-primary btn-save">
|
<button type="submit" class="btn btn-primary btn-save">
|
||||||
{% trans "Save" %}
|
{% trans "Save" %}
|
||||||
|
|||||||
@@ -248,7 +248,19 @@ class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
|
|||||||
raise NotImplementedError()
|
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
|
priority = 45
|
||||||
identifier = "customer"
|
identifier = "customer"
|
||||||
template_name = "pretixpresale/event/checkout_customer.html"
|
template_name = "pretixpresale/event/checkout_customer.html"
|
||||||
@@ -326,28 +338,34 @@ class CustomerStep(CartMixin, TemplateFlowStep):
|
|||||||
customer_login(self.request, customer)
|
customer_login(self.request, customer)
|
||||||
return True
|
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):
|
def post(self, request):
|
||||||
self.request = request
|
self.request = request
|
||||||
|
|
||||||
if request.POST.get("customer_mode") == 'login':
|
if request.POST.get("customer_mode") == 'login':
|
||||||
if self.cart_session.get('customer'):
|
if self.cart_session.get('customer'):
|
||||||
return redirect_to_url(self.get_next_url(request))
|
return self._proceed_to_next()
|
||||||
elif request.customer:
|
elif request.customer:
|
||||||
self.cart_session['customer_mode'] = 'login'
|
self.cart_session['customer_mode'] = 'login'
|
||||||
self.cart_session['customer'] = request.customer.pk
|
self.cart_session['customer'] = request.customer.pk
|
||||||
self.cart_session['customer_cart_tied_to_login'] = True
|
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"):
|
elif self.request.POST.get("login-sso-data"):
|
||||||
if not self._handle_sso_login():
|
if not self._handle_sso_login():
|
||||||
messages.error(request, _('We failed to process your authentication request, please try again.'))
|
messages.error(request, _('We failed to process your authentication request, please try again.'))
|
||||||
return self.render()
|
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():
|
elif self.event.settings.customer_accounts_native and self.login_form.is_valid():
|
||||||
customer_login(self.request, self.login_form.get_customer())
|
customer_login(self.request, self.login_form.get_customer())
|
||||||
self.cart_session['customer_mode'] = 'login'
|
self.cart_session['customer_mode'] = 'login'
|
||||||
self.cart_session['customer'] = self.login_form.get_customer().pk
|
self.cart_session['customer'] = self.login_form.get_customer().pk
|
||||||
self.cart_session['customer_cart_tied_to_login'] = True
|
self.cart_session['customer_cart_tied_to_login'] = True
|
||||||
return redirect_to_url(self.get_next_url(request))
|
return self._proceed_to_next()
|
||||||
else:
|
else:
|
||||||
return self.render()
|
return self.render()
|
||||||
elif request.POST.get("customer_mode") == 'register' and self.signup_allowed:
|
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_mode'] = 'login'
|
||||||
self.cart_session['customer'] = customer.pk
|
self.cart_session['customer'] = customer.pk
|
||||||
self.cart_session['customer_cart_tied_to_login'] = False
|
self.cart_session['customer_cart_tied_to_login'] = False
|
||||||
return redirect_to_url(self.get_next_url(request))
|
return self._proceed_to_next()
|
||||||
else:
|
else:
|
||||||
return self.render()
|
return self.render()
|
||||||
elif request.POST.get("customer_mode") == 'guest' and self.guest_allowed:
|
elif request.POST.get("customer_mode") == 'guest' and self.guest_allowed:
|
||||||
self.cart_session['customer'] = None
|
self.cart_session['customer'] = None
|
||||||
self.cart_session['customer_mode'] = 'guest'
|
self.cart_session['customer_mode'] = 'guest'
|
||||||
return redirect_to_url(self.get_next_url(request))
|
return self._proceed_to_next()
|
||||||
else:
|
else:
|
||||||
return self.render()
|
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)
|
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),
|
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(),
|
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
|
priority = 50
|
||||||
identifier = "questions"
|
identifier = "questions"
|
||||||
template_name = "pretixpresale/event/checkout_questions.html"
|
template_name = "pretixpresale/event/checkout_questions.html"
|
||||||
@@ -986,6 +1010,7 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
def post(self, request):
|
def post(self, request):
|
||||||
self.request = request
|
self.request = request
|
||||||
failed = not self.save() or not self.contact_form.is_valid()
|
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:
|
if self.address_asked or self.request.event.settings.invoice_name_required:
|
||||||
failed = failed or not self.invoice_form.is_valid()
|
failed = failed or not self.invoice_form.is_valid()
|
||||||
if failed:
|
if failed:
|
||||||
@@ -1030,13 +1055,8 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
|
|||||||
self.cart_session['saved_invoice_address'] = saved.pk
|
self.cart_session['saved_invoice_address'] = saved.pk
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cm = CartManager(
|
diff = self.recompute_cart()
|
||||||
event=self.request.event,
|
recomputed = True
|
||||||
cart_id=get_or_create_cart_id(request),
|
|
||||||
invoice_address=addr,
|
|
||||||
sales_channel=request.sales_channel,
|
|
||||||
)
|
|
||||||
diff = cm.recompute_final_prices_and_taxes()
|
|
||||||
except TaxRule.SaleNotAllowed:
|
except TaxRule.SaleNotAllowed:
|
||||||
messages.error(request,
|
messages.error(request,
|
||||||
_("Unfortunately, based on the invoice address you entered, we're not able to sell you "
|
_("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
|
self.cart_session['invoice_address'] = addr.pk
|
||||||
if abs(diff) > Decimal('0.001'):
|
if abs(diff) > Decimal('0.001'):
|
||||||
messages.info(request, _('Due to the invoice address you entered, we need to apply a different tax '
|
messages.info(request, _('Due to the information you entered, tax rates or discounts on your purchase '
|
||||||
'rate to your purchase and the price of the products in your cart has '
|
'have been changed and the price of the products in your cart has changed '
|
||||||
'changed accordingly.'))
|
'accordingly.'))
|
||||||
return redirect_to_url(self.get_next_url(request) + '?open_cart=true')
|
return redirect_to_url(self.get_next_url(request) + '?open_cart=true')
|
||||||
elif 'invoice_address' in self.cart_session:
|
elif 'invoice_address' in self.cart_session:
|
||||||
# Invoice address was there, but is no longer asked for
|
# Invoice address was there, but is no longer asked for
|
||||||
self.invoice_address.delete()
|
self.invoice_address.delete()
|
||||||
del self.cart_session['invoice_address']
|
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:
|
try:
|
||||||
validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False,
|
validate_memberships_in_order(self.cart_customer, self.positions, self.request.event, lock=False,
|
||||||
testmode=self.request.event.testmode, valid_from_not_chosen=False)
|
testmode=self.request.event.testmode, valid_from_not_chosen=False)
|
||||||
|
|||||||
@@ -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, Membership, 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,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,
|
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,
|
||||||
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_set = base_qs is not None
|
||||||
base_qs = base_qs if base_qs is not None else event.items
|
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:
|
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.
|
||||||
|
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
|
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 +327,11 @@ 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_matches_product(item) and d.valid_for_memberships(memberships, event, subevent, item)
|
||||||
|
]
|
||||||
|
|
||||||
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 +359,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 +433,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)
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ from django.views.generic import TemplateView, View
|
|||||||
from django_scopes import scopes_disabled
|
from django_scopes import scopes_disabled
|
||||||
|
|
||||||
from pretix.base.models import (
|
from pretix.base.models import (
|
||||||
CartPosition, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent, Voucher,
|
CartPosition, Customer, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent,
|
||||||
|
Voucher,
|
||||||
)
|
)
|
||||||
from pretix.base.services.cart import (
|
from pretix.base.services.cart import (
|
||||||
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
|
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
|
||||||
@@ -153,6 +154,17 @@ class CartActionMixin:
|
|||||||
except InvoiceAddress.DoesNotExist:
|
except InvoiceAddress.DoesNotExist:
|
||||||
return InvoiceAddress()
|
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):
|
def _item_from_post_value(request, key, value, voucher=None, voucher_ignore_if_redeemed=False):
|
||||||
if value.strip() == '' or '_' not in key:
|
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),
|
return self.do(self.request.event.id, code, get_or_create_cart_id(self.request),
|
||||||
translation.get_language(), request.sales_channel.identifier,
|
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:
|
else:
|
||||||
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
|
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
@@ -532,7 +544,7 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
|
|||||||
try:
|
try:
|
||||||
return self.do(self.request.event.id, int(request.POST.get('id')), get_or_create_cart_id(self.request),
|
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,
|
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:
|
except ValueError:
|
||||||
return redirect_to_url(self.get_error_url())
|
return redirect_to_url(self.get_error_url())
|
||||||
else:
|
else:
|
||||||
@@ -555,7 +567,7 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
|
|||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
|
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')
|
@method_decorator(allow_cors_if_namespaced, 'dispatch')
|
||||||
@@ -591,7 +603,7 @@ class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
|
|||||||
|
|
||||||
def post(self, request, *args, **kwargs):
|
def post(self, request, *args, **kwargs):
|
||||||
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
|
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')
|
@method_decorator(allow_cors_if_namespaced, 'dispatch')
|
||||||
@@ -648,7 +660,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
|
|||||||
if items:
|
if items:
|
||||||
return self.do(self.request.event.id, items, cart_id, translation.get_language(),
|
return self.do(self.request.event.id, items, cart_id, translation.get_language(),
|
||||||
self.invoice_address.pk, widget_data, self.request.sales_channel.identifier,
|
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:
|
else:
|
||||||
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
|
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ TEST_DISCOUNT_RES = {
|
|||||||
"sales_channels": ["bar", "baz", "web"],
|
"sales_channels": ["bar", "baz", "web"],
|
||||||
"available_from": None,
|
"available_from": None,
|
||||||
"available_until": None,
|
"available_until": None,
|
||||||
|
"require_membership_hidden": False,
|
||||||
|
"require_membership_types": [],
|
||||||
"subevent_mode": "mixed",
|
"subevent_mode": "mixed",
|
||||||
"subevent_date_from": None,
|
"subevent_date_from": None,
|
||||||
"subevent_date_until": None,
|
"subevent_date_until": None,
|
||||||
|
|||||||
@@ -749,7 +749,7 @@ def test_query_count_many_items(event, itemcount):
|
|||||||
''',
|
''',
|
||||||
recommendations=''' Price Discounted Price Max Count Prefix
|
recommendations=''' Price Discounted Price Max Count Prefix
|
||||||
''',
|
''',
|
||||||
expect_num_queries=8,
|
expect_num_queries=9,
|
||||||
)
|
)
|
||||||
check_cart_behaviour(
|
check_cart_behaviour(
|
||||||
event,
|
event,
|
||||||
@@ -763,7 +763,7 @@ def test_query_count_many_items(event, itemcount):
|
|||||||
recommendations=''' Price Discounted Price Max Count Prefix
|
recommendations=''' Price Discounted Price Max Count Prefix
|
||||||
Tickets Ticket 2 42.00 0.00 1 -
|
Tickets Ticket 2 42.00 0.00 1 -
|
||||||
''',
|
''',
|
||||||
expect_num_queries=9,
|
expect_num_queries=10,
|
||||||
)
|
)
|
||||||
check_cart_behaviour(
|
check_cart_behaviour(
|
||||||
event,
|
event,
|
||||||
@@ -779,7 +779,7 @@ def test_query_count_many_items(event, itemcount):
|
|||||||
recommendations=''' Price Discounted Price Max Count Prefix
|
recommendations=''' Price Discounted Price Max Count Prefix
|
||||||
Tickets Ticket 2 42.00 0.00 1 -
|
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
|
recommendations=''' Price Discounted Price Max Count Prefix
|
||||||
''',
|
''',
|
||||||
expect_num_queries=8,
|
expect_num_queries=9,
|
||||||
)
|
)
|
||||||
check_cart_behaviour(
|
check_cart_behaviour(
|
||||||
event,
|
event,
|
||||||
@@ -817,7 +817,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
|
|||||||
recommendations=''' Price Discounted Price Max Count Prefix
|
recommendations=''' Price Discounted Price Max Count Prefix
|
||||||
Category 1 Ticket 1-B 42.00 0.00 1 -
|
Category 1 Ticket 1-B 42.00 0.00 1 -
|
||||||
''',
|
''',
|
||||||
expect_num_queries=9,
|
expect_num_queries=10,
|
||||||
)
|
)
|
||||||
check_cart_behaviour(
|
check_cart_behaviour(
|
||||||
event,
|
event,
|
||||||
@@ -833,7 +833,7 @@ def test_query_count_many_categories_and_discounts(event, catcount):
|
|||||||
recommendations=''' Price Discounted Price Max Count Prefix
|
recommendations=''' Price Discounted Price Max Count Prefix
|
||||||
Category 1 Ticket 1-B 42.00 0.00 1 -
|
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
|
recommendations=''' Price Discounted Price Max Count Prefix
|
||||||
''',
|
''',
|
||||||
expect_num_queries=8,
|
expect_num_queries=9,
|
||||||
)
|
)
|
||||||
check_cart_behaviour(
|
check_cart_behaviour(
|
||||||
event,
|
event,
|
||||||
@@ -871,7 +871,7 @@ def test_query_count_many_cartpos(event, catcount):
|
|||||||
recommendations=''' Price Discounted Price Max Count Prefix
|
recommendations=''' Price Discounted Price Max Count Prefix
|
||||||
Category 1 Ticket 1-B 42.00 0.00 1 -
|
Category 1 Ticket 1-B 42.00 0.00 1 -
|
||||||
''',
|
''',
|
||||||
expect_num_queries=9,
|
expect_num_queries=10,
|
||||||
)
|
)
|
||||||
check_cart_behaviour(
|
check_cart_behaviour(
|
||||||
event,
|
event,
|
||||||
@@ -893,5 +893,5 @@ def test_query_count_many_cartpos(event, catcount):
|
|||||||
Category 1 Ticket 1-B 42.00 0.00 1 -
|
Category 1 Ticket 1-B 42.00 0.00 1 -
|
||||||
Category 2 Ticket 2-B 42.00 0.00 1 -
|
Category 2 Ticket 2-B 42.00 0.00 1 -
|
||||||
''',
|
''',
|
||||||
expect_num_queries=13,
|
expect_num_queries=14,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user