diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 2387f988e..a3e7b7f09 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -74,6 +74,7 @@ from pretix.base.services.tasks import ProfiledEventTask from pretix.base.settings import PERSON_NAME_SCHEMES, LazyI18nStringList from pretix.base.signals import validate_cart_addons from pretix.base.templatetags.rich_text import rich_text +from pretix.base.timemachine import time_machine_now, time_machine_now_assigned from pretix.celery_app import app from pretix.presale.signals import ( checkout_confirm_messages, fee_calculation_for_cart, @@ -278,7 +279,7 @@ class CartManager: sales_channel='web'): self.event = event self.cart_id = cart_id - self.now_dt = now() + self.real_now_dt = now() self._operations = [] self._quota_diff = Counter() self._voucher_use_diff = Counter() @@ -305,10 +306,10 @@ class CartManager: return self._seated_cache[item, subevent] def _calculate_expiry(self): - self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int)) + self._expiry = self.real_now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int)) def _check_presale_dates(self): - if self.event.presale_start and self.now_dt < self.event.presale_start: + if self.event.presale_start and time_machine_now(self.real_now_dt) < self.event.presale_start: raise CartError(error_messages['not_started']) if self.event.presale_has_ended: raise CartError(error_messages['ended']) @@ -319,13 +320,13 @@ class CartManager: tlv.datetime(self.event).date(), time(hour=23, minute=59, second=59) ), self.event.timezone) - if term_last < self.now_dt: + if term_last < time_machine_now(self.real_now_dt): raise CartError(error_messages['payment_ended']) def _extend_expiry_of_valid_existing_positions(self): # Extend this user's cart session to ensure all items in the cart expire at the same time # We can extend the reservation of items which are not yet expired without risk - self.positions.filter(expires__gt=self.now_dt).update(expires=self._expiry) + self.positions.filter(expires__gt=self.real_now_dt).update(expires=self._expiry) def _delete_out_of_timeframe(self): err = None @@ -333,12 +334,12 @@ class CartManager: if not cp.pk: continue - if cp.subevent and cp.subevent.presale_start and self.now_dt < cp.subevent.presale_start: + if cp.subevent and cp.subevent.presale_start and time_machine_now(self.real_now_dt) < cp.subevent.presale_start: err = error_messages['some_subevent_not_started'] cp.addons.all().delete() cp.delete() - if cp.subevent and cp.subevent.presale_end and self.now_dt > cp.subevent.presale_end: + if cp.subevent and cp.subevent.presale_end and time_machine_now(self.real_now_dt) > cp.subevent.presale_end: err = error_messages['some_subevent_ended'] cp.addons.all().delete() cp.delete() @@ -350,7 +351,7 @@ class CartManager: tlv.datetime(cp.subevent).date(), time(hour=23, minute=59, second=59) ), self.event.timezone) - if term_last < self.now_dt: + if term_last < time_machine_now(self.real_now_dt): err = error_messages['some_subevent_ended'] cp.addons.all().delete() cp.delete() @@ -449,7 +450,7 @@ class CartManager: if op.subevent and not op.subevent.active: raise CartError(error_messages['inactive_subevent']) - if op.subevent and op.subevent.presale_start and self.now_dt < op.subevent.presale_start: + 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: @@ -472,7 +473,7 @@ class CartManager: tlv.datetime(op.subevent).date(), time(hour=23, minute=59, second=59) ), self.event.timezone) - if term_last < self.now_dt: + if term_last < time_machine_now(self.real_now_dt): raise CartError(error_messages['payment_ended']) if isinstance(op, self.AddOperation): @@ -509,7 +510,7 @@ class CartManager: ) if not self.event.settings.seating_choice: requires_seat = Value(0, output_field=IntegerField()) - expired = self.positions.filter(expires__lte=self.now_dt).select_related( + expired = self.positions.filter(expires__lte=self.real_now_dt).select_related( 'item', 'variation', 'voucher', 'addon_to', 'addon_to__item' ).annotate( requires_seat=requires_seat @@ -690,7 +691,7 @@ class CartManager: # than either of the possible default assumptions. predicted_redeemed_after = ( voucher.redeemed + - CartPosition.objects.filter(voucher=voucher, expires__gte=self.now_dt).count() + + CartPosition.objects.filter(voucher=voucher, expires__gte=self.real_now_dt).count() + self._voucher_use_diff[voucher] + voucher_use_diff[voucher] ) @@ -982,7 +983,7 @@ class CartManager: current_num = len(current_addons[cp].get(k, [])) if input_num < current_num: for a in current_addons[cp][k][:current_num - input_num]: - if a.expires > self.now_dt: + if a.expires > self.real_now_dt: quotas = list(a.quotas) for quota in quotas: @@ -996,7 +997,7 @@ class CartManager: def _get_voucher_availability(self): vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability( - self.event, self._voucher_use_diff, self.now_dt, + self.event, self._voucher_use_diff, self.real_now_dt, exclude_position_ids=[ op.position.id for op in self._operations if isinstance(op, self.ExtendOperation) ] @@ -1101,7 +1102,7 @@ class CartManager: shared_lock_objects=[self.event] ) vouchers_ok = self._get_voucher_availability() - quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt) + quotas_ok = _get_quota_availability(self._quota_diff, self.real_now_dt) err = None new_cart_positions = [] deleted_positions = set() @@ -1118,7 +1119,7 @@ class CartManager: for iop, op in enumerate(self._operations): if isinstance(op, self.RemoveOperation): - if op.position.expires > self.now_dt: + if op.position.expires > self.real_now_dt: for q in op.position.quotas: quotas_ok[q] += 1 addons = op.position.addons.all() @@ -1395,7 +1396,7 @@ class CartManager: err = self.extend_expired_positions() or err err = err or self._check_min_per_voucher() - self.now_dt = now() + self.real_now_dt = now() self._extend_expiry_of_valid_existing_positions() err = self._perform_operations() or err @@ -1487,7 +1488,7 @@ def get_fees(event, request, total, invoice_address, payments, positions): @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en', - invoice_address: int=None, widget_data=None, sales_channel='web') -> None: + invoice_address: int=None, widget_data=None, sales_channel='web', override_now_dt: datetime=None) -> None: """ Adds a list of items to a user's cart. :param event: The event ID in question @@ -1495,7 +1496,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo :param cart_id: Session ID of a guest :raises CartError: On any error that occurred """ - with language(locale): + with language(locale), time_machine_now_assigned(override_now_dt): ia = False if invoice_address: try: @@ -1517,14 +1518,14 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) -def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web') -> None: +def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: """ Removes a list of items from a user's cart. :param event: The event ID in question :param voucher: A voucher code :param session: Session ID of a guest """ - with language(locale): + with language(locale), time_machine_now_assigned(override_now_dt): try: try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) @@ -1537,14 +1538,14 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) -def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web') -> None: +def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: """ Removes a list of items from a user's cart. :param event: The event ID in question :param position: A cart position ID :param session: Session ID of a guest """ - with language(locale): + with language(locale), time_machine_now_assigned(override_now_dt): try: try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) @@ -1557,13 +1558,13 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) -def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web') -> None: +def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel='web', override_now_dt: datetime=None) -> None: """ Removes a list of items from a user's cart. :param event: The event ID in question :param session: Session ID of a guest """ - with language(locale): + with language(locale), time_machine_now_assigned(override_now_dt): try: try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) @@ -1577,14 +1578,14 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en', - invoice_address: int=None, sales_channel='web') -> None: + invoice_address: int=None, sales_channel='web', override_now_dt: datetime=None) -> None: """ Removes a list of items from a user's cart. :param event: The event ID in question :param addons: A list of dicts with the keys addon_to, item, variation :param session: Session ID of a guest """ - with language(locale): + with language(locale), time_machine_now_assigned(override_now_dt): ia = False if invoice_address: try: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index a52cc541a..566792938 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -102,7 +102,7 @@ from pretix.base.signals import ( order_fee_calculation, order_paid, order_placed, order_split, order_valid_if_pending, periodic_task, validate_order, ) -from pretix.base.timemachine import time_machine_now +from pretix.base.timemachine import time_machine_now, time_machine_now_assigned from pretix.celery_app import app from pretix.helpers import OF_SELF from pretix.helpers.models import modelcopy @@ -2853,8 +2853,8 @@ class OrderChangeManager: @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) def perform_order(self, event: Event, payments: List[dict], positions: List[str], email: str=None, locale: str=None, address: int=None, meta_info: dict=None, - sales_channel: str='web', shown_total=None, customer=None): - with language(locale): + sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None): + with language(locale), time_machine_now_assigned(override_now_dt): try: try: return _perform_order(event, payments, positions, email, locale, address, meta_info, diff --git a/src/pretix/base/timemachine.py b/src/pretix/base/timemachine.py index c1f12b6cd..56f76bc77 100644 --- a/src/pretix/base/timemachine.py +++ b/src/pretix/base/timemachine.py @@ -33,6 +33,7 @@ # License for the specific language governing permissions and limitations under the License. import threading +from contextlib import contextmanager from dateutil.parser import parse from django.utils.timezone import now @@ -64,5 +65,16 @@ class TimeMachineMiddleware: TimeMachineMiddleware.tls.now_dt = None -def time_machine_now(now_dt=None): - return getattr(TimeMachineMiddleware.tls, 'now_dt', None) or now_dt or now() +def time_machine_now(default=False): + if default is False: + default = now() + return getattr(TimeMachineMiddleware.tls, 'now_dt', None) or default + + +@contextmanager +def time_machine_now_assigned(now_dt): + try: + TimeMachineMiddleware.tls.now_dt = now_dt + yield + finally: + TimeMachineMiddleware.tls.now_dt = None diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 622e2b4bc..4e8514334 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -73,6 +73,7 @@ from pretix.base.signals import validate_cart_addons from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.phone_format import phone_format from pretix.base.templatetags.rich_text import rich_text_snippet +from pretix.base.timemachine import time_machine_now from pretix.base.views.tasks import AsyncAction from pretix.celery_app import app from pretix.helpers.http import redirect_to_url @@ -706,7 +707,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): return self.do(self.request.event.id, data, get_or_create_cart_id(self.request), invoice_address=self.invoice_address.pk, locale=get_language(), - sales_channel=request.sales_channel.identifier) + sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None)) class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): @@ -1548,6 +1549,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): sales_channel=request.sales_channel.identifier, shown_total=self.cart_session.get('shown_total'), customer=self.cart_session.get('customer'), + override_now_dt=time_machine_now(default=None), ) def get_success_message(self, value): diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index c9b296843..92c188056 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -63,6 +63,7 @@ from pretix.base.services.cart import ( CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages, remove_cart_position, ) +from pretix.base.timemachine import time_machine_now from pretix.base.views.tasks import AsyncAction from pretix.helpers.http import redirect_to_url from pretix.multidomain.urlreverse import eventreverse @@ -429,7 +430,8 @@ class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View): def post(self, request, *args, **kwargs): if 'voucher' in request.POST: return self.do(self.request.event.id, request.POST.get('voucher'), 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)) else: if 'ajax' in self.request.GET or 'ajax' in self.request.POST: return JsonResponse({ @@ -455,7 +457,8 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View): if 'id' in request.POST: try: return self.do(self.request.event.id, int(request.POST.get('id')), get_or_create_cart_id(self.request), - translation.get_language(), request.sales_channel.identifier) + translation.get_language(), request.sales_channel.identifier, + time_machine_now(default=None)) except ValueError: return redirect_to_url(self.get_error_url()) else: @@ -478,7 +481,7 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View): def post(self, request, *args, **kwargs): return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(), - request.sales_channel.identifier) + request.sales_channel.identifier, time_machine_now(default=None)) @method_decorator(allow_cors_if_namespaced, 'dispatch') @@ -534,7 +537,8 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): items = self._items_from_post_data() if items: return self.do(self.request.event.id, items, cart_id, translation.get_language(), - self.invoice_address.pk, widget_data, self.request.sales_channel.identifier) + self.invoice_address.pk, widget_data, self.request.sales_channel.identifier, + time_machine_now(default=None)) else: if 'ajax' in self.request.GET or 'ajax' in self.request.POST: return JsonResponse({ diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 57f54b1d7..44bfaf795 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -49,7 +49,7 @@ from pretix.base.decimal import round_decimal from pretix.base.models import ( CartPosition, Discount, Event, InvoiceAddress, Item, ItemCategory, ItemVariation, Organizer, Question, QuestionAnswer, Quota, SeatingPlan, - Voucher, + Team, User, Voucher, ) from pretix.base.models.items import ( ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation, @@ -4274,3 +4274,101 @@ class CartSeatingTest(CartTestMixin, TestCase): self.cm.commit() assert not CartPosition.objects.filter(cart_id=self.session_key).exists() + + +class CartTimemachineTest(CartTestMixin, TestCase): + @scopes_disabled() + def setUp(self): + super().setUp() + self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + self.team1 = Team.objects.create(organizer=self.orga, can_create_events=True, can_change_event_settings=True, + can_change_items=True, all_events=True) + self.team1.members.add(self.user) + self.client.login(email='dummy@dummy.dummy', password='dummy') + + def _set_time_machine_now(self, dt): + session = self.client.session + session['timemachine_now_dt'] = str(dt) + session.save() + + def test_before_presale_timemachine(self): + self.event.presale_start = now() + timedelta(days=1) + self.event.testmode = True + self.event.save() + self._set_time_machine_now(now() + timedelta(days=2)) + + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1' + }, follow=True) + self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert 'alert-success' in response.rendered_content + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 23) + self.assertLessEqual(objs[0].expires, now() + timedelta( + minutes=self.event.settings.get('reservation_time', as_type=int))) + + def test_after_presale_timemachine(self): + self.event.presale_end = now() - timedelta(days=1) + self.event.testmode = True + self.event.save() + self._set_time_machine_now(now() - timedelta(days=2)) + + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1' + }, follow=True) + self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert 'alert-success' in response.rendered_content + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, 23) + self.assertLessEqual(objs[0].expires, now() + timedelta( + minutes=self.event.settings.get('reservation_time', as_type=int))) + + def test_not_yet_available_with_timemachine_in_time(self): + self.event.testmode = True + self.event.save() + self.ticket.available_from = now() + timedelta(days=2) + self.ticket.available_until = now() + timedelta(days=4) + self.ticket.save() + self._set_time_machine_now(now() + timedelta(days=3)) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + }, follow=True) + with scopes_disabled(): + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1) + + def test_variation_no_longer_available_with_timemachine_in_time(self): + self.event.testmode = True + self.event.save() + self.shirt_blue.available_from = now() - timedelta(days=4) + self.shirt_blue.available_until = now() - timedelta(days=2) + self.shirt_blue.save() + self._set_time_machine_now(now() - timedelta(days=3)) + + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1', + }, follow=True) + with scopes_disabled(): + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1) + + def test_variation_no_longer_available_with_timemachine_before(self): + self.event.testmode = True + self.event.save() + self.shirt_blue.available_from = now() - timedelta(days=4) + self.shirt_blue.available_until = now() - timedelta(days=2) + self.shirt_blue.save() + self._set_time_machine_now(now() - timedelta(days=5)) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1', + }, follow=True) + with scopes_disabled(): + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0)