diff --git a/doc/development/implementation/index.rst b/doc/development/implementation/index.rst index 9a050c02b8..64adb27867 100644 --- a/doc/development/implementation/index.rst +++ b/doc/development/implementation/index.rst @@ -19,3 +19,4 @@ Contents: permissions logging locking + timemachine diff --git a/doc/development/implementation/timemachine.rst b/doc/development/implementation/timemachine.rst new file mode 100644 index 0000000000..dc20115fd6 --- /dev/null +++ b/doc/development/implementation/timemachine.rst @@ -0,0 +1,32 @@ +Time machine mode +================= + +In test mode, pretix provides a "time machine" feature which allows event organizers +to test their shop as if it were a different date and time. To enable this feature, they can +click on the "time machine"-link in the test mode warning box on the event page. + +Internally, this time machine mode is implemented by calling our custom :py:meth:`time_machine_now()` +function instead of :py:meth:`django.utils.timezone.now()` in all places where the fake time should be +taken into account. If you add code that uses the current date and time for checking whether some +product can be bought, you should use :py:meth:`time_machine_now`. + +.. autofunction:: pretix.base.timemachine.time_machine_now + +Background tasks +---------------- + +The time machine datetime is passed through the request flow via a thread-local variable (ContextVar). +Therefore, if you call a background task in the order process, where time_machine_now should be +respected, you need to pass it through manually as shown in the example below: + +.. code-block:: python + + @app.task() + def my_task(self, override_now_dt: datetime=None) -> None: + with time_machine_now_assigned(override_now_dt): + # ...do something that uses time_machine_now() + + my_task.apply_async(kwargs={'override_now_dt': time_machine_now(default=None)}) + + +.. autofunction:: pretix.base.timemachine.time_machine_now_assigned diff --git a/doc/development/implementation/urlconfig.rst b/doc/development/implementation/urlconfig.rst index b2bc7ebab3..e1f5b86417 100644 --- a/doc/development/implementation/urlconfig.rst +++ b/doc/development/implementation/urlconfig.rst @@ -90,6 +90,10 @@ as its first argument and can be used like this:: Pay Pay +To generate absolute URLs on the main domain, you can use the ``absurl`` template tag:: + + {% load eventurl %} + Event settings Implementation details ---------------------- diff --git a/doc/development/setup.rst b/doc/development/setup.rst index f4f04c0abd..fc83e80cb9 100644 --- a/doc/development/setup.rst +++ b/doc/development/setup.rst @@ -211,5 +211,15 @@ with the documentation a lot, you might find it useful to use sphinx-autobuild:: Then, go to http://localhost:8081 for a version of the documentation that automatically re-builds whenever you change a source file. +Working with frontend assets +---------------------------- + +To update the frontend styles of shops with a custom styling, run the following commands inside +your virtual environment.:: + + python -m pretix collectstatic --noinput + python -m pretix updatestyles + + .. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver .. _pretixdroid: https://github.com/pretix/pretixdroid diff --git a/doc/development/structure.rst b/doc/development/structure.rst index b17d3ba069..20eef4bc44 100644 --- a/doc/development/structure.rst +++ b/doc/development/structure.rst @@ -31,7 +31,7 @@ pretix/ Additional code implementing our customized :ref:`URL handling `. static/ - Contains all static files (CSS/SASS, JavaScript, images) of pretix' core + Contains all static files (CSS/SASS, JavaScript, images) of pretix' core. We use libsass as a preprocessor for CSS. Our own sass code is built in the same step as Bootstrap and FontAwesome, so their mixins etc. are fully available. diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index f1c525b5ea..a11bb1f0ea 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -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: diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 81f3ba2cc0..a18f05d623 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -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(), diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 8e8bf7070e..4f0f24d53f 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -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' diff --git a/src/pretix/base/models/memberships.py b/src/pretix/base/models/memberships.py index b444509722..540cba6233 100644 --- a/src/pretix/base/models/memberships.py +++ b/src/pretix/base/models/memberships.py @@ -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 diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index ed014b264c..465ce45ca7 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -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, ) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 4dad532cf7..955f57e824 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -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( diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 2387f988e3..a3e7b7f09e 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/memberships.py b/src/pretix/base/services/memberships.py index e85c728b41..fc52d76cc2 100644 --- a/src/pretix/base/services/memberships.py +++ b/src/pretix/base/services/memberships.py @@ -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: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 624c70fd6b..8182c27dde 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -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, diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 50fcc73169..9c4a12ffb5 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -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') diff --git a/src/pretix/base/timemachine.py b/src/pretix/base/timemachine.py new file mode 100644 index 0000000000..e8848c8372 --- /dev/null +++ b/src/pretix/base/timemachine.py @@ -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 . +# +# 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 +# . +# +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) + ) diff --git a/src/pretix/control/context.py b/src/pretix/control/context.py index 736887413d..13eb1d2054 100644 --- a/src/pretix/control/context.py +++ b/src/pretix/control/context.py @@ -46,11 +46,11 @@ from pretix.base.settings import GlobalSettingsObject from pretix.control.navigation import ( get_event_navigation, get_global_navigation, get_organizer_navigation, ) - -from ..helpers.i18n import ( +from pretix.helpers.i18n import ( get_javascript_format, get_javascript_output_format, get_moment_locale, ) -from ..multidomain.urlreverse import get_event_domain +from pretix.multidomain.urlreverse import get_event_domain + from .signals import html_head, nav_topbar SessionStore = import_module(settings.SESSION_ENGINE).SessionStore @@ -106,7 +106,7 @@ def _default_context(request): else: ctx['complain_testmode_orders'] = False - if not request.event.live and ctx['has_domain']: + if (request.event.testmode or not request.event.live) and ctx['has_domain']: child_sess = request.session.get('child_session_{}'.format(request.event.pk)) s = SessionStore() if not child_sess or not s.exists(child_sess): @@ -114,10 +114,8 @@ def _default_context(request): s.create() ctx['new_session'] = s.session_key request.session['child_session_{}'.format(request.event.pk)] = s.session_key - request.session['event_access'] = True else: ctx['new_session'] = child_sess - request.session['event_access'] = True if request.GET.get('subevent', ''): # Do not use .get() for lazy evaluation diff --git a/src/pretix/control/templates/pretixcontrol/event/live.html b/src/pretix/control/templates/pretixcontrol/event/live.html index 79dcad7fbf..10a7185399 100644 --- a/src/pretix/control/templates/pretixcontrol/event/live.html +++ b/src/pretix/control/templates/pretixcontrol/event/live.html @@ -46,7 +46,7 @@ {% endfor %} -
+
@@ -82,10 +82,10 @@

{% trans "Your shop is currently in test mode. All orders are not persistent and can be deleted at any point." %}

-
-