forked from CGM_Public/pretix_original
Refactor validation of cart contents, fix purchase of inactive subevent (Z#23217806) (#5715)
* Refactor validation of cart contents, fix purchase of inactive subevent (Z#23217806) * Apply suggestions from code review Co-authored-by: Richard Schreiber <schreiber@pretix.eu> * Review notes --------- Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
This commit is contained in:
@@ -97,6 +97,10 @@ class CartError(Exception):
|
|||||||
super().__init__(msg)
|
super().__init__(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class CartPositionError(CartError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
'busy': gettext_lazy(
|
'busy': gettext_lazy(
|
||||||
'We were not able to process your request completely as the '
|
'We were not able to process your request completely as the '
|
||||||
@@ -106,6 +110,9 @@ error_messages = {
|
|||||||
'unknown_position': gettext_lazy('Unknown cart position.'),
|
'unknown_position': gettext_lazy('Unknown cart position.'),
|
||||||
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
|
'subevent_required': pgettext_lazy('subevent', 'No date was specified.'),
|
||||||
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
|
'not_for_sale': gettext_lazy('You selected a product which is not available for sale.'),
|
||||||
|
'positions_removed': gettext_lazy(
|
||||||
|
'Some products can no longer be purchased and have been removed from your cart for the following reason: %s'
|
||||||
|
),
|
||||||
'unavailable': gettext_lazy(
|
'unavailable': gettext_lazy(
|
||||||
'Some of the products you selected are no longer available. '
|
'Some of the products you selected are no longer available. '
|
||||||
'Please see below for details.'
|
'Please see below for details.'
|
||||||
@@ -258,6 +265,138 @@ def _get_voucher_availability(event, voucher_use_diff, now_dt, exclude_position_
|
|||||||
return vouchers_ok, _voucher_depend_on_cart
|
return vouchers_ok, _voucher_depend_on_cart
|
||||||
|
|
||||||
|
|
||||||
|
def _check_position_constraints(
|
||||||
|
event: Event, item: Item, variation: ItemVariation, voucher: Voucher, subevent: SubEvent,
|
||||||
|
seat: Seat, sales_channel: SalesChannel, already_in_cart: bool, cart_is_expired: bool, real_now_dt: datetime,
|
||||||
|
item_requires_seat: bool, is_addon: bool, is_bundled: bool,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Checks if a cart position with the given constraints can still be sold. This checks configuration and time-based
|
||||||
|
constraints of item, subevent, and voucher.
|
||||||
|
|
||||||
|
It does NOT
|
||||||
|
- check if quota/voucher/seat are still available
|
||||||
|
- check prices
|
||||||
|
- check memberships
|
||||||
|
- perform any checks that go beyond the single line (like item.max_per_order)
|
||||||
|
"""
|
||||||
|
time_machine_now_dt = time_machine_now(real_now_dt)
|
||||||
|
# Item or variation disabled
|
||||||
|
# Item disabled or unavailable by time
|
||||||
|
if not item.is_available(time_machine_now_dt) or (variation and not variation.is_available(time_machine_now_dt)):
|
||||||
|
raise CartPositionError(error_messages['unavailable'])
|
||||||
|
|
||||||
|
# Invalid media policy for online sale
|
||||||
|
if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
||||||
|
mt = MEDIA_TYPES[item.media_type]
|
||||||
|
if not mt.medium_created_by_server:
|
||||||
|
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
||||||
|
elif item.media_policy == Item.MEDIA_POLICY_REUSE:
|
||||||
|
raise CartPositionError(error_messages['media_usage_not_implemented'])
|
||||||
|
|
||||||
|
# Item removed from sales channel
|
||||||
|
if not item.all_sales_channels:
|
||||||
|
if sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
|
||||||
|
raise CartPositionError(error_messages['unavailable'])
|
||||||
|
|
||||||
|
# Variation removed from sales channel
|
||||||
|
if variation and not variation.all_sales_channels:
|
||||||
|
if sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
|
||||||
|
raise CartPositionError(error_messages['unavailable'])
|
||||||
|
|
||||||
|
# Item disabled or unavailable by time in subevent
|
||||||
|
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(time_machine_now_dt):
|
||||||
|
raise CartPositionError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
|
# Variation disabled or unavailable by time in subevent
|
||||||
|
if subevent and variation and variation.pk in subevent.var_overrides and \
|
||||||
|
not subevent.var_overrides[variation.pk].is_available(time_machine_now_dt):
|
||||||
|
raise CartPositionError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
|
# Item requires a variation (should never happen)
|
||||||
|
if item.has_variations and not variation:
|
||||||
|
raise CartPositionError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
|
# Variation belongs to wrong item (should never happen)
|
||||||
|
if variation and variation.item_id != item.pk:
|
||||||
|
raise CartPositionError(error_messages['not_for_sale'])
|
||||||
|
|
||||||
|
# Voucher does not apply to product
|
||||||
|
if voucher and not voucher.applies_to(item, variation):
|
||||||
|
raise CartPositionError(error_messages['voucher_invalid_item'])
|
||||||
|
|
||||||
|
# Voucher does not apply to seat
|
||||||
|
if voucher and voucher.seat and voucher.seat != seat:
|
||||||
|
raise CartPositionError(error_messages['voucher_invalid_seat'])
|
||||||
|
|
||||||
|
# Voucher does not apply to subevent
|
||||||
|
if voucher and voucher.subevent_id and voucher.subevent_id != subevent.pk:
|
||||||
|
raise CartPositionError(error_messages['voucher_invalid_subevent'])
|
||||||
|
|
||||||
|
# Voucher expired
|
||||||
|
if voucher and voucher.valid_until and voucher.valid_until < time_machine_now_dt:
|
||||||
|
raise CartPositionError(error_messages['voucher_expired'])
|
||||||
|
|
||||||
|
# Subevent has been disabled
|
||||||
|
if subevent and not subevent.active:
|
||||||
|
raise CartPositionError(error_messages['inactive_subevent'])
|
||||||
|
|
||||||
|
# Subevent sale not started
|
||||||
|
if subevent and subevent.effective_presale_start and time_machine_now_dt < subevent.effective_presale_start:
|
||||||
|
raise CartPositionError(error_messages['not_started'])
|
||||||
|
|
||||||
|
# Subevent sale has ended
|
||||||
|
if subevent and subevent.presale_has_ended:
|
||||||
|
raise CartPositionError(error_messages['ended'])
|
||||||
|
|
||||||
|
# Payment for subevent no longer possible
|
||||||
|
if subevent:
|
||||||
|
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
||||||
|
if tlv:
|
||||||
|
term_last = make_aware(datetime.combine(
|
||||||
|
tlv.datetime(subevent).date(),
|
||||||
|
time(hour=23, minute=59, second=59)
|
||||||
|
), event.timezone)
|
||||||
|
if term_last < time_machine_now_dt:
|
||||||
|
raise CartPositionError(error_messages['payment_ended'])
|
||||||
|
|
||||||
|
# Seat required but no seat given
|
||||||
|
if item_requires_seat and not seat:
|
||||||
|
raise CartPositionError(error_messages['seat_invalid'])
|
||||||
|
|
||||||
|
# Seat given but no seat required
|
||||||
|
if seat and not item_requires_seat:
|
||||||
|
raise CartPositionError(error_messages['seat_forbidden'])
|
||||||
|
|
||||||
|
# Item requires to be add-on but is top-level position
|
||||||
|
if item.category and item.category.is_addon and not is_addon:
|
||||||
|
raise CartPositionError(error_messages['addon_only'])
|
||||||
|
|
||||||
|
# Item requires bundling but is top-level position
|
||||||
|
if item.require_bundling and not is_bundled:
|
||||||
|
raise CartPositionError(error_messages['bundled_only'])
|
||||||
|
|
||||||
|
# Seat for wrong product
|
||||||
|
if seat and seat.product != item:
|
||||||
|
raise CartPositionError(error_messages['seat_invalid'])
|
||||||
|
|
||||||
|
# Seat blocked
|
||||||
|
if seat and seat.blocked and sales_channel.identifier not in event.settings.seating_allow_blocked_seats_for_channel:
|
||||||
|
raise CartPositionError(error_messages['seat_invalid'])
|
||||||
|
|
||||||
|
# Item requires voucher but no voucher given
|
||||||
|
if item.require_voucher and voucher is None and not is_bundled:
|
||||||
|
raise CartPositionError(error_messages['voucher_required'])
|
||||||
|
|
||||||
|
# Item or variation is hidden without voucher but no voucher is given
|
||||||
|
if (
|
||||||
|
(item.hide_without_voucher or (variation and variation.hide_without_voucher)) and
|
||||||
|
(voucher is None or not voucher.show_hidden_items) and
|
||||||
|
not is_bundled
|
||||||
|
):
|
||||||
|
raise CartPositionError(error_messages['voucher_required'])
|
||||||
|
|
||||||
|
|
||||||
class CartManager:
|
class CartManager:
|
||||||
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
|
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'voucher', 'quotas',
|
||||||
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
|
'addon_to', 'subevent', 'bundled', 'seat', 'listed_price',
|
||||||
@@ -294,6 +433,7 @@ class CartManager:
|
|||||||
self._widget_data = widget_data or {}
|
self._widget_data = widget_data or {}
|
||||||
self._sales_channel = sales_channel
|
self._sales_channel = sales_channel
|
||||||
self.num_extended_positions = 0
|
self.num_extended_positions = 0
|
||||||
|
self.price_change_for_extended = False
|
||||||
|
|
||||||
if reservation_time:
|
if reservation_time:
|
||||||
self._reservation_time = reservation_time
|
self._reservation_time = reservation_time
|
||||||
@@ -421,14 +561,14 @@ class CartManager:
|
|||||||
if cartsize > limit:
|
if cartsize > limit:
|
||||||
raise CartError(error_messages['max_items'] % limit)
|
raise CartError(error_messages['max_items'] % limit)
|
||||||
|
|
||||||
def _check_item_constraints(self, op, current_ops=[]):
|
def _check_item_constraints(self, op):
|
||||||
if isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
if isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
||||||
if not (
|
if not (
|
||||||
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
|
(isinstance(op, self.AddOperation) and op.addon_to == 'FAKE') or
|
||||||
(isinstance(op, self.ExtendOperation) and op.position.is_bundled)
|
(isinstance(op, self.ExtendOperation) and op.position.is_bundled)
|
||||||
):
|
):
|
||||||
if op.item.require_voucher and op.voucher is None:
|
if op.item.require_voucher and op.voucher is None:
|
||||||
if getattr(op, 'voucher_ignored', False):
|
if getattr(op, 'voucher_ignored', False): # todo??
|
||||||
raise CartError(error_messages['voucher_redeemed'])
|
raise CartError(error_messages['voucher_redeemed'])
|
||||||
raise CartError(error_messages['voucher_required'])
|
raise CartError(error_messages['voucher_required'])
|
||||||
|
|
||||||
@@ -440,88 +580,39 @@ class CartManager:
|
|||||||
raise CartError(error_messages['voucher_redeemed'])
|
raise CartError(error_messages['voucher_redeemed'])
|
||||||
raise CartError(error_messages['voucher_required'])
|
raise CartError(error_messages['voucher_required'])
|
||||||
|
|
||||||
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
|
if op.seat and op.count > 1:
|
||||||
raise CartError(error_messages['unavailable'])
|
|
||||||
|
|
||||||
if op.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
|
|
||||||
mt = MEDIA_TYPES[op.item.media_type]
|
|
||||||
if not mt.medium_created_by_server:
|
|
||||||
raise CartError(error_messages['media_usage_not_implemented'])
|
|
||||||
elif op.item.media_policy == Item.MEDIA_POLICY_REUSE:
|
|
||||||
raise CartError(error_messages['media_usage_not_implemented'])
|
|
||||||
|
|
||||||
if not op.item.all_sales_channels:
|
|
||||||
if self._sales_channel.identifier not in (s.identifier for s in op.item.limit_sales_channels.all()):
|
|
||||||
raise CartError(error_messages['unavailable'])
|
|
||||||
|
|
||||||
if op.variation and not op.variation.all_sales_channels:
|
|
||||||
if self._sales_channel.identifier not in (s.identifier for s in op.variation.limit_sales_channels.all()):
|
|
||||||
raise CartError(error_messages['unavailable'])
|
|
||||||
|
|
||||||
if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
|
|
||||||
raise CartError(error_messages['not_for_sale'])
|
|
||||||
|
|
||||||
if op.subevent and op.variation and op.variation.pk in op.subevent.var_overrides and \
|
|
||||||
not op.subevent.var_overrides[op.variation.pk].is_available():
|
|
||||||
raise CartError(error_messages['not_for_sale'])
|
|
||||||
|
|
||||||
if op.item.has_variations and not op.variation:
|
|
||||||
raise CartError(error_messages['not_for_sale'])
|
|
||||||
|
|
||||||
if op.variation and op.variation.item_id != op.item.pk:
|
|
||||||
raise CartError(error_messages['not_for_sale'])
|
|
||||||
|
|
||||||
if op.voucher and not op.voucher.applies_to(op.item, op.variation):
|
|
||||||
raise CartError(error_messages['voucher_invalid_item'])
|
|
||||||
|
|
||||||
if op.voucher and op.voucher.seat and op.voucher.seat != op.seat:
|
|
||||||
raise CartError(error_messages['voucher_invalid_seat'])
|
|
||||||
|
|
||||||
if op.voucher and op.voucher.subevent_id and op.voucher.subevent_id != op.subevent.pk:
|
|
||||||
raise CartError(error_messages['voucher_invalid_subevent'])
|
|
||||||
|
|
||||||
if op.subevent and not op.subevent.active:
|
|
||||||
raise CartError(error_messages['inactive_subevent'])
|
|
||||||
|
|
||||||
if op.subevent and op.subevent.presale_start and time_machine_now(self.real_now_dt) < op.subevent.presale_start:
|
|
||||||
raise CartError(error_messages['not_started'])
|
|
||||||
|
|
||||||
if op.subevent and op.subevent.presale_has_ended:
|
|
||||||
raise CartError(error_messages['ended'])
|
|
||||||
|
|
||||||
seated = self._is_seated(op.item, op.subevent)
|
|
||||||
if (
|
|
||||||
seated and (
|
|
||||||
not op.seat or (
|
|
||||||
op.seat.blocked and
|
|
||||||
self._sales_channel.identifier not in self.event.settings.seating_allow_blocked_seats_for_channel
|
|
||||||
)
|
|
||||||
)
|
|
||||||
):
|
|
||||||
raise CartError(error_messages['seat_invalid'])
|
|
||||||
elif op.seat and not seated:
|
|
||||||
raise CartError(error_messages['seat_forbidden'])
|
|
||||||
elif op.seat and op.seat.product != op.item:
|
|
||||||
raise CartError(error_messages['seat_invalid'])
|
|
||||||
elif op.seat and op.count > 1:
|
|
||||||
raise CartError('Invalid request: A seat can only be bought once.')
|
raise CartError('Invalid request: A seat can only be bought once.')
|
||||||
|
|
||||||
if op.subevent:
|
if isinstance(op, self.AddOperation):
|
||||||
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
is_addon = op.addon_to
|
||||||
if tlv:
|
is_bundled = op.addon_to == "FAKE"
|
||||||
term_last = make_aware(datetime.combine(
|
else:
|
||||||
tlv.datetime(op.subevent).date(),
|
is_addon = op.position.addon_to
|
||||||
time(hour=23, minute=59, second=59)
|
is_bundled = op.position.is_bundled
|
||||||
), self.event.timezone)
|
|
||||||
if term_last < time_machine_now(self.real_now_dt):
|
|
||||||
raise CartError(error_messages['payment_ended'])
|
|
||||||
|
|
||||||
if isinstance(op, self.AddOperation):
|
try:
|
||||||
if op.item.category and op.item.category.is_addon and not (op.addon_to and op.addon_to != 'FAKE'):
|
_check_position_constraints(
|
||||||
raise CartError(error_messages['addon_only'])
|
event=self.event,
|
||||||
|
item=op.item,
|
||||||
if op.item.require_bundling and not op.addon_to == 'FAKE':
|
variation=op.variation,
|
||||||
raise CartError(error_messages['bundled_only'])
|
voucher=op.voucher,
|
||||||
|
subevent=op.subevent,
|
||||||
|
seat=op.seat,
|
||||||
|
sales_channel=self._sales_channel,
|
||||||
|
already_in_cart=isinstance(op, self.ExtendOperation),
|
||||||
|
cart_is_expired=isinstance(op, self.ExtendOperation),
|
||||||
|
real_now_dt=self.real_now_dt,
|
||||||
|
item_requires_seat=self._is_seated(op.item, op.subevent),
|
||||||
|
is_addon=is_addon,
|
||||||
|
is_bundled=is_bundled,
|
||||||
|
)
|
||||||
|
# Quota, seat, and voucher availability is checked for in perform_operations
|
||||||
|
# Price changes are checked for in extend_expired_positions
|
||||||
|
except CartPositionError as e:
|
||||||
|
if e.args[0] == error_messages['voucher_required'] and getattr(op, 'voucher_ignored', False):
|
||||||
|
# This is the case where someone clicks +1 on a voucher-only item with a fully redeemed voucher:
|
||||||
|
raise CartPositionError(error_messages['voucher_redeemed'])
|
||||||
|
raise
|
||||||
|
|
||||||
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
def _get_price(self, item: Item, variation: Optional[ItemVariation],
|
||||||
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
voucher: Optional[Voucher], custom_price: Optional[Decimal],
|
||||||
@@ -604,7 +695,11 @@ class CartManager:
|
|||||||
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
|
quotas=quotas, subevent=cp.subevent, seat=cp.seat, listed_price=listed_price,
|
||||||
price_after_voucher=price_after_voucher,
|
price_after_voucher=price_after_voucher,
|
||||||
)
|
)
|
||||||
self._check_item_constraints(op)
|
try:
|
||||||
|
self._check_item_constraints(op)
|
||||||
|
except CartPositionError as e:
|
||||||
|
self._operations.append(self.RemoveOperation(position=cp))
|
||||||
|
err = error_messages['positions_removed'] % str(e)
|
||||||
|
|
||||||
if cp.voucher:
|
if cp.voucher:
|
||||||
self._voucher_use_diff[cp.voucher] += 2
|
self._voucher_use_diff[cp.voucher] += 2
|
||||||
@@ -797,7 +892,7 @@ class CartManager:
|
|||||||
custom_price_input_is_net=False,
|
custom_price_input_is_net=False,
|
||||||
voucher_ignored=False,
|
voucher_ignored=False,
|
||||||
)
|
)
|
||||||
self._check_item_constraints(bop, operations)
|
self._check_item_constraints(bop)
|
||||||
bundled.append(bop)
|
bundled.append(bop)
|
||||||
|
|
||||||
listed_price = get_listed_price(item, variation, subevent)
|
listed_price = get_listed_price(item, variation, subevent)
|
||||||
@@ -836,7 +931,7 @@ class CartManager:
|
|||||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||||
voucher_ignored=voucher_ignored,
|
voucher_ignored=voucher_ignored,
|
||||||
)
|
)
|
||||||
self._check_item_constraints(op, operations)
|
self._check_item_constraints(op)
|
||||||
operations.append(op)
|
operations.append(op)
|
||||||
|
|
||||||
self._quota_diff.update(quota_diff)
|
self._quota_diff.update(quota_diff)
|
||||||
@@ -975,7 +1070,7 @@ class CartManager:
|
|||||||
custom_price_input_is_net=self.event.settings.display_net_prices,
|
custom_price_input_is_net=self.event.settings.display_net_prices,
|
||||||
voucher_ignored=False,
|
voucher_ignored=False,
|
||||||
)
|
)
|
||||||
self._check_item_constraints(op, operations)
|
self._check_item_constraints(op)
|
||||||
operations.append(op)
|
operations.append(op)
|
||||||
|
|
||||||
# Check constraints on the add-on combinations
|
# Check constraints on the add-on combinations
|
||||||
@@ -1172,7 +1267,9 @@ class CartManager:
|
|||||||
op.position.delete()
|
op.position.delete()
|
||||||
|
|
||||||
elif isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
elif isinstance(op, (self.AddOperation, self.ExtendOperation)):
|
||||||
# Create a CartPosition for as much items as we can
|
if isinstance(op, self.ExtendOperation) and (op.position.pk in deleted_positions or not op.position.pk):
|
||||||
|
continue # Already deleted in other operation
|
||||||
|
# Create a CartPosition for as many items as we can
|
||||||
requested_count = quota_available_count = voucher_available_count = op.count
|
requested_count = quota_available_count = voucher_available_count = op.count
|
||||||
|
|
||||||
if op.seat:
|
if op.seat:
|
||||||
@@ -1343,6 +1440,8 @@ class CartManager:
|
|||||||
addons.delete()
|
addons.delete()
|
||||||
op.position.delete()
|
op.position.delete()
|
||||||
elif available_count == 1:
|
elif available_count == 1:
|
||||||
|
if op.price_after_voucher != op.position.price_after_voucher:
|
||||||
|
self.price_change_for_extended = True
|
||||||
op.position.expires = self._expiry
|
op.position.expires = self._expiry
|
||||||
op.position.max_extend = self._max_expiry_extend
|
op.position.max_extend = self._max_expiry_extend
|
||||||
op.position.listed_price = op.listed_price
|
op.position.listed_price = op.listed_price
|
||||||
@@ -1444,6 +1543,14 @@ class CartManager:
|
|||||||
|
|
||||||
return diff
|
return diff
|
||||||
|
|
||||||
|
def _remove_parents_if_bundles_are_removed(self):
|
||||||
|
removed_positions = {op.position.pk for op in self._operations if isinstance(op, self.RemoveOperation)}
|
||||||
|
for op in self._operations:
|
||||||
|
if isinstance(op, self.RemoveOperation):
|
||||||
|
if op.position.is_bundled and op.position.addon_to_id not in removed_positions:
|
||||||
|
self._operations.append(self.RemoveOperation(position=op.position.addon_to))
|
||||||
|
removed_positions.add(op.position.addon_to_id)
|
||||||
|
|
||||||
def commit(self):
|
def commit(self):
|
||||||
self._check_presale_dates()
|
self._check_presale_dates()
|
||||||
self._check_max_cart_size()
|
self._check_max_cart_size()
|
||||||
@@ -1453,6 +1560,7 @@ class CartManager:
|
|||||||
err = err or self._check_min_per_voucher()
|
err = err or self._check_min_per_voucher()
|
||||||
|
|
||||||
self._extend_expiry_of_valid_existing_positions()
|
self._extend_expiry_of_valid_existing_positions()
|
||||||
|
self._remove_parents_if_bundles_are_removed()
|
||||||
err = self._perform_operations() or err
|
err = self._perform_operations() or err
|
||||||
self.recompute_final_prices_and_taxes()
|
self.recompute_final_prices_and_taxes()
|
||||||
|
|
||||||
@@ -1708,7 +1816,12 @@ def extend_cart_reservation(self, event: Event, cart_id: str=None, locale='en',
|
|||||||
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)
|
||||||
cm.commit()
|
cm.commit()
|
||||||
return {"success": cm.num_extended_positions, "expiry": cm._expiry, "max_expiry_extend": cm._max_expiry_extend}
|
return {
|
||||||
|
"success": cm.num_extended_positions,
|
||||||
|
"expiry": cm._expiry,
|
||||||
|
"max_expiry_extend": cm._max_expiry_extend,
|
||||||
|
"price_changed": cm.price_change_for_extended,
|
||||||
|
}
|
||||||
except LockTimeoutException:
|
except LockTimeoutException:
|
||||||
self.retry()
|
self.retry()
|
||||||
except (MaxRetriesExceededError, LockTimeoutException):
|
except (MaxRetriesExceededError, LockTimeoutException):
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
|
|||||||
from pretix.base.payment import GiftCardPayment, PaymentException
|
from pretix.base.payment import GiftCardPayment, PaymentException
|
||||||
from pretix.base.reldate import RelativeDateWrapper
|
from pretix.base.reldate import RelativeDateWrapper
|
||||||
from pretix.base.secrets import assign_ticket_secret
|
from pretix.base.secrets import assign_ticket_secret
|
||||||
from pretix.base.services import tickets
|
from pretix.base.services import cart, tickets
|
||||||
from pretix.base.services.invoices import (
|
from pretix.base.services.invoices import (
|
||||||
generate_cancellation, generate_invoice, invoice_qualified,
|
generate_cancellation, generate_invoice, invoice_qualified,
|
||||||
invoice_transmission_separately, order_invoice_transmission_separately,
|
invoice_transmission_separately, order_invoice_transmission_separately,
|
||||||
@@ -130,6 +130,9 @@ class OrderError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
error_messages = {
|
error_messages = {
|
||||||
|
'positions_removed': gettext_lazy(
|
||||||
|
'Some products can no longer be purchased and have been removed from your cart for the following reason: %s'
|
||||||
|
),
|
||||||
'unavailable': gettext_lazy(
|
'unavailable': gettext_lazy(
|
||||||
'Some of the products you selected were no longer available. '
|
'Some of the products you selected were no longer available. '
|
||||||
'Please see below for details.'
|
'Please see below for details.'
|
||||||
@@ -182,14 +185,6 @@ error_messages = {
|
|||||||
'The voucher code used for one of the items in your cart is not valid for this item. We removed this item from your cart.'
|
'The voucher code used for one of the items in your cart is not valid for this item. We removed this item from your cart.'
|
||||||
),
|
),
|
||||||
'voucher_required': gettext_lazy('You need a valid voucher code to order one of the products.'),
|
'voucher_required': gettext_lazy('You need a valid voucher code to order one of the products.'),
|
||||||
'some_subevent_not_started': gettext_lazy(
|
|
||||||
'The booking period for one of the events in your cart has not yet started. The '
|
|
||||||
'affected positions have been removed from your cart.'
|
|
||||||
),
|
|
||||||
'some_subevent_ended': gettext_lazy(
|
|
||||||
'The booking period for one of the events in your cart has ended. The affected '
|
|
||||||
'positions have been removed from your cart.'
|
|
||||||
),
|
|
||||||
'seat_invalid': gettext_lazy('One of the seats in your order was invalid, we removed the position from your cart.'),
|
'seat_invalid': gettext_lazy('One of the seats in your order was invalid, we removed the position from your cart.'),
|
||||||
'seat_unavailable': gettext_lazy('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
'seat_unavailable': gettext_lazy('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
|
||||||
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
|
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
|
||||||
@@ -744,12 +739,37 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
deleted_positions.add(cp.pk)
|
deleted_positions.add(cp.pk)
|
||||||
cp.delete()
|
cp.delete()
|
||||||
|
|
||||||
sorted_positions = sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk))
|
sorted_positions = list(sorted(positions, key=lambda c: (-int(c.is_bundled), c.pk)))
|
||||||
|
|
||||||
for cp in sorted_positions:
|
for cp in sorted_positions:
|
||||||
cp._cached_quotas = list(cp.quotas)
|
cp._cached_quotas = list(cp.quotas)
|
||||||
|
|
||||||
|
for cp in sorted_positions:
|
||||||
|
try:
|
||||||
|
cart._check_position_constraints(
|
||||||
|
event=event,
|
||||||
|
item=cp.item,
|
||||||
|
variation=cp.variation,
|
||||||
|
voucher=cp.voucher,
|
||||||
|
subevent=cp.subevent,
|
||||||
|
seat=cp.seat,
|
||||||
|
sales_channel=sales_channel,
|
||||||
|
already_in_cart=True,
|
||||||
|
cart_is_expired=cp.expires < now_dt,
|
||||||
|
real_now_dt=now_dt,
|
||||||
|
item_requires_seat=cp.requires_seat,
|
||||||
|
is_addon=bool(cp.addon_to_id),
|
||||||
|
is_bundled=bool(cp.addon_to_id) and cp.is_bundled,
|
||||||
|
)
|
||||||
|
# Quota, seat, and voucher availability is checked for below
|
||||||
|
# Prices are checked for below
|
||||||
|
# Memberships are checked in _create_order
|
||||||
|
except cart.CartPositionError as e:
|
||||||
|
err = error_messages['positions_removed'] % str(e)
|
||||||
|
delete(cp)
|
||||||
|
|
||||||
# Create locks
|
# Create locks
|
||||||
|
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
|
||||||
if any(cp.expires < now() + timedelta(seconds=LOCK_TRUST_WINDOW) for cp in sorted_positions):
|
if any(cp.expires < now() + timedelta(seconds=LOCK_TRUST_WINDOW) for cp in sorted_positions):
|
||||||
# No need to perform any locking if the cart positions still guarantee everything long enough.
|
# No need to perform any locking if the cart positions still guarantee everything long enough.
|
||||||
full_lock_required = any(
|
full_lock_required = any(
|
||||||
@@ -774,15 +794,12 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
|
|
||||||
# Check availability
|
# Check availability
|
||||||
for i, cp in enumerate(sorted_positions):
|
for i, cp in enumerate(sorted_positions):
|
||||||
if cp.pk in deleted_positions:
|
if cp.pk in deleted_positions or not cp.pk:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if not cp.item.is_available() or (cp.variation and not cp.variation.is_available()):
|
|
||||||
err = err or error_messages['unavailable']
|
|
||||||
delete(cp)
|
|
||||||
continue
|
|
||||||
quotas = cp._cached_quotas
|
quotas = cp._cached_quotas
|
||||||
|
|
||||||
|
# Product per order limits
|
||||||
products_seen[cp.item] += 1
|
products_seen[cp.item] += 1
|
||||||
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
if cp.item.max_per_order and products_seen[cp.item] > cp.item.max_per_order:
|
||||||
err = error_messages['max_items_per_product'] % {
|
err = error_messages['max_items_per_product'] % {
|
||||||
@@ -792,6 +809,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
delete(cp)
|
delete(cp)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Voucher availability
|
||||||
if cp.voucher:
|
if cp.voucher:
|
||||||
v_usages[cp.voucher] += 1
|
v_usages[cp.voucher] += 1
|
||||||
if cp.voucher not in v_avail:
|
if cp.voucher not in v_avail:
|
||||||
@@ -806,48 +824,14 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
delete(cp)
|
delete(cp)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cp.subevent and cp.subevent.presale_start and time_machine_now_dt < cp.subevent.presale_start:
|
# Check duplicate seats in order
|
||||||
err = err or error_messages['some_subevent_not_started']
|
if cp.seat in seats_seen:
|
||||||
delete(cp)
|
|
||||||
break
|
|
||||||
|
|
||||||
if cp.subevent:
|
|
||||||
tlv = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
|
|
||||||
if tlv:
|
|
||||||
term_last = make_aware(datetime.combine(
|
|
||||||
tlv.datetime(cp.subevent).date(),
|
|
||||||
time(hour=23, minute=59, second=59)
|
|
||||||
), event.timezone)
|
|
||||||
if term_last < time_machine_now_dt:
|
|
||||||
err = err or error_messages['some_subevent_ended']
|
|
||||||
delete(cp)
|
|
||||||
break
|
|
||||||
|
|
||||||
if cp.subevent and cp.subevent.presale_has_ended:
|
|
||||||
err = err or error_messages['some_subevent_ended']
|
|
||||||
delete(cp)
|
|
||||||
break
|
|
||||||
|
|
||||||
if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen:
|
|
||||||
err = err or error_messages['seat_invalid']
|
err = err or error_messages['seat_invalid']
|
||||||
delete(cp)
|
delete(cp)
|
||||||
break
|
break
|
||||||
|
|
||||||
if cp.seat:
|
if cp.seat:
|
||||||
seats_seen.add(cp.seat)
|
seats_seen.add(cp.seat)
|
||||||
|
|
||||||
if cp.item.require_voucher and cp.voucher is None and not cp.is_bundled:
|
|
||||||
delete(cp)
|
|
||||||
err = err or error_messages['voucher_required']
|
|
||||||
break
|
|
||||||
|
|
||||||
if (cp.item.hide_without_voucher or (cp.variation and cp.variation.hide_without_voucher)) and (
|
|
||||||
cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)
|
|
||||||
) and not cp.is_bundled:
|
|
||||||
delete(cp)
|
|
||||||
err = error_messages['voucher_required']
|
|
||||||
break
|
|
||||||
|
|
||||||
if cp.seat:
|
|
||||||
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every
|
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every
|
||||||
# time, since we absolutely can not overbook a seat.
|
# time, since we absolutely can not overbook a seat.
|
||||||
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier):
|
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier):
|
||||||
@@ -855,34 +839,13 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
delete(cp)
|
delete(cp)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cp.expires >= now_dt and not cp.voucher:
|
# Check useful quota configuration
|
||||||
# Other checks are not necessary
|
|
||||||
continue
|
|
||||||
|
|
||||||
if len(quotas) == 0:
|
if len(quotas) == 0:
|
||||||
err = err or error_messages['unavailable']
|
err = err or error_messages['unavailable']
|
||||||
delete(cp)
|
delete(cp)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(time_machine_now_dt):
|
|
||||||
err = err or error_messages['unavailable']
|
|
||||||
delete(cp)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and \
|
|
||||||
not cp.subevent.var_overrides[cp.variation.pk].is_available(time_machine_now_dt):
|
|
||||||
err = err or error_messages['unavailable']
|
|
||||||
delete(cp)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if cp.voucher:
|
|
||||||
if cp.voucher.valid_until and cp.voucher.valid_until < time_machine_now_dt:
|
|
||||||
err = err or error_messages['voucher_expired']
|
|
||||||
delete(cp)
|
|
||||||
continue
|
|
||||||
|
|
||||||
quota_ok = True
|
quota_ok = True
|
||||||
|
|
||||||
ignore_all_quotas = cp.expires >= now_dt or (
|
ignore_all_quotas = cp.expires >= now_dt or (
|
||||||
cp.voucher and (
|
cp.voucher and (
|
||||||
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
|
cp.voucher.allow_ignore_quota or (cp.voucher.block_quota and cp.voucher.quota is None)
|
||||||
@@ -914,7 +877,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Check prices
|
# Check prices
|
||||||
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
|
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
|
||||||
old_total = sum(cp.price for cp in sorted_positions)
|
old_total = sum(cp.price for cp in sorted_positions)
|
||||||
for i, cp in enumerate(sorted_positions):
|
for i, cp in enumerate(sorted_positions):
|
||||||
if cp.listed_price is None:
|
if cp.listed_price is None:
|
||||||
@@ -945,7 +908,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
|
|||||||
delete(cp)
|
delete(cp)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
|
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] # eliminate deleted
|
||||||
discount_results = apply_discounts(
|
discount_results = apply_discounts(
|
||||||
event,
|
event,
|
||||||
sales_channel.identifier,
|
sales_channel.identifier,
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ def cart_session(request):
|
|||||||
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
||||||
class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
|
class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||||
task = apply_voucher
|
task = apply_voucher
|
||||||
known_errortypes = ['CartError']
|
known_errortypes = ['CartError', 'CartPositionError']
|
||||||
|
|
||||||
def get_success_message(self, value):
|
def get_success_message(self, value):
|
||||||
return _('We applied the voucher to as many products in your cart as we could.')
|
return _('We applied the voucher to as many products in your cart as we could.')
|
||||||
@@ -513,7 +513,7 @@ class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
|
|||||||
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
||||||
class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
|
class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||||
task = remove_cart_position
|
task = remove_cart_position
|
||||||
known_errortypes = ['CartError']
|
known_errortypes = ['CartError', 'CartPositionError']
|
||||||
|
|
||||||
def get_success_message(self, value):
|
def get_success_message(self, value):
|
||||||
if CartPosition.objects.filter(cart_id=get_or_create_cart_id(self.request)).exists():
|
if CartPosition.objects.filter(cart_id=get_or_create_cart_id(self.request)).exists():
|
||||||
@@ -542,7 +542,7 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
|
|||||||
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
||||||
class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
|
class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||||
task = clear_cart
|
task = clear_cart
|
||||||
known_errortypes = ['CartError']
|
known_errortypes = ['CartError', 'CartPositionError']
|
||||||
|
|
||||||
def get_success_message(self, value):
|
def get_success_message(self, value):
|
||||||
create_empty_cart_id(self.request)
|
create_empty_cart_id(self.request)
|
||||||
@@ -556,7 +556,7 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
|
|||||||
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
@method_decorator(allow_frame_if_namespaced, 'dispatch')
|
||||||
class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
|
class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||||
task = extend_cart_reservation
|
task = extend_cart_reservation
|
||||||
known_errortypes = ['CartError']
|
known_errortypes = ['CartError', 'CartPositionError']
|
||||||
|
|
||||||
def _ajax_response_data(self, value):
|
def _ajax_response_data(self, value):
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
@@ -566,7 +566,11 @@ class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
|
|||||||
|
|
||||||
def get_success_message(self, value):
|
def get_success_message(self, value):
|
||||||
if value['success'] > 0:
|
if value['success'] > 0:
|
||||||
return _('Your cart timeout was extended.')
|
if value.get('price_changed'):
|
||||||
|
return _('Your cart timeout was extended. Please note that some of the prices in your cart '
|
||||||
|
'changed.')
|
||||||
|
else:
|
||||||
|
return _('Your cart timeout was extended.')
|
||||||
|
|
||||||
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(),
|
||||||
@@ -578,7 +582,7 @@ class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
|
|||||||
@method_decorator(iframe_entry_view_wrapper, 'dispatch')
|
@method_decorator(iframe_entry_view_wrapper, 'dispatch')
|
||||||
class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
|
class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
|
||||||
task = add_items_to_cart
|
task = add_items_to_cart
|
||||||
known_errortypes = ['CartError']
|
known_errortypes = ['CartError', 'CartPositionError']
|
||||||
|
|
||||||
def get_success_message(self, value):
|
def get_success_message(self, value):
|
||||||
return _('The products have been successfully added to your cart.')
|
return _('The products have been successfully added to your cart.')
|
||||||
|
|||||||
@@ -745,6 +745,8 @@ def test_use_membership(event, customer, membership, requiring_ticket):
|
|||||||
item=requiring_ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123",
|
item=requiring_ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123",
|
||||||
used_membership=membership
|
used_membership=membership
|
||||||
)
|
)
|
||||||
|
q = event.quotas.create(size=None, name="foo")
|
||||||
|
q.items.add(requiring_ticket)
|
||||||
order = _create_order(event, email='dummy@example.org', positions=[cp1],
|
order = _create_order(event, email='dummy@example.org', positions=[cp1],
|
||||||
now_dt=now(),
|
now_dt=now(),
|
||||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||||
@@ -767,6 +769,8 @@ def test_use_membership_invalid(event, customer, membership, requiring_ticket):
|
|||||||
membership.date_start -= timedelta(days=100)
|
membership.date_start -= timedelta(days=100)
|
||||||
membership.date_end -= timedelta(days=100)
|
membership.date_end -= timedelta(days=100)
|
||||||
membership.save()
|
membership.save()
|
||||||
|
q = event.quotas.create(size=None, name="foo")
|
||||||
|
q.items.add(requiring_ticket)
|
||||||
cp1 = CartPosition.objects.create(
|
cp1 = CartPosition.objects.create(
|
||||||
item=requiring_ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123",
|
item=requiring_ticket, price=23, expires=now() + timedelta(days=1), event=event, cart_id="123",
|
||||||
used_membership=membership
|
used_membership=membership
|
||||||
|
|||||||
@@ -1428,6 +1428,27 @@ class CartTest(CartTestMixin, TestCase):
|
|||||||
self.assertEqual(cp2.expires, now() + self.cart_reservation_time)
|
self.assertEqual(cp2.expires, now() + self.cart_reservation_time)
|
||||||
self.assertEqual(cp2.max_extend, now() + 11 * self.cart_reservation_time)
|
self.assertEqual(cp2.max_extend, now() + 11 * self.cart_reservation_time)
|
||||||
|
|
||||||
|
def test_expired_cart_extend_price_change_note(self):
|
||||||
|
start_time = datetime.datetime(2024, 1, 1, 10, 00, 00, tzinfo=datetime.timezone.utc)
|
||||||
|
max_extend = start_time + 11 * self.cart_reservation_time
|
||||||
|
with scopes_disabled():
|
||||||
|
cp1 = CartPosition.objects.create(
|
||||||
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
|
price=23, expires=max_extend, max_extend=max_extend
|
||||||
|
)
|
||||||
|
cp1.update_listed_price_and_voucher()
|
||||||
|
self.ticket.default_price = Decimal("25.00")
|
||||||
|
self.ticket.save()
|
||||||
|
with freezegun.freeze_time(max_extend + timedelta(hours=1)):
|
||||||
|
response = self.client.post('/%s/%s/cart/extend' % (self.orga.slug, self.event.slug), {
|
||||||
|
}, follow=True)
|
||||||
|
doc = BeautifulSoup(response.rendered_content, "lxml")
|
||||||
|
self.assertIn('some of the prices in your cart changed', doc.select('.alert-success')[0].text)
|
||||||
|
with scopes_disabled():
|
||||||
|
cp1.refresh_from_db()
|
||||||
|
self.assertEqual(cp1.price, Decimal("25.00"))
|
||||||
|
self.assertEqual(cp1.expires, now() + self.cart_reservation_time)
|
||||||
|
|
||||||
def test_expired_cart_extend_fails_partially_on_bundled(self):
|
def test_expired_cart_extend_fails_partially_on_bundled(self):
|
||||||
start_time = datetime.datetime(2024, 1, 1, 10, 00, 00, tzinfo=datetime.timezone.utc)
|
start_time = datetime.datetime(2024, 1, 1, 10, 00, 00, tzinfo=datetime.timezone.utc)
|
||||||
max_extend = start_time + 11 * self.cart_reservation_time
|
max_extend = start_time + 11 * self.cart_reservation_time
|
||||||
@@ -3408,6 +3429,22 @@ class CartAddonTest(CartTestMixin, TestCase):
|
|||||||
assert cp2.expires > now()
|
assert cp2.expires > now()
|
||||||
assert cp2.addon_to_id == cp1.pk
|
assert cp2.addon_to_id == cp1.pk
|
||||||
|
|
||||||
|
@classscope(attr='orga')
|
||||||
|
def test_expand_expired_price_change(self):
|
||||||
|
cp1 = CartPosition.objects.create(
|
||||||
|
expires=now() - timedelta(minutes=10), max_extend=now() + 10 * self.cart_reservation_time,
|
||||||
|
item=self.ticket, price=Decimal('23.00'),
|
||||||
|
event=self.event, cart_id=self.session_key
|
||||||
|
)
|
||||||
|
self.ticket.default_price = Decimal("25.00")
|
||||||
|
self.ticket.save()
|
||||||
|
self.cm.extend_expired_positions()
|
||||||
|
self.cm.commit()
|
||||||
|
cp1.refresh_from_db()
|
||||||
|
assert cp1.expires > now()
|
||||||
|
assert cp1.listed_price == Decimal("25.00")
|
||||||
|
assert cp1.price == Decimal("25.00")
|
||||||
|
|
||||||
@classscope(attr='orga')
|
@classscope(attr='orga')
|
||||||
def test_expand_expired_refresh_voucher(self):
|
def test_expand_expired_refresh_voucher(self):
|
||||||
v = Voucher.objects.create(item=self.ticket, value=Decimal('20.00'), event=self.event, price_mode='set',
|
v = Voucher.objects.create(item=self.ticket, value=Decimal('20.00'), event=self.event, price_mode='set',
|
||||||
@@ -4080,6 +4117,8 @@ class CartBundleTest(CartTestMixin, TestCase):
|
|||||||
|
|
||||||
@classscope(attr='orga')
|
@classscope(attr='orga')
|
||||||
def test_extend_bundled_and_addon(self):
|
def test_extend_bundled_and_addon(self):
|
||||||
|
self.trans.require_bundling = False
|
||||||
|
self.trans.save()
|
||||||
cp = CartPosition.objects.create(
|
cp = CartPosition.objects.create(
|
||||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
price=21.5, expires=now() - timedelta(minutes=10), max_extend=now() + 10 * self.cart_reservation_time
|
price=21.5, expires=now() - timedelta(minutes=10), max_extend=now() + 10 * self.cart_reservation_time
|
||||||
|
|||||||
@@ -2669,7 +2669,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
q = se.quotas.create(name="foo", size=None, event=self.event)
|
q = se.quotas.create(name="foo", size=None, event=self.event)
|
||||||
q.items.add(self.ticket)
|
q.items.add(self.ticket)
|
||||||
cr1 = CartPosition.objects.create(
|
cr1 = CartPosition.objects.create(
|
||||||
@@ -2839,8 +2839,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
se2 = self.event.subevents.create(name='Foo', date_from=now())
|
se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
self.quota_tickets.size = 0
|
self.quota_tickets.size = 0
|
||||||
self.quota_tickets.subevent = se2
|
self.quota_tickets.subevent = se2
|
||||||
self.quota_tickets.save()
|
self.quota_tickets.save()
|
||||||
@@ -2880,7 +2880,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
q = se.quotas.create(name="foo", size=None, event=self.event)
|
q = se.quotas.create(name="foo", size=None, event=self.event)
|
||||||
q.items.add(self.ticket)
|
q.items.add(self.ticket)
|
||||||
SubEventItem.objects.create(subevent=se, item=self.ticket, price=24)
|
SubEventItem.objects.create(subevent=se, item=self.ticket, price=24)
|
||||||
@@ -2901,7 +2901,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
q = se.quotas.create(name="foo", size=None, event=self.event)
|
q = se.quotas.create(name="foo", size=None, event=self.event)
|
||||||
q.items.add(self.ticket)
|
q.items.add(self.ticket)
|
||||||
SubEventItem.objects.create(subevent=se, item=self.ticket, price=24, disabled=True)
|
SubEventItem.objects.create(subevent=se, item=self.ticket, price=24, disabled=True)
|
||||||
@@ -2919,7 +2919,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
q = se.quotas.create(name="foo", size=None, event=self.event)
|
q = se.quotas.create(name="foo", size=None, event=self.event)
|
||||||
q.items.add(self.workshop2)
|
q.items.add(self.workshop2)
|
||||||
q.variations.add(self.workshop2b)
|
q.variations.add(self.workshop2b)
|
||||||
@@ -2938,7 +2938,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
q = se.quotas.create(name="foo", size=None, event=self.event)
|
q = se.quotas.create(name="foo", size=None, event=self.event)
|
||||||
q.items.add(self.ticket)
|
q.items.add(self.ticket)
|
||||||
SubEventItem.objects.create(subevent=se, item=self.ticket, price=24, available_until=now() - timedelta(days=1))
|
SubEventItem.objects.create(subevent=se, item=self.ticket, price=24, available_until=now() - timedelta(days=1))
|
||||||
@@ -2956,7 +2956,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
q = se.quotas.create(name="foo", size=None, event=self.event)
|
q = se.quotas.create(name="foo", size=None, event=self.event)
|
||||||
q.items.add(self.workshop2)
|
q.items.add(self.workshop2)
|
||||||
q.variations.add(self.workshop2b)
|
q.variations.add(self.workshop2b)
|
||||||
@@ -3735,8 +3735,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
se2 = self.event.subevents.create(name='Foo', date_from=now())
|
se2 = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
self.quota_tickets.size = 10
|
self.quota_tickets.size = 10
|
||||||
self.quota_tickets.subevent = se2
|
self.quota_tickets.subevent = se2
|
||||||
self.quota_tickets.save()
|
self.quota_tickets.save()
|
||||||
@@ -4165,7 +4165,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
self.workshopquota.size = 1
|
self.workshopquota.size = 1
|
||||||
self.workshopquota.subevent = se
|
self.workshopquota.subevent = se
|
||||||
self.workshopquota.save()
|
self.workshopquota.save()
|
||||||
@@ -4214,7 +4214,10 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.settings.display_net_prices = True
|
self.event.settings.display_net_prices = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now(), presale_start=now() + datetime.timedelta(days=1))
|
se = self.event.subevents.create(name='Foo', date_from=now(), presale_start=now() + datetime.timedelta(days=1),
|
||||||
|
active=True)
|
||||||
|
q = se.quotas.create(name="foo", size=None, event=self.event)
|
||||||
|
q.items.add(self.ticket)
|
||||||
CartPosition.objects.create(
|
CartPosition.objects.create(
|
||||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
price=23, expires=now() + timedelta(minutes=10), subevent=se
|
price=23, expires=now() + timedelta(minutes=10), subevent=se
|
||||||
@@ -4224,7 +4227,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||||
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
|
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
|
||||||
assert 'booking period for one of the events in your cart has not yet started.' in response.content.decode()
|
assert 'booking period for this event has not yet started.' in response.content.decode()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert not CartPosition.objects.filter(cart_id=self.session_key).exists()
|
assert not CartPosition.objects.filter(cart_id=self.session_key).exists()
|
||||||
|
|
||||||
@@ -4233,7 +4236,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.has_subevents = True
|
self.event.has_subevents = True
|
||||||
self.event.settings.display_net_prices = True
|
self.event.settings.display_net_prices = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now(), presale_end=now() - datetime.timedelta(days=1))
|
se = self.event.subevents.create(name='Foo', date_from=now(), presale_end=now() - datetime.timedelta(days=1), active=True)
|
||||||
CartPosition.objects.create(
|
CartPosition.objects.create(
|
||||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
price=23, expires=now() + timedelta(minutes=10), subevent=se
|
price=23, expires=now() + timedelta(minutes=10), subevent=se
|
||||||
@@ -4243,7 +4246,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||||
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
|
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
|
||||||
assert 'booking period for one of the events in your cart has ended.' in response.content.decode()
|
assert 'The booking period for this event has ended.' in response.content.decode()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert not CartPosition.objects.filter(cart_id=self.session_key).exists()
|
assert not CartPosition.objects.filter(cart_id=self.session_key).exists()
|
||||||
|
|
||||||
@@ -4253,7 +4256,9 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.settings.display_net_prices = True
|
self.event.settings.display_net_prices = True
|
||||||
self.event.save()
|
self.event.save()
|
||||||
self.event.settings.payment_term_last = 'RELDATE/1/23:59:59/date_from/'
|
self.event.settings.payment_term_last = 'RELDATE/1/23:59:59/date_from/'
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now())
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=True)
|
||||||
|
q = se.quotas.create(name="foo", size=None, event=self.event)
|
||||||
|
q.items.add(self.ticket)
|
||||||
CartPosition.objects.create(
|
CartPosition.objects.create(
|
||||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
price=23, expires=now() + timedelta(minutes=10), subevent=se
|
price=23, expires=now() + timedelta(minutes=10), subevent=se
|
||||||
@@ -4263,7 +4268,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||||
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
|
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
|
||||||
assert 'booking period for one of the events in your cart has ended.' in response.content.decode()
|
assert 'All payments for this event need to be confirmed already, so no new orders can be created.' in response.content.decode()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
assert not CartPosition.objects.filter(cart_id=self.session_key).exists()
|
assert not CartPosition.objects.filter(cart_id=self.session_key).exists()
|
||||||
|
|
||||||
@@ -4272,7 +4277,10 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.event.date_to = now() - datetime.timedelta(days=1)
|
self.event.date_to = now() - datetime.timedelta(days=1)
|
||||||
self.event.save()
|
self.event.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
se = self.event.subevents.create(name='Foo', date_from=now(), presale_end=now() + datetime.timedelta(days=1))
|
se = self.event.subevents.create(name='Foo', date_from=now(), presale_end=now() + datetime.timedelta(days=1),
|
||||||
|
active=True)
|
||||||
|
q = se.quotas.create(name="foo", size=None, event=self.event)
|
||||||
|
q.items.add(self.ticket)
|
||||||
CartPosition.objects.create(
|
CartPosition.objects.create(
|
||||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
price=23, expires=now() + timedelta(minutes=10), subevent=se
|
price=23, expires=now() + timedelta(minutes=10), subevent=se
|
||||||
@@ -4283,6 +4291,25 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||||
|
|
||||||
|
def test_confirm_subevent_disabled(self):
|
||||||
|
with scopes_disabled():
|
||||||
|
self.event.has_subevents = True
|
||||||
|
self.event.settings.display_net_prices = True
|
||||||
|
self.event.save()
|
||||||
|
se = self.event.subevents.create(name='Foo', date_from=now(), active=False)
|
||||||
|
CartPosition.objects.create(
|
||||||
|
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||||
|
price=23, expires=now() + timedelta(minutes=10), subevent=se
|
||||||
|
)
|
||||||
|
|
||||||
|
self._set_payment()
|
||||||
|
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||||
|
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||||
|
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
|
||||||
|
assert 'selected event date is not active.' in response.content.decode()
|
||||||
|
with scopes_disabled():
|
||||||
|
assert not CartPosition.objects.filter(cart_id=self.session_key).exists()
|
||||||
|
|
||||||
def test_before_presale_timemachine(self):
|
def test_before_presale_timemachine(self):
|
||||||
self._login_with_permission(self.orga)
|
self._login_with_permission(self.orga)
|
||||||
self._enable_test_mode()
|
self._enable_test_mode()
|
||||||
@@ -4497,6 +4524,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
self.assertEqual(Order.objects.first().locale, 'de')
|
self.assertEqual(Order.objects.first().locale, 'de')
|
||||||
|
|
||||||
def test_variation_require_approval(self):
|
def test_variation_require_approval(self):
|
||||||
|
self.workshop2.category = None
|
||||||
|
self.workshop2.save()
|
||||||
self.workshop2a.require_approval = True
|
self.workshop2a.require_approval = True
|
||||||
self.workshop2a.save()
|
self.workshop2a.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
@@ -4518,6 +4547,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
|||||||
|
|
||||||
def test_item_with_variations_require_approval(self):
|
def test_item_with_variations_require_approval(self):
|
||||||
self.workshop2.require_approval = True
|
self.workshop2.require_approval = True
|
||||||
|
self.workshop2.category = None
|
||||||
self.workshop2.save()
|
self.workshop2.save()
|
||||||
with scopes_disabled():
|
with scopes_disabled():
|
||||||
cr1 = CartPosition.objects.create(
|
cr1 = CartPosition.objects.create(
|
||||||
|
|||||||
Reference in New Issue
Block a user