Time machine mode [Z#23129725] (#3961)

Allows organizers to test their shop as if it were a different date and time.

Implemented using a time_machine_now() function which is used instead of regular now(), which can overlay the real date time with a value from a ContextVar, assigned from a session value in EventMiddleware.

For more information, see doc/development/implementation/timemachine.rst

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
This commit is contained in:
Mira
2024-05-17 10:52:17 +02:00
committed by GitHub
parent bfcca7046a
commit b638c00952
38 changed files with 789 additions and 142 deletions

View File

@@ -57,7 +57,7 @@ from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone, now
from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries import countries
from django_countries.fields import Country, CountryField
@@ -86,6 +86,7 @@ from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import rich_text
from pretix.base.timemachine import time_machine_now
from pretix.control.forms import (
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField,
)
@@ -606,13 +607,13 @@ class BaseQuestionsForm(forms.Form):
if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
if item.validity_dynamic_start_choice_day_limit:
max_date = now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit)
else:
max_date = None
min_date = now()
min_date = time_machine_now()
initial = None
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership:
if pos.used_membership.date_start >= now():
if pos.used_membership.date_start >= time_machine_now():
initial = min_date = pos.used_membership.date_start
max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end
if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:

View File

@@ -68,6 +68,7 @@ from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.timemachine import time_machine_now
from pretix.base.validators import EventSlugBanlistValidator
from pretix.helpers.database import GroupConcat
from pretix.helpers.daterange import daterange
@@ -235,7 +236,7 @@ class EventMixin:
if not self.settings.waiting_list_enabled:
return False
if self.settings.waiting_list_auto_disable:
return self.settings.waiting_list_auto_disable.datetime(self) > now()
return self.settings.waiting_list_auto_disable.datetime(self) > time_machine_now()
return True
@property
@@ -244,11 +245,11 @@ class EventMixin:
Is true, when ``presale_end`` is set and in the past.
"""
if self.effective_presale_end:
return now() > self.effective_presale_end
return time_machine_now() > self.effective_presale_end
elif self.date_to:
return now() > self.date_to
return time_machine_now() > self.date_to
else:
return now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
return time_machine_now().astimezone(self.timezone).date() > self.date_from.astimezone(self.timezone).date()
@property
def effective_presale_start(self):
@@ -268,7 +269,7 @@ class EventMixin:
Is true, when ``presale_end`` is not set or in the future and ``presale_start`` is not
set or in the past.
"""
if self.effective_presale_start and now() < self.effective_presale_start:
if self.effective_presale_start and time_machine_now() < self.effective_presale_start:
return False
return not self.presale_has_ended
@@ -316,11 +317,11 @@ class EventMixin:
q_variation = (
Q(active=True)
& Q(sales_channels__contains=channel)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()))
& Q(item__active=True)
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now()))
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now()))
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=time_machine_now()))
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=time_machine_now()))
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
& Q(item__sales_channels__contains=channel)
& Q(item__require_bundling=False)
@@ -695,7 +696,7 @@ class Event(EventMixin, LoggedModel):
@property
def presale_has_ended(self):
if self.has_subevents:
return self.presale_end and now() > self.presale_end
return self.presale_end and time_machine_now() > self.presale_end
else:
return super().presale_has_ended
@@ -1188,8 +1189,8 @@ class Event(EventMixin, LoggedModel):
)
).filter(
Q(active=True) & Q(is_public=True) & (
Q(Q(date_to__isnull=True) & Q(date_from__gte=now() - timedelta(hours=24)))
| Q(date_to__gte=now() - timedelta(hours=24))
Q(Q(date_to__isnull=True) & Q(date_from__gte=time_machine_now() - timedelta(hours=24)))
| Q(date_to__gte=time_machine_now() - timedelta(hours=24))
)
) # order_by doesn't make sense with I18nField
if ordering in ("date_ascending", "date_descending"):
@@ -1509,7 +1510,7 @@ class SubEvent(EventMixin, LoggedModel):
disabled_items=Coalesce(
Subquery(
SubEventItem.objects.filter(
Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()),
Q(disabled=True) | Q(available_from__gt=time_machine_now()) | Q(available_until__lt=time_machine_now()),
subevent=OuterRef('pk'),
).order_by().values('subevent').annotate(items=GroupConcat('item_id', delimiter=',')).values('items'),
output_field=models.TextField(),
@@ -1520,7 +1521,7 @@ class SubEvent(EventMixin, LoggedModel):
disabled_vars=Coalesce(
Subquery(
SubEventItemVariation.objects.filter(
Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()),
Q(disabled=True) | Q(available_from__gt=time_machine_now()) | Q(available_until__lt=time_machine_now()),
subevent=OuterRef('pk'),
).order_by().values('subevent').annotate(items=GroupConcat('variation_id', delimiter=',')).values('items'),
output_field=models.TextField(),

View File

@@ -55,7 +55,7 @@ from django.db.models import Q
from django.utils import formats
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now
from django.utils.timezone import is_naive, make_aware
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries.fields import Country
from django_scopes import ScopedManager
@@ -65,6 +65,7 @@ from pretix.base.models import fields
from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice
from pretix.base.timemachine import time_machine_now
from ...helpers.images import ImageSizeValidator
from ..media import MEDIA_TYPES
@@ -192,7 +193,7 @@ class SubEventItem(models.Model):
self.subevent.event.cache.clear()
def is_available(self, now_dt: datetime=None) -> bool:
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
if self.disabled:
return False
if self.available_from and self.available_from > now_dt:
@@ -248,7 +249,7 @@ class SubEventItemVariation(models.Model):
self.subevent.event.cache.clear()
def is_available(self, now_dt: datetime=None) -> bool:
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
if self.disabled:
return False
if self.available_from and self.available_from > now_dt:
@@ -263,8 +264,8 @@ def filter_available(qs, channel='web', voucher=None, allow_addons=False):
# IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated()
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()) | Q(available_from_mode='info'))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()) | Q(available_until_mode='info'))
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info'))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info'))
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
)
if not allow_addons:
@@ -782,7 +783,7 @@ class Item(LoggedModel):
return t
def is_available_by_time(self, now_dt: datetime=None) -> bool:
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
if self.available_from and self.available_from > now_dt:
return False
if self.available_until and self.available_until < now_dt:
@@ -794,13 +795,13 @@ class Item(LoggedModel):
Returns whether this item is available according to its ``active`` flag
and its ``available_from`` and ``available_until`` fields
"""
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
if not self.active or not self.is_available_by_time(now_dt):
return False
return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
subevent_item = subevent and subevent.item_overrides.get(self.pk)
if not self.active:
return 'active'
@@ -957,11 +958,11 @@ class Item(LoggedModel):
return self.validity_fixed_from, self.validity_fixed_until
elif self.validity_mode == Item.VALIDITY_MODE_DYNAMIC:
tz = override_tz or self.event.timezone
requested_start = requested_start or now()
requested_start = requested_start or time_machine_now()
if enforce_start_limit and not self.validity_dynamic_start_choice:
requested_start = now()
requested_start = time_machine_now()
if enforce_start_limit and self.validity_dynamic_start_choice_day_limit is not None:
requested_start = min(requested_start, now() + timedelta(days=self.validity_dynamic_start_choice_day_limit))
requested_start = min(requested_start, time_machine_now() + timedelta(days=self.validity_dynamic_start_choice_day_limit))
valid_until = requested_start.astimezone(tz)
@@ -1290,7 +1291,7 @@ class ItemVariation(models.Model):
return ItemVariation.objects.filter(item=self.item).count() == 1
def is_available_by_time(self, now_dt: datetime=None) -> bool:
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
if self.available_from and self.available_from > now_dt:
return False
if self.available_until and self.available_until < now_dt:
@@ -1302,13 +1303,13 @@ class ItemVariation(models.Model):
Returns whether this item is available according to its ``active`` flag
and its ``available_from`` and ``available_until`` fields
"""
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
if not self.active or not self.is_available_by_time(now_dt):
return False
return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]:
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
subevent_var = subevent and subevent.var_overrides.get(self.pk)
if not self.active:
return 'active'

View File

@@ -23,7 +23,6 @@ from django.db import models
from django.db.models import Count, OuterRef, Subquery, Value
from django.db.models.functions import Coalesce
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField
@@ -31,6 +30,7 @@ from i18nfield.fields import I18nCharField
from pretix.base.models import Customer
from pretix.base.models.base import LoggedModel
from pretix.base.models.organizer import Organizer
from pretix.base.timemachine import time_machine_now
from pretix.helpers.names import build_name
@@ -165,13 +165,13 @@ class Membership(models.Model):
def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
if valid_from_not_chosen:
return not self.canceled and self.date_end >= now()
return not self.canceled and self.date_end >= time_machine_now()
elif ticket_valid_from:
dt = ticket_valid_from
elif ev:
dt = ev.date_from
else:
dt = now()
dt = time_machine_now()
return not self.canceled and dt >= self.date_start and dt <= self.date_end

View File

@@ -80,6 +80,7 @@ from pretix.base.models import Customer, User
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import allow_ticket_download, order_gracefully_delete
from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField
@@ -681,7 +682,7 @@ class Order(LockModel, LoggedModel):
for op in positions:
if op.issued_gift_cards.all():
return False
if self.user_change_deadline and now() > self.user_change_deadline:
if self.user_change_deadline and time_machine_now() > self.user_change_deadline:
return False
return (
@@ -713,7 +714,7 @@ class Order(LockModel, LoggedModel):
return False
if op.granted_memberships.with_usages().filter(usages__gt=0):
return False
if self.user_cancel_deadline and now() > self.user_cancel_deadline:
if self.user_cancel_deadline and time_machine_now() > self.user_cancel_deadline:
return False
if self.status == Order.STATUS_PAID:
@@ -854,7 +855,7 @@ class Order(LockModel, LoggedModel):
return False
modify_deadline = self.modify_deadline
if modify_deadline is not None and now() > modify_deadline:
if modify_deadline is not None and time_machine_now() > modify_deadline:
return False
positions = list(
@@ -906,7 +907,7 @@ class Order(LockModel, LoggedModel):
return self.event.settings.ticket_download and (
self.event.settings.ticket_download_date is None
or self.ticket_download_date is None
or now() > self.ticket_download_date
or time_machine_now() > self.ticket_download_date
) and (
self.status == Order.STATUS_PAID
or (
@@ -978,7 +979,7 @@ class Order(LockModel, LoggedModel):
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if now() > term_last:
if time_machine_now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING:
@@ -1001,7 +1002,7 @@ class Order(LockModel, LoggedModel):
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'),
}
now_dt = now_dt or now()
now_dt = now_dt or time_machine_now()
positions = list(self.positions.all().select_related('item', 'variation', 'seat', 'voucher'))
quota_cache = {}
v_budget = {}
@@ -2575,9 +2576,9 @@ class OrderPosition(AbstractPosition):
if cartpos.item.validity_mode:
valid_from, valid_until = cartpos.item.compute_validity(
requested_start=(
max(cartpos.requested_valid_from, now())
max(cartpos.requested_valid_from, time_machine_now())
if cartpos.requested_valid_from and cartpos.item.validity_dynamic_start_choice
else now()
else time_machine_now()
),
enforce_start_limit=True,
override_tz=order.event.timezone,
@@ -3103,9 +3104,9 @@ class CartPosition(AbstractPosition):
def predicted_validity(self):
return self.item.compute_validity(
requested_start=(
max(self.requested_valid_from, now())
max(self.requested_valid_from, time_machine_now())
if self.requested_valid_from and self.item.validity_dynamic_start_choice
else now()
else time_machine_now()
),
override_tz=self.event.timezone,
)

View File

@@ -67,6 +67,7 @@ from pretix.base.settings import SettingsSandbox
from pretix.base.signals import register_payment_providers
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text
from pretix.base.timemachine import time_machine_now
from pretix.helpers import OF_SELF
from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map
@@ -1441,7 +1442,7 @@ class GiftCardPayment(BasePaymentProvider):
if not gc.testmode and self.event.testmode:
messages.error(request, _("Only test gift cards can be used in test mode."))
return
if gc.expires and gc.expires < now():
if gc.expires and gc.expires < time_machine_now():
messages.error(request, _("This gift card is no longer valid."))
return
if gc.value <= Decimal("0.00"):
@@ -1491,7 +1492,7 @@ class GiftCardPayment(BasePaymentProvider):
if not gc.testmode and payment.order.testmode:
messages.error(request, _("Only test gift cards can be used in test mode."))
return
if gc.expires and gc.expires < now():
if gc.expires and gc.expires < time_machine_now():
messages.error(request, _("This gift card is no longer valid."))
return
if gc.value <= Decimal("0.00"):
@@ -1539,7 +1540,7 @@ class GiftCardPayment(BasePaymentProvider):
raise PaymentException(_("This gift card can only be used in test mode."))
if not gc.testmode and payment.order.testmode:
raise PaymentException(_("Only test gift cards can be used in test mode."))
if gc.expires and gc.expires < now():
if gc.expires and gc.expires < time_machine_now():
raise PaymentException(_("This gift card is no longer valid."))
trans = gc.transactions.create(

View File

@@ -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:

View File

@@ -25,13 +25,13 @@ from typing import List, Optional
from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from pretix.base.models import (
AbstractPosition, CartPosition, Customer, Event, Item, Membership, Order,
OrderPosition, SubEvent,
)
from pretix.base.timemachine import time_machine_now
from pretix.helpers import OF_SELF
@@ -48,7 +48,7 @@ def membership_validity(item: Item, subevent: Optional[SubEvent], event: Event):
else:
# Always start at start of day
date_start = now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
date_start = time_machine_now().astimezone(tz).replace(hour=0, minute=0, second=0, microsecond=0)
date_end = date_start
if item.grant_membership_duration_months:

View File

@@ -102,6 +102,7 @@ from pretix.base.signals import (
order_fee_calculation, order_paid, order_placed, order_reactivated,
order_split, order_valid_if_pending, periodic_task, validate_order,
)
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
@@ -648,10 +649,11 @@ def _check_date(event: Event, now_dt: datetime):
raise OrderError(error_messages['ended'])
def _check_positions(event: Event, now_dt: datetime, positions: List[CartPosition], address: InvoiceAddress=None,
def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: datetime, positions: List[CartPosition],
address: InvoiceAddress = None,
sales_channel='web', customer=None):
err = None
_check_date(event, now_dt)
_check_date(event, time_machine_now_dt)
products_seen = Counter()
q_avail = Counter()
@@ -729,7 +731,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
if cp.subevent and cp.subevent.presale_start and now_dt < cp.subevent.presale_start:
if cp.subevent and cp.subevent.presale_start and time_machine_now_dt < cp.subevent.presale_start:
err = err or error_messages['some_subevent_not_started']
delete(cp)
break
@@ -741,7 +743,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
tlv.datetime(cp.subevent).date(),
time(hour=23, minute=59, second=59)
), event.timezone)
if term_last < now_dt:
if term_last < time_machine_now_dt:
err = err or error_messages['some_subevent_ended']
delete(cp)
break
@@ -787,19 +789,19 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
continue
if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(now_dt):
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(now_dt):
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 < now_dt:
if cp.voucher.valid_until and cp.voucher.valid_until < time_machine_now_dt:
err = err or error_messages['voucher_expired']
delete(cp)
continue
@@ -1163,7 +1165,8 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
warnings = []
any_payment_failed = False
now_dt = now()
real_now_dt = now()
time_machine_now_dt = time_machine_now(real_now_dt)
err_out = None
with transaction.atomic(durable=True):
positions = list(
@@ -1175,14 +1178,15 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if len(position_ids) != len(positions):
raise OrderError(error_messages['internal'])
try:
_check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer)
_check_positions(event, real_now_dt, time_machine_now_dt, positions,
address=addr, sales_channel=sales_channel, customer=customer)
except OrderError as e:
err_out = e # Don't raise directly to make sure transaction is committed, as it might have deleted things
else:
if 'sleep-after-quota-check' in debugflags_var.get():
sleep(2)
order, payment_objs = _create_order(event, email, positions, now_dt, payment_requests,
order, payment_objs = _create_order(event, email, positions, real_now_dt, payment_requests,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
shown_total=shown_total, customer=customer, valid_if_pending=valid_if_pending)
@@ -2849,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,

View File

@@ -25,7 +25,6 @@ from typing import List, Optional, Tuple
from django import forms
from django.db.models import Q
from django.utils.timezone import now
from pretix.base.decimal import round_decimal
from pretix.base.models import (
@@ -33,6 +32,7 @@ from pretix.base.models import (
)
from pretix.base.models.event import Event, SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.timemachine import time_machine_now
def get_price(item: Item, variation: ItemVariation = None,
@@ -167,8 +167,8 @@ def apply_discounts(event: Event, sales_channel: str,
new_prices = {}
discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=now()),
Q(available_until__isnull=True) | Q(available_until__gte=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()),
sales_channels__contains=sales_channel,
active=True,
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')

View File

@@ -0,0 +1,85 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io 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/>.
#
import contextvars
from contextlib import contextmanager
from dateutil.parser import parse
from django.utils.timezone import now
timemachine_now_var = contextvars.ContextVar('timemachine_now', default=None)
@contextmanager
def time_machine_now_assigned_from_request(request):
if hasattr(request, 'event') and f'timemachine_now_dt:{request.event.pk}' in request.session and \
request.event.testmode and has_time_machine_permission(request, request.event):
request.now_dt = parse(request.session[f'timemachine_now_dt:{request.event.pk}'])
request.now_dt_is_fake = True
else:
request.now_dt = now()
request.now_dt_is_fake = False
try:
timemachine_now_var.set(request.now_dt if request.now_dt_is_fake else None)
yield
finally:
timemachine_now_var.set(None)
def time_machine_now(default=False):
"""
Return the datetime to use as current datetime for checking order restrictions in event
index and checkout flow.
:param default: Value to return if time machine mode is disabled. By default the current datetime is used.
"""
if default is False:
default = now()
return timemachine_now_var.get() or default
@contextmanager
def time_machine_now_assigned(now_dt):
"""
Use this context manager to assign current datetime for time machine mode. Useful e.g. for background tasks.
:param now_dt: The datetime value to assign. May be `None` to disable time machine.
"""
try:
timemachine_now_var.set(now_dt)
yield
finally:
timemachine_now_var.set(None)
def has_time_machine_permission(request, event):
permission = 'can_change_event_settings'
return (
request.user.is_authenticated and
request.user.has_event_permission(request.organizer, request.event, permission, request=request)
) or (
getattr(request, 'event_access_user', None) and
request.event_access_user.is_authenticated and
request.event_access_user.has_event_permission(request.organizer, request.event, permission, request=request)
)