Compare commits

...

45 Commits

Author SHA1 Message Date
Mira Weller
0630b67546 Improve comment on multidomainurl 2024-05-07 20:53:42 +02:00
Mira Weller
9101238743 Add test cases, fix docs 2024-05-07 20:49:17 +02:00
Mira Weller
cefbfc1ad1 Redirect directly to time machine page after session transfer 2024-05-07 12:42:07 +02:00
Mira Weller
69a798046e Create absmainurl template tag, use for session transfer link 2024-05-07 12:38:21 +02:00
Mira Weller
c7983bf811 unused imports 2024-04-30 21:55:15 +02:00
Mira Weller
042be3603b use time_machine_now for waiting_list_auto_disable 2024-04-30 21:52:44 +02:00
Mira Weller
1de2320cc5 use time_machine_now for GiftCard, membership and discount validity 2024-04-30 21:49:58 +02:00
Mira Weller
8042d9d3f4 Scope time machine state per event 2024-04-30 21:49:58 +02:00
Mira Weller
44afe9e193 Improve form/error handling 2024-04-30 21:49:58 +02:00
Mira Weller
46dce1bf43 Implement review comments 2024-04-30 21:49:58 +02:00
Mira Weller
4865879978 fontawesome elements have wider characters 2024-04-30 21:49:58 +02:00
Mira
4c793076b7 Apply suggestions from code review
Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Raphael Michel <michel@rami.io>
2024-04-30 21:49:58 +02:00
Mira Weller
5294d819a9 flake8 2024-04-30 21:49:58 +02:00
Mira Weller
7bfe94139a Fix exception if parent session expired 2024-04-30 21:49:58 +02:00
Mira Weller
e5cbaa9246 Improve docs 2024-04-30 21:49:58 +02:00
Mira Weller
37208286b1 Improve UI 2024-04-30 21:49:58 +02:00
Mira Weller
6da56291d4 Put text on "Time machine" button 2024-04-30 21:49:58 +02:00
Mira Weller
2d335cd095 improve documentation 2024-04-30 21:49:58 +02:00
Mira Weller
569f1719b0 add documentation 2024-04-30 21:49:58 +02:00
Mira Weller
00dd1a5b31 add documentation 2024-04-30 21:49:58 +02:00
Mira Weller
18bebb6d31 Separate event_access user from regular user 2024-04-30 21:49:58 +02:00
Mira Weller
77c8e81cd7 Remove stray print() call 2024-04-30 21:49:58 +02:00
Mira Weller
3d03f30119 Fix bootstrap classes 2024-04-30 21:49:58 +02:00
Mira Weller
91b2d685da Use ContextVar instead of threading.local 2024-04-30 21:49:58 +02:00
Mira Weller
9787ed1820 Move time machine logic into contextmanager 2024-04-30 21:49:58 +02:00
Mira Weller
204b8e53de Changes from review 2024-04-30 21:49:58 +02:00
Mira Weller
64358be4ae Move timemachine controls into presale, implement session transfer for multidomain time machine support 2024-04-30 21:49:58 +02:00
Mira Weller
5b1175ff05 Code formatting 2024-04-30 21:49:58 +02:00
Mira Weller
e6f56bfdc2 Fix dynamic validity and add test cases 2024-04-30 21:49:58 +02:00
Mira Weller
9610e9c89f Pass time_machine_now to async tasks 2024-04-30 21:49:58 +02:00
Mira Weller
c5f4eeeb28 Distinguish real and time_machine now in Order creation 2024-04-30 21:49:58 +02:00
Mira Weller
b61880fb5b Allow passing a fallback now_dt to time_machine_now 2024-04-30 21:49:58 +02:00
Mira Weller
b29c7fc11d Enable time machine only in testmode 2024-04-30 21:49:58 +02:00
Mira Weller
d99bf7437a Use time_machine_now in more Order related checks and for Order.datetime 2024-04-30 21:49:58 +02:00
Mira Weller
648cc14ae0 Move timemachine to pretix.base, revert package refactor 2024-04-30 21:49:58 +02:00
Mira Weller
5d71cb500a Move timemachine to pretix.base.middleware 2024-04-30 21:49:58 +02:00
Mira Weller
68d81982ba Make pretix.base.middleware a package 2024-04-30 21:49:58 +02:00
Mira Weller
efa0d5f362 use time_machine_now for order expiry 2024-04-30 21:49:58 +02:00
Mira Weller
046898678b Improve UX 2024-04-30 21:49:58 +02:00
Mira Weller
f38ecd0ec7 remove some print() logging 2024-04-30 21:49:57 +02:00
Mira Weller
3dca6c232e add time machine form to live.html 2024-04-30 21:49:57 +02:00
Mira Weller
297bf566ad fix live.html layout 2024-04-30 21:49:57 +02:00
Mira Weller
67f09b5ede Enable time machine for membership and ticket validity dates 2024-04-30 21:49:57 +02:00
Mira Weller
752137ad84 timemachine 2024-04-30 21:49:57 +02:00
Mira Weller
100528ad0f start implementing time machine mode (thread local) 2024-04-30 21:49:57 +02:00
38 changed files with 789 additions and 142 deletions

View File

@@ -19,3 +19,4 @@ Contents:
permissions permissions
logging logging
locking locking
timemachine

View File

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

View File

@@ -90,6 +90,10 @@ as its first argument and can be used like this::
<a href="{% eventurl request.event "presale:event.checkout" step="payment" %}">Pay</a> <a href="{% eventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
<a href="{% abseventurl request.event "presale:event.checkout" step="payment" %}">Pay</a> <a href="{% abseventurl request.event "presale:event.checkout" step="payment" %}">Pay</a>
To generate absolute URLs on the main domain, you can use the ``absurl`` template tag::
{% load eventurl %}
<a href="{% absmainurl "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}">Event settings</a>
Implementation details Implementation details
---------------------- ----------------------

View File

@@ -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 Then, go to http://localhost:8081 for a version of the documentation that automatically re-builds
whenever you change a source file. 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 .. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
.. _pretixdroid: https://github.com/pretix/pretixdroid .. _pretixdroid: https://github.com/pretix/pretixdroid

View File

@@ -31,7 +31,7 @@ pretix/
Additional code implementing our customized :ref:`URL handling <urlconf>`. Additional code implementing our customized :ref:`URL handling <urlconf>`.
static/ 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 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. step as Bootstrap and FontAwesome, so their mixins etc. are fully available.

View File

@@ -57,7 +57,7 @@ from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe 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.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries import countries from django_countries import countries
from django_countries.fields import Country, CountryField from django_countries.fields import Country, CountryField
@@ -86,6 +86,7 @@ from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS,
) )
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.base.timemachine import time_machine_now
from pretix.control.forms import ( from pretix.control.forms import (
ExtFileField, ExtValidationMixin, SizeValidationMixin, SplitDateTimeField, 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 cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice:
if item.validity_dynamic_start_choice_day_limit: 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: else:
max_date = None max_date = None
min_date = now() min_date = time_machine_now()
initial = None initial = None
if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership: 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 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 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: if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days:

View File

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

View File

@@ -55,7 +55,7 @@ from django.db.models import Q
from django.utils import formats from django.utils import formats
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property 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.utils.translation import gettext_lazy as _, pgettext_lazy
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import ScopedManager 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.base import LoggedModel
from pretix.base.models.fields import MultiStringField from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice from pretix.base.models.tax import TaxedPrice
from pretix.base.timemachine import time_machine_now
from ...helpers.images import ImageSizeValidator from ...helpers.images import ImageSizeValidator
from ..media import MEDIA_TYPES from ..media import MEDIA_TYPES
@@ -192,7 +193,7 @@ class SubEventItem(models.Model):
self.subevent.event.cache.clear() self.subevent.event.cache.clear()
def is_available(self, now_dt: datetime=None) -> bool: 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: if self.disabled:
return False return False
if self.available_from and self.available_from > now_dt: if self.available_from and self.available_from > now_dt:
@@ -248,7 +249,7 @@ class SubEventItemVariation(models.Model):
self.subevent.event.cache.clear() self.subevent.event.cache.clear()
def is_available(self, now_dt: datetime=None) -> bool: 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: if self.disabled:
return False return False
if self.available_from and self.available_from > now_dt: 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 # IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated() # in models/event.py: EventMixin.annotated()
Q(active=True) Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()) | Q(available_from_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=now()) | Q(available_until_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) & Q(sales_channels__contains=channel) & Q(require_bundling=False)
) )
if not allow_addons: if not allow_addons:
@@ -782,7 +783,7 @@ class Item(LoggedModel):
return t return t
def is_available_by_time(self, now_dt: datetime=None) -> bool: 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: if self.available_from and self.available_from > now_dt:
return False return False
if self.available_until and self.available_until < now_dt: 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 Returns whether this item is available according to its ``active`` flag
and its ``available_from`` and ``available_until`` fields 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): if not self.active or not self.is_available_by_time(now_dt):
return False return False
return True return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]: 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) subevent_item = subevent and subevent.item_overrides.get(self.pk)
if not self.active: if not self.active:
return 'active' return 'active'
@@ -957,11 +958,11 @@ class Item(LoggedModel):
return self.validity_fixed_from, self.validity_fixed_until return self.validity_fixed_from, self.validity_fixed_until
elif self.validity_mode == Item.VALIDITY_MODE_DYNAMIC: elif self.validity_mode == Item.VALIDITY_MODE_DYNAMIC:
tz = override_tz or self.event.timezone 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: 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: 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) valid_until = requested_start.astimezone(tz)
@@ -1290,7 +1291,7 @@ class ItemVariation(models.Model):
return ItemVariation.objects.filter(item=self.item).count() == 1 return ItemVariation.objects.filter(item=self.item).count() == 1
def is_available_by_time(self, now_dt: datetime=None) -> bool: 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: if self.available_from and self.available_from > now_dt:
return False return False
if self.available_until and self.available_until < now_dt: 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 Returns whether this item is available according to its ``active`` flag
and its ``available_from`` and ``available_until`` fields 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): if not self.active or not self.is_available_by_time(now_dt):
return False return False
return True return True
def unavailability_reason(self, now_dt: datetime=None, has_voucher=False, subevent=None) -> Optional[str]: 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) subevent_var = subevent and subevent.var_overrides.get(self.pk)
if not self.active: if not self.active:
return '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 import Count, OuterRef, Subquery, Value
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField from i18nfield.fields import I18nCharField
@@ -31,6 +30,7 @@ from i18nfield.fields import I18nCharField
from pretix.base.models import Customer from pretix.base.models import Customer
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.models.organizer import Organizer from pretix.base.models.organizer import Organizer
from pretix.base.timemachine import time_machine_now
from pretix.helpers.names import build_name 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): def is_valid(self, ev=None, ticket_valid_from=None, valid_from_not_chosen=False):
if valid_from_not_chosen: 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: elif ticket_valid_from:
dt = ticket_valid_from dt = ticket_valid_from
elif ev: elif ev:
dt = ev.date_from dt = ev.date_from
else: else:
dt = now() dt = time_machine_now()
return not self.canceled and dt >= self.date_start and dt <= self.date_end 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.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import allow_ticket_download, order_gracefully_delete 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 import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
@@ -681,7 +682,7 @@ class Order(LockModel, LoggedModel):
for op in positions: for op in positions:
if op.issued_gift_cards.all(): if op.issued_gift_cards.all():
return False 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 False
return ( return (
@@ -713,7 +714,7 @@ class Order(LockModel, LoggedModel):
return False return False
if op.granted_memberships.with_usages().filter(usages__gt=0): if op.granted_memberships.with_usages().filter(usages__gt=0):
return False 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 return False
if self.status == Order.STATUS_PAID: if self.status == Order.STATUS_PAID:
@@ -851,7 +852,7 @@ class Order(LockModel, LoggedModel):
return False return False
modify_deadline = self.modify_deadline 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 return False
positions = list( positions = list(
@@ -903,7 +904,7 @@ class Order(LockModel, LoggedModel):
return self.event.settings.ticket_download and ( return self.event.settings.ticket_download and (
self.event.settings.ticket_download_date is None self.event.settings.ticket_download_date is None
or self.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 ( ) and (
self.status == Order.STATUS_PAID self.status == Order.STATUS_PAID
or ( or (
@@ -975,7 +976,7 @@ class Order(LockModel, LoggedModel):
return error_messages['require_approval'] return error_messages['require_approval']
term_last = self.payment_term_last term_last = self.payment_term_last
if term_last and not ignore_date: if term_last and not ignore_date:
if now() > term_last: if time_machine_now() > term_last:
return error_messages['late_lastdate'] return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING: if self.status == self.STATUS_PENDING:
@@ -998,7 +999,7 @@ class Order(LockModel, LoggedModel):
'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'), 'voucher_budget': _('The voucher "{voucher}" no longer has sufficient budget.'),
'voucher_usages': _('The voucher "{voucher}" has been used in the meantime.'), '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')) positions = list(self.positions.all().select_related('item', 'variation', 'seat', 'voucher'))
quota_cache = {} quota_cache = {}
v_budget = {} v_budget = {}
@@ -2535,9 +2536,9 @@ class OrderPosition(AbstractPosition):
if cartpos.item.validity_mode: if cartpos.item.validity_mode:
valid_from, valid_until = cartpos.item.compute_validity( valid_from, valid_until = cartpos.item.compute_validity(
requested_start=( 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 if cartpos.requested_valid_from and cartpos.item.validity_dynamic_start_choice
else now() else time_machine_now()
), ),
enforce_start_limit=True, enforce_start_limit=True,
override_tz=order.event.timezone, override_tz=order.event.timezone,
@@ -3063,9 +3064,9 @@ class CartPosition(AbstractPosition):
def predicted_validity(self): def predicted_validity(self):
return self.item.compute_validity( return self.item.compute_validity(
requested_start=( 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 if self.requested_valid_from and self.item.validity_dynamic_start_choice
else now() else time_machine_now()
), ),
override_tz=self.event.timezone, 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.signals import register_payment_providers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text 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 import OF_SELF
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map from pretix.helpers.format import format_map
@@ -1441,7 +1442,7 @@ class GiftCardPayment(BasePaymentProvider):
if not gc.testmode and self.event.testmode: if not gc.testmode and self.event.testmode:
messages.error(request, _("Only test gift cards can be used in test mode.")) messages.error(request, _("Only test gift cards can be used in test mode."))
return 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.")) messages.error(request, _("This gift card is no longer valid."))
return return
if gc.value <= Decimal("0.00"): if gc.value <= Decimal("0.00"):
@@ -1491,7 +1492,7 @@ class GiftCardPayment(BasePaymentProvider):
if not gc.testmode and payment.order.testmode: if not gc.testmode and payment.order.testmode:
messages.error(request, _("Only test gift cards can be used in test mode.")) messages.error(request, _("Only test gift cards can be used in test mode."))
return 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.")) messages.error(request, _("This gift card is no longer valid."))
return return
if gc.value <= Decimal("0.00"): 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.")) raise PaymentException(_("This gift card can only be used in test mode."))
if not gc.testmode and payment.order.testmode: if not gc.testmode and payment.order.testmode:
raise PaymentException(_("Only test gift cards can be used in test mode.")) 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.")) raise PaymentException(_("This gift card is no longer valid."))
trans = gc.transactions.create( 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.settings import PERSON_NAME_SCHEMES, LazyI18nStringList
from pretix.base.signals import validate_cart_addons from pretix.base.signals import validate_cart_addons
from pretix.base.templatetags.rich_text import rich_text 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.celery_app import app
from pretix.presale.signals import ( from pretix.presale.signals import (
checkout_confirm_messages, fee_calculation_for_cart, checkout_confirm_messages, fee_calculation_for_cart,
@@ -278,7 +279,7 @@ class CartManager:
sales_channel='web'): sales_channel='web'):
self.event = event self.event = event
self.cart_id = cart_id self.cart_id = cart_id
self.now_dt = now() self.real_now_dt = now()
self._operations = [] self._operations = []
self._quota_diff = Counter() self._quota_diff = Counter()
self._voucher_use_diff = Counter() self._voucher_use_diff = Counter()
@@ -305,10 +306,10 @@ class CartManager:
return self._seated_cache[item, subevent] return self._seated_cache[item, subevent]
def _calculate_expiry(self): 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): 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']) raise CartError(error_messages['not_started'])
if self.event.presale_has_ended: if self.event.presale_has_ended:
raise CartError(error_messages['ended']) raise CartError(error_messages['ended'])
@@ -319,13 +320,13 @@ class CartManager:
tlv.datetime(self.event).date(), tlv.datetime(self.event).date(),
time(hour=23, minute=59, second=59) time(hour=23, minute=59, second=59)
), self.event.timezone) ), 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']) raise CartError(error_messages['payment_ended'])
def _extend_expiry_of_valid_existing_positions(self): 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 # 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 # 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): def _delete_out_of_timeframe(self):
err = None err = None
@@ -333,12 +334,12 @@ class CartManager:
if not cp.pk: if not cp.pk:
continue 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'] err = error_messages['some_subevent_not_started']
cp.addons.all().delete() cp.addons.all().delete()
cp.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'] err = error_messages['some_subevent_ended']
cp.addons.all().delete() cp.addons.all().delete()
cp.delete() cp.delete()
@@ -350,7 +351,7 @@ class CartManager:
tlv.datetime(cp.subevent).date(), tlv.datetime(cp.subevent).date(),
time(hour=23, minute=59, second=59) time(hour=23, minute=59, second=59)
), self.event.timezone) ), 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'] err = error_messages['some_subevent_ended']
cp.addons.all().delete() cp.addons.all().delete()
cp.delete() cp.delete()
@@ -449,7 +450,7 @@ class CartManager:
if op.subevent and not op.subevent.active: if op.subevent and not op.subevent.active:
raise CartError(error_messages['inactive_subevent']) 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']) raise CartError(error_messages['not_started'])
if op.subevent and op.subevent.presale_has_ended: if op.subevent and op.subevent.presale_has_ended:
@@ -472,7 +473,7 @@ class CartManager:
tlv.datetime(op.subevent).date(), tlv.datetime(op.subevent).date(),
time(hour=23, minute=59, second=59) time(hour=23, minute=59, second=59)
), self.event.timezone) ), 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']) raise CartError(error_messages['payment_ended'])
if isinstance(op, self.AddOperation): if isinstance(op, self.AddOperation):
@@ -509,7 +510,7 @@ class CartManager:
) )
if not self.event.settings.seating_choice: if not self.event.settings.seating_choice:
requires_seat = Value(0, output_field=IntegerField()) 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' 'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
).annotate( ).annotate(
requires_seat=requires_seat requires_seat=requires_seat
@@ -690,7 +691,7 @@ class CartManager:
# than either of the possible default assumptions. # than either of the possible default assumptions.
predicted_redeemed_after = ( predicted_redeemed_after = (
voucher.redeemed + 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] + self._voucher_use_diff[voucher] +
voucher_use_diff[voucher] voucher_use_diff[voucher]
) )
@@ -982,7 +983,7 @@ class CartManager:
current_num = len(current_addons[cp].get(k, [])) current_num = len(current_addons[cp].get(k, []))
if input_num < current_num: if input_num < current_num:
for a in current_addons[cp][k][:current_num - input_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) quotas = list(a.quotas)
for quota in quotas: for quota in quotas:
@@ -996,7 +997,7 @@ class CartManager:
def _get_voucher_availability(self): def _get_voucher_availability(self):
vouchers_ok, self._voucher_depend_on_cart = _get_voucher_availability( 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=[ exclude_position_ids=[
op.position.id for op in self._operations if isinstance(op, self.ExtendOperation) op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
] ]
@@ -1101,7 +1102,7 @@ class CartManager:
shared_lock_objects=[self.event] shared_lock_objects=[self.event]
) )
vouchers_ok = self._get_voucher_availability() 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 err = None
new_cart_positions = [] new_cart_positions = []
deleted_positions = set() deleted_positions = set()
@@ -1118,7 +1119,7 @@ class CartManager:
for iop, op in enumerate(self._operations): for iop, op in enumerate(self._operations):
if isinstance(op, self.RemoveOperation): 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: for q in op.position.quotas:
quotas_ok[q] += 1 quotas_ok[q] += 1
addons = op.position.addons.all() addons = op.position.addons.all()
@@ -1395,7 +1396,7 @@ class CartManager:
err = self.extend_expired_positions() or err err = self.extend_expired_positions() or err
err = err or self._check_min_per_voucher() err = err or self._check_min_per_voucher()
self.now_dt = now() self.real_now_dt = now()
self._extend_expiry_of_valid_existing_positions() self._extend_expiry_of_valid_existing_positions()
err = self._perform_operations() or err 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,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en', def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, widget_data=None, sales_channel='web') -> 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. Adds a list of items to a user's cart.
:param event: The event ID in question :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 :param cart_id: Session ID of a guest
:raises CartError: On any error that occurred :raises CartError: On any error that occurred
""" """
with language(locale): with language(locale), time_machine_now_assigned(override_now_dt):
ia = False ia = False
if invoice_address: if invoice_address:
try: 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,)) @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. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param voucher: A voucher code :param voucher: A voucher code
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
@@ -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,)) @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. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param position: A cart position ID :param position: A cart position ID
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
@@ -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,)) @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. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
@@ -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,)) @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', 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. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param addons: A list of dicts with the keys addon_to, item, variation :param addons: A list of dicts with the keys addon_to, item, variation
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale), time_machine_now_assigned(override_now_dt):
ia = False ia = False
if invoice_address: if invoice_address:
try: try:

View File

@@ -25,13 +25,13 @@ from typing import List, Optional
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.models import ( from pretix.base.models import (
AbstractPosition, CartPosition, Customer, Event, Item, Membership, Order, AbstractPosition, CartPosition, Customer, Event, Item, Membership, Order,
OrderPosition, SubEvent, OrderPosition, SubEvent,
) )
from pretix.base.timemachine import time_machine_now
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
@@ -48,7 +48,7 @@ def membership_validity(item: Item, subevent: Optional[SubEvent], event: Event):
else: else:
# Always start at start of day # 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 date_end = date_start
if item.grant_membership_duration_months: 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_split, order_fee_calculation, order_paid, order_placed, order_split,
order_valid_if_pending, periodic_task, validate_order, 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.celery_app import app
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
@@ -648,10 +649,11 @@ def _check_date(event: Event, now_dt: datetime):
raise OrderError(error_messages['ended']) 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): sales_channel='web', customer=None):
err = None err = None
_check_date(event, now_dt) _check_date(event, time_machine_now_dt)
products_seen = Counter() products_seen = Counter()
q_avail = Counter() q_avail = Counter()
@@ -729,7 +731,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp) delete(cp)
continue 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'] err = err or error_messages['some_subevent_not_started']
delete(cp) delete(cp)
break break
@@ -741,7 +743,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
tlv.datetime(cp.subevent).date(), tlv.datetime(cp.subevent).date(),
time(hour=23, minute=59, second=59) time(hour=23, minute=59, second=59)
), event.timezone) ), event.timezone)
if term_last < now_dt: if term_last < time_machine_now_dt:
err = err or error_messages['some_subevent_ended'] err = err or error_messages['some_subevent_ended']
delete(cp) delete(cp)
break break
@@ -787,19 +789,19 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp) delete(cp)
continue continue
if cp.subevent and cp.item.pk in cp.subevent.item_overrides and not cp.subevent.item_overrides[cp.item.pk].is_available(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'] err = err or error_messages['unavailable']
delete(cp) delete(cp)
continue continue
if cp.subevent and cp.variation and cp.variation.pk in cp.subevent.var_overrides and \ 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'] err = err or error_messages['unavailable']
delete(cp) delete(cp)
continue continue
if cp.voucher: 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'] err = err or error_messages['voucher_expired']
delete(cp) delete(cp)
continue continue
@@ -1163,7 +1165,8 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
warnings = [] warnings = []
any_payment_failed = False any_payment_failed = False
now_dt = now() real_now_dt = now()
time_machine_now_dt = time_machine_now(real_now_dt)
err_out = None err_out = None
with transaction.atomic(durable=True): with transaction.atomic(durable=True):
positions = list( positions = list(
@@ -1175,14 +1178,15 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if len(position_ids) != len(positions): if len(position_ids) != len(positions):
raise OrderError(error_messages['internal']) raise OrderError(error_messages['internal'])
try: 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: except OrderError as e:
err_out = e # Don't raise directly to make sure transaction is committed, as it might have deleted things err_out = e # Don't raise directly to make sure transaction is committed, as it might have deleted things
else: else:
if 'sleep-after-quota-check' in debugflags_var.get(): if 'sleep-after-quota-check' in debugflags_var.get():
sleep(2) 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, locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
shown_total=shown_total, customer=customer, valid_if_pending=valid_if_pending) 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,)) @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], 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, email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web', shown_total=None, customer=None): sales_channel: str='web', shown_total=None, customer=None, override_now_dt: datetime=None):
with language(locale): with language(locale), time_machine_now_assigned(override_now_dt):
try: try:
try: try:
return _perform_order(event, payments, positions, email, locale, address, meta_info, 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 import forms
from django.db.models import Q from django.db.models import Q
from django.utils.timezone import now
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models import ( 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.event import Event, SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule 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, def get_price(item: Item, variation: ItemVariation = None,
@@ -167,8 +167,8 @@ def apply_discounts(event: Event, sales_channel: str,
new_prices = {} new_prices = {}
discount_qs = event.discounts.filter( discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=now()), Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
Q(available_until__isnull=True) | Q(available_until__gte=now()), Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
sales_channels__contains=sales_channel, sales_channels__contains=sales_channel,
active=True, active=True,
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') ).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)
)

View File

@@ -46,11 +46,11 @@ from pretix.base.settings import GlobalSettingsObject
from pretix.control.navigation import ( from pretix.control.navigation import (
get_event_navigation, get_global_navigation, get_organizer_navigation, get_event_navigation, get_global_navigation, get_organizer_navigation,
) )
from pretix.helpers.i18n import (
from ..helpers.i18n import (
get_javascript_format, get_javascript_output_format, get_moment_locale, 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 from .signals import html_head, nav_topbar
SessionStore = import_module(settings.SESSION_ENGINE).SessionStore SessionStore = import_module(settings.SESSION_ENGINE).SessionStore
@@ -106,7 +106,7 @@ def _default_context(request):
else: else:
ctx['complain_testmode_orders'] = False 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)) child_sess = request.session.get('child_session_{}'.format(request.event.pk))
s = SessionStore() s = SessionStore()
if not child_sess or not s.exists(child_sess): if not child_sess or not s.exists(child_sess):
@@ -114,10 +114,8 @@ def _default_context(request):
s.create() s.create()
ctx['new_session'] = s.session_key ctx['new_session'] = s.session_key
request.session['child_session_{}'.format(request.event.pk)] = s.session_key request.session['child_session_{}'.format(request.event.pk)] = s.session_key
request.session['event_access'] = True
else: else:
ctx['new_session'] = child_sess ctx['new_session'] = child_sess
request.session['event_access'] = True
if request.GET.get('subevent', ''): if request.GET.get('subevent', ''):
# Do not use .get() for lazy evaluation # Do not use .get() for lazy evaluation

View File

@@ -46,7 +46,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
</div> </div>
<div class="test-right"> <div class="text-right">
<button type="submit" class="btn btn-primary btn-lg btn-save" disabled> <button type="submit" class="btn btn-primary btn-lg btn-save" disabled>
{% trans "Go live" %} {% trans "Go live" %}
</button> </button>
@@ -82,10 +82,10 @@
<p> <p>
{% trans "Your shop is currently in test mode. All orders are not persistent and can be deleted at any point." %} {% trans "Your shop is currently in test mode. All orders are not persistent and can be deleted at any point." %}
</p> </p>
<div class="form-inline"> <div class="checkbox">
<label class="checkbox"> <label>
<input type="checkbox" name="delete" value="yes" /> <input type="checkbox" name="delete" value="yes" />
{% trans "Permanently delete all orders created in test mode" %} <b>{% trans "Permanently delete all orders created in test mode" %}</b>
</label> </label>
</div> </div>
<div class="text-right"> <div class="text-right">

View File

@@ -0,0 +1,9 @@
{% load eventurl %}
{% load static %}
<form action="{% eventurl request.event "presale:event.auth" %}{% if request.GET.next %}?next={{ request.GET.next }}{% endif %}" method="post">
<input type="hidden" value="{{ new_session }}" name="session">
<button type="submit">
Continue
</button>
</form>
<script src="{% static "pretixcontrol/js/send_form.js" %}"></script>

View File

@@ -247,6 +247,7 @@ urlpatterns = [
re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'), re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'),
re_path(r'^logs/embed$', dashboards.event_index_log_lazy, name='event.index.logs'), re_path(r'^logs/embed$', dashboards.event_index_log_lazy, name='event.index.logs'),
re_path(r'^live/$', event.EventLive.as_view(), name='event.live'), re_path(r'^live/$', event.EventLive.as_view(), name='event.live'),
re_path(r'^transfer_session/$', event.EventTransferSession.as_view(), name='event.transfer_session'),
re_path(r'^logs/$', event.EventLog.as_view(), name='event.log'), re_path(r'^logs/$', event.EventLog.as_view(), name='event.log'),
re_path(r'^delete/$', event.EventDelete.as_view(), name='event.delete'), re_path(r'^delete/$', event.EventDelete.as_view(), name='event.delete'),
re_path(r'^comment/$', event.EventComment.as_view(), re_path(r'^comment/$', event.EventComment.as_view(),

View File

@@ -1017,6 +1017,11 @@ class EventLive(EventPermissionRequiredMixin, TemplateView):
}) })
class EventTransferSession(EventPermissionRequiredMixin, TemplateView):
permission = 'can_change_event_settings'
template_name = 'pretixcontrol/event/transfer_session.html'
class EventDelete(RecentAuthenticationRequiredMixin, EventPermissionRequiredMixin, FormView): class EventDelete(RecentAuthenticationRequiredMixin, EventPermissionRequiredMixin, FormView):
permission = 'can_change_event_settings' permission = 'can_change_event_settings'
template_name = 'pretixcontrol/event/delete.html' template_name = 'pretixcontrol/event/delete.html'

View File

@@ -27,7 +27,7 @@ from django.urls import NoReverseMatch
from django.utils.encoding import smart_str from django.utils.encoding import smart_str
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri, mainreverse
register = template.Library() register = template.Library()
@@ -45,11 +45,13 @@ class EventURLNode(URLNode):
for k, v in self.kwargs.items() for k, v in self.kwargs.items()
} }
view_name = self.view_name.resolve(context) view_name = self.view_name.resolve(context)
event = self.event.resolve(context) event = self.event.resolve(context) if self.event is not False else False
url = '' url = ''
try: try:
if self.absolute: if self.absolute:
url = build_absolute_uri(event, view_name, kwargs=kwargs) url = build_absolute_uri(event, view_name, kwargs=kwargs)
elif self.event is False:
url = mainreverse(view_name, kwargs)
else: else:
url = eventreverse(event, view_name, kwargs=kwargs) url = eventreverse(event, view_name, kwargs=kwargs)
except NoReverseMatch: except NoReverseMatch:
@@ -65,21 +67,34 @@ class EventURLNode(URLNode):
return url return url
@register.tag def multidomainurl(parser, token, has_event, absolute):
def eventurl(parser, token, absolute=False):
""" """
Similar to {% url %} in the same way that eventreverse() is similar to reverse(). Similar to {% url %}, but multidomain-aware. Used by eventurl, abseventurl and absmainurl.
Takes an event or organizer object, an url name and optional keyword arguments If has_event=True, takes an event or organizer object as first template tag parameter.
Always takes an url name and optional keyword arguments after that.
Returns an absolute URL in the following cases:
- absolute=True
- has_event=True and the event has a custom domain
Returns a relative URL otherwise.
""" """
bits = token.split_contents() bits = token.split_contents()
if len(bits) < 3: tagname = bits[0]
raise TemplateSyntaxError("'%s' takes at least two arguments, an event and the name of a url()." % bits[0]) if has_event:
viewname = parser.compile_filter(bits[2]) if len(bits) < 3:
event = parser.compile_filter(bits[1]) raise TemplateSyntaxError("'%s' takes at least two arguments, an event and the name of a url()." % tagname)
viewname = parser.compile_filter(bits[2])
event = parser.compile_filter(bits[1])
bits = bits[3:]
else:
if len(bits) < 2:
raise TemplateSyntaxError("'%s' takes at least one arguments, the name of a url()." % tagname)
viewname = parser.compile_filter(bits[1])
event = False
bits = bits[2:]
kwargs = {} kwargs = {}
asvar = None asvar = None
bits = bits[3:]
if len(bits) >= 2 and bits[-2] == 'as': if len(bits) >= 2 and bits[-2] == 'as':
asvar = bits[-1] asvar = bits[-1]
bits = bits[:-2] bits = bits[:-2]
@@ -88,16 +103,26 @@ def eventurl(parser, token, absolute=False):
for bit in bits: for bit in bits:
match = kwarg_re.match(bit) match = kwarg_re.match(bit)
if not match: if not match:
raise TemplateSyntaxError("Malformed arguments to eventurl tag") raise TemplateSyntaxError("Malformed arguments to %s tag" % tagname)
name, value = match.groups() name, value = match.groups()
if name: if name:
kwargs[name] = parser.compile_filter(value) kwargs[name] = parser.compile_filter(value)
else: else:
raise TemplateSyntaxError('Event urls only have keyword arguments.') raise TemplateSyntaxError('Multidomain urls only have keyword arguments.')
return EventURLNode(event, viewname, kwargs, asvar, absolute) return EventURLNode(event, viewname, kwargs, asvar, absolute)
@register.tag
def eventurl(parser, token):
"""
Similar to {% url %} in the same way that eventreverse() is similar to reverse().
Takes an event or organizer object, an url name and optional keyword arguments
"""
return multidomainurl(parser, token, has_event=True, absolute=False)
@register.tag @register.tag
def abseventurl(parser, token): def abseventurl(parser, token):
""" """
@@ -105,4 +130,12 @@ def abseventurl(parser, token):
Returns an absolute URL. Returns an absolute URL.
""" """
return eventurl(parser, token, absolute=True) return multidomainurl(parser, token, has_event=True, absolute=True)
@register.tag
def absmainurl(parser, token):
"""
Like {% url %}, but always returns an absolute URL on the main domain.
"""
return multidomainurl(parser, token, has_event=False, absolute=True)

View File

@@ -180,7 +180,7 @@ def build_absolute_uri(obj, urlname, kwargs=None):
""" """
Works similar to ``eventreverse`` but always returns an absolute URL. Works similar to ``eventreverse`` but always returns an absolute URL.
:param obj: An ``Event`` or ``Organizer`` object :param obj: An ``Event`` or ``Organizer`` object, or ``False`` to generate main domain URLs
:param name: The name of the URL route :param name: The name of the URL route
:type name: str :type name: str
:param kwargs: A dictionary of additional keyword arguments that should be used. You do not :param kwargs: A dictionary of additional keyword arguments that should be used. You do not
@@ -188,7 +188,10 @@ def build_absolute_uri(obj, urlname, kwargs=None):
needed. needed.
:returns: An absolute URL (including scheme and host) as a string :returns: An absolute URL (including scheme and host) as a string
""" """
reversedurl = eventreverse(obj, urlname, kwargs) if obj is False:
reversedurl = mainreverse(urlname, kwargs)
else:
reversedurl = eventreverse(obj, urlname, kwargs)
if '://' in reversedurl: if '://' in reversedurl:
return reversedurl return reversedurl
return urljoin(settings.SITE_URL, reversedurl) return urljoin(settings.SITE_URL, reversedurl)

View File

@@ -73,6 +73,7 @@ from pretix.base.signals import validate_cart_addons
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format from pretix.base.templatetags.phone_format import phone_format
from pretix.base.templatetags.rich_text import rich_text_snippet 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.base.views.tasks import AsyncAction
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.http import redirect_to_url 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), return self.do(self.request.event.id, data, get_or_create_cart_id(self.request),
invoice_address=self.invoice_address.pk, locale=get_language(), invoice_address=self.invoice_address.pk, locale=get_language(),
sales_channel=request.sales_channel.identifier) sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))
class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
@@ -1548,6 +1549,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
sales_channel=request.sales_channel.identifier, sales_channel=request.sales_channel.identifier,
shown_total=self.cart_session.get('shown_total'), shown_total=self.cart_session.get('shown_total'),
customer=self.cart_session.get('customer'), customer=self.cart_session.get('customer'),
override_now_dt=time_machine_now(default=None),
) )
def get_success_message(self, value): def get_success_message(self, value):

View File

@@ -37,6 +37,7 @@ from django.urls import resolve
from django_scopes import scope from django_scopes import scope
from pretix.base.channels import WebshopSalesChannel from pretix.base.channels import WebshopSalesChannel
from pretix.base.timemachine import time_machine_now_assigned_from_request
from pretix.presale.signals import process_response from pretix.presale.signals import process_response
from .utils import _detect_event from .utils import _detect_event
@@ -68,7 +69,8 @@ class EventMiddleware:
if redirect: if redirect:
return redirect return redirect
with scope(organizer=getattr(request, 'organizer', None)): with scope(organizer=getattr(request, 'organizer', None)), \
time_machine_now_assigned_from_request(request):
response = self.get_response(request) response = self.get_response(request)
if hasattr(request, '_namespace') and request._namespace == 'presale' and hasattr(request, 'event'): if hasattr(request, '_namespace') and request._namespace == 'presale' and hasattr(request, 'event'):

View File

@@ -111,9 +111,40 @@
{% if request.event.testmode %} {% if request.event.testmode %}
{% if request.sales_channel.testmode_supported %} {% if request.sales_channel.testmode_supported %}
<div class="alert alert-warning"> <div class="alert alert-warning">
<p><strong><span class="sr-only">{% trans "Warning" context "alert-messages" %}:</span> <p><strong>
{% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %} <span class="sr-only">{% trans "Warning" context "alert-messages" %}:</span>
{% trans "This ticket shop is currently in test mode." %}
</strong></p> </strong></p>
<p>
{% trans "Please do not perform any real purchases as your order might be deleted without notice." %}
</p>
{% if request.now_dt_is_fake %}
<p>
{% blocktrans trimmed with datetime=request.now_dt|date:"SHORT_DATETIME_FORMAT" %}
You are currently using the time machine. The ticket shop is rendered as if it were {{ datetime }}.
{% endblocktrans %}
<a href="{% eventurl event "presale:event.timemachine" %}"><span class="fa fa-clock-o" aria-hidden="true"></span>{% trans "Change" %}</a>
</p>
{% elif request.user.is_authenticated or request.event_access_user.is_authenticated %}
<p>
{% eventurl event "presale:event.timemachine" as time_machine_link %}
{% blocktrans trimmed with time_machine_link=time_machine_link %}
To view your shop at different points in time, you can enable
<a href="{{ time_machine_link }}"><span class="fa fa-clock-o" aria-hidden="true"></span>time machine</a>.
{% endblocktrans %}
</p>
{% elif request.event_domain or request.organizer_domain %}
<p>
{% absmainurl "control:event.transfer_session" event=event.slug organizer=event.organizer.slug as transfer_session_link %}
{% eventurl event "presale:event.timemachine" as time_machine_link %}
{% with time_machine_link_encoded=time_machine_link|urlencode %}
{% blocktrans trimmed with time_machine_link=transfer_session_link|add:"?next="|add:time_machine_link_encoded %}
To view your shop at different points in time, you can enable
<a href="{{ time_machine_link }}"><span class="fa fa-clock-o" aria-hidden="true"></span>time machine</a>.
{% endblocktrans %}
{% endwith %}
</p>
{% endif %}
</div> </div>
{% else %} {% else %}
<div class="alert alert-danger"> <div class="alert alert-danger">
@@ -122,6 +153,8 @@
</strong></p> </strong></p>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if messages %} {% if messages %}
{% for message in messages %} {% for message in messages %}
@@ -152,9 +185,13 @@
{% if request.event.testmode %} {% if request.event.testmode %}
{% if request.sales_channel.testmode_supported %} {% if request.sales_channel.testmode_supported %}
<div class="alert alert-testmode alert-warning"> <div class="alert alert-testmode alert-warning">
<p><strong><span class="sr-only">{% trans "Warning" context "alert-messages" %}:</span> <p><strong>
{% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %} <span class="sr-only">{% trans "Warning" context "alert-messages" %}:</span>
{% trans "This ticket shop is currently in test mode." %}
</strong></p> </strong></p>
<p>
{% trans "Please do not perform any real purchases as your order might be deleted without notice." %}
</p>
</div> </div>
{% else %} {% else %}
<div class="alert alert-testmode alert-danger"> <div class="alert alert-testmode alert-danger">

View File

@@ -0,0 +1,47 @@
{% extends "pretixpresale/event/base.html" %}
{% load i18n %}
{% load l10n %}
{% load money %}
{% load eventurl %}
{% load eventsignal %}
{% load thumb %}
{% load rich_text %}
{% load bootstrap3 %}
{% block title %}{% trans "Time machine" %}{% endblock %}
{% block content %}
<div class="panel {% if request.session.timemachine_now_dt %}panel-success{% else %}panel-default{% endif %}">
<div class="panel-heading">
{% trans "Time machine" %}
</div>
<div class="panel-body">
<form action="" method="post" class="">
{% csrf_token %}
{% bootstrap_form_errors timemachine_form "all" %}
<p>{% trans "Test your shop as if it were a different date and time." %}</p>
<div class="row">
<div class="col-md-6">
{% bootstrap_field timemachine_form.now_dt layout="inline" show_label=False %}
</div>
<div class="col-md-6 text-right">
<button type="submit" class="btn btn-primary btn-lg btn-save">
{% if request.session.timemachine_now_dt %}{% trans "Change" %}{% else %}{% trans "Enable time machine" %}{% endif %}
</button>
{% if request.session.timemachine_now_dt %}
<button form="disable_form" type="submit" class="btn btn-default btn-lg btn-save">
{% trans "Disable" %}
</button>
{% endif %}
</div>
</div>
</form>
<form action="" method="post" class="" id="disable_form">
{% csrf_token %}
<input type="hidden" name="timemachine_disable" value="true">
</form>
<div class="clear"></div>
</div>
</div>
{% endblock %}

View File

@@ -173,6 +173,8 @@ event_patterns = [
re_path(r'^(?P<subevent>\d+)/widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(), re_path(r'^(?P<subevent>\d+)/widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(),
name='event.widget.productlist'), name='event.widget.productlist'),
re_path(r'timemachine/$', pretix.presale.views.event.EventTimeMachine.as_view(), name='event.timemachine'),
# Account management is done on org level, but we at least need a logout # Account management is done on org level, but we at least need a logout
re_path(r'^account/logout$', pretix.presale.views.customer.LogoutView.as_view(), name='organizer.customer.logout'), re_path(r'^account/logout$', pretix.presale.views.customer.LogoutView.as_view(), name='organizer.customer.logout'),
] ]

View File

@@ -38,6 +38,9 @@ from importlib import import_module
from urllib.parse import urljoin from urllib.parse import urljoin
from django.conf import settings from django.conf import settings
from django.contrib.auth import (
BACKEND_SESSION_KEY, SESSION_KEY, get_user_model, load_backend,
)
from django.db.models import Q from django.db.models import Q
from django.http import Http404, HttpResponseForbidden from django.http import Http404, HttpResponseForbidden
from django.middleware.csrf import rotate_token from django.middleware.csrf import rotate_token
@@ -52,6 +55,7 @@ from django_scopes import scope
from pretix.base.middleware import LocaleMiddleware from pretix.base.middleware import LocaleMiddleware
from pretix.base.models import Customer, Event, Organizer from pretix.base.models import Customer, Event, Organizer
from pretix.base.timemachine import time_machine_now_assigned_from_request
from pretix.helpers.http import redirect_to_url from pretix.helpers.http import redirect_to_url
from pretix.multidomain.urlreverse import ( from pretix.multidomain.urlreverse import (
get_event_domain, get_organizer_domain, get_event_domain, get_organizer_domain,
@@ -218,6 +222,17 @@ def customer_logout(request):
request._cached_customer = None request._cached_customer = None
def _get_user_from_session_data(sessiondata):
if SESSION_KEY not in sessiondata:
return None
user_id = get_user_model()._meta.pk.to_python(sessiondata[SESSION_KEY])
backend_path = sessiondata[BACKEND_SESSION_KEY]
if backend_path in settings.AUTHENTICATION_BACKENDS:
backend = load_backend(backend_path)
user = backend.get_user(user_id)
return user
@scope(organizer=None) @scope(organizer=None)
def _detect_event(request, require_live=True, require_plugin=None): def _detect_event(request, require_live=True, require_plugin=None):
@@ -303,14 +318,13 @@ def _detect_event(request, require_live=True, require_plugin=None):
# Restrict locales to the ones available for this event # Restrict locales to the ones available for this event
LocaleMiddleware(NotImplementedError).process_request(request) LocaleMiddleware(NotImplementedError).process_request(request)
if require_live and not request.event.live: if require_live and (request.event.testmode or not request.event.live):
can_access = ( can_access = (
url.url_name == 'event.auth' url.url_name == 'event.auth'
or ( or (
request.user.is_authenticated request.user.is_authenticated
and request.user.has_event_permission(request.organizer, request.event, request=request) and request.user.has_event_permission(request.organizer, request.event, request=request)
) )
) )
if not can_access and 'pretix_event_access_{}'.format(request.event.pk) in request.session: if not can_access and 'pretix_event_access_{}'.format(request.event.pk) in request.session:
sparent = SessionStore(request.session.get('pretix_event_access_{}'.format(request.event.pk))) sparent = SessionStore(request.session.get('pretix_event_access_{}'.format(request.event.pk)))
@@ -319,9 +333,12 @@ def _detect_event(request, require_live=True, require_plugin=None):
except: except:
pass pass
else: else:
can_access = 'event_access' in parentdata user = _get_user_from_session_data(parentdata)
if user and user.is_authenticated and user.has_event_permission(request.organizer, request.event, request=request):
can_access = True
request.event_access_user = user
if not can_access: if not can_access and not request.event.live:
# Directly construct view instead of just calling `raise` since this case is so common that we # Directly construct view instead of just calling `raise` since this case is so common that we
# don't want it to show in our log files. # don't want it to show in our log files.
template = loader.get_template("pretixpresale/event/offline.html") template = loader.get_template("pretixpresale/event/offline.html")
@@ -393,7 +410,8 @@ def _event_view(function=None, require_live=True, require_plugin=None):
if ret: if ret:
return ret return ret
else: else:
with scope(organizer=getattr(request, 'organizer', None)): with scope(organizer=getattr(request, 'organizer', None)), \
time_machine_now_assigned_from_request(request):
response = func(request=request, *args, **kwargs) response = func(request=request, *args, **kwargs)
if getattr(request, 'event', None): if getattr(request, 'event', None):
for receiver, r in process_response.send(request.event, request=request, response=response): for receiver, r in process_response.send(request.event, request=request, response=response):

View File

@@ -63,6 +63,7 @@ from pretix.base.services.cart import (
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages, CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
remove_cart_position, remove_cart_position,
) )
from pretix.base.timemachine import time_machine_now
from pretix.base.views.tasks import AsyncAction from pretix.base.views.tasks import AsyncAction
from pretix.helpers.http import redirect_to_url from pretix.helpers.http import redirect_to_url
from pretix.multidomain.urlreverse import eventreverse from pretix.multidomain.urlreverse import eventreverse
@@ -429,7 +430,8 @@ class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if 'voucher' in request.POST: if 'voucher' in request.POST:
return self.do(self.request.event.id, request.POST.get('voucher'), get_or_create_cart_id(self.request), 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: else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST: if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({ return JsonResponse({
@@ -455,7 +457,8 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
if 'id' in request.POST: if 'id' in request.POST:
try: try:
return self.do(self.request.event.id, int(request.POST.get('id')), get_or_create_cart_id(self.request), return self.do(self.request.event.id, int(request.POST.get('id')), get_or_create_cart_id(self.request),
translation.get_language(), request.sales_channel.identifier) translation.get_language(), request.sales_channel.identifier,
time_machine_now(default=None))
except ValueError: except ValueError:
return redirect_to_url(self.get_error_url()) return redirect_to_url(self.get_error_url())
else: else:
@@ -478,7 +481,7 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(), return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
request.sales_channel.identifier) request.sales_channel.identifier, time_machine_now(default=None))
@method_decorator(allow_cors_if_namespaced, 'dispatch') @method_decorator(allow_cors_if_namespaced, 'dispatch')
@@ -534,7 +537,8 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
items = self._items_from_post_data() items = self._items_from_post_data()
if items: if items:
return self.do(self.request.event.id, items, cart_id, translation.get_language(), return self.do(self.request.event.id, items, cart_id, translation.get_language(),
self.invoice_address.pk, widget_data, self.request.sales_channel.identifier) self.invoice_address.pk, widget_data, self.request.sales_channel.identifier,
time_machine_now(default=None))
else: else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST: if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({ return JsonResponse({

View File

@@ -42,24 +42,29 @@ from importlib import import_module
from urllib.parse import urlencode from urllib.parse import urlencode
import isoweek import isoweek
from dateutil import parser
from django import forms
from django.conf import settings from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied from django.core.exceptions import PermissionDenied
from django.db.models import ( from django.db.models import (
Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value, Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value,
) )
from django.db.models.lookups import Exact from django.db.models.lookups import Exact
from django.http import Http404, HttpResponse from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.formats import get_format from django.utils.formats import get_format
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from django.utils.timezone import now from django.utils.http import url_has_allowed_host_and_scheme
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.views import View from django.views import View
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView from django.views.generic import TemplateView
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import ( from pretix.base.models import (
ItemVariation, Quota, SeatCategoryMapping, Voucher, ItemVariation, Quota, SeatCategoryMapping, Voucher,
) )
@@ -69,7 +74,12 @@ from pretix.base.models.items import (
) )
from pretix.base.services.placeholders import PlaceholderContext from pretix.base.services.placeholders import PlaceholderContext
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.timemachine import has_time_machine_permission
from pretix.helpers.compat import date_fromisocalendar from pretix.helpers.compat import date_fromisocalendar
from pretix.helpers.formats.en.formats import (
SHORT_MONTH_DAY_FORMAT, WEEK_FORMAT,
)
from pretix.helpers.http import redirect_to_url
from pretix.multidomain.urlreverse import eventreverse from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.ical import get_public_ical from pretix.presale.ical import get_public_ical
from pretix.presale.signals import item_description from pretix.presale.signals import item_description
@@ -78,8 +88,6 @@ from pretix.presale.views.organizer import (
filter_qs_by_attr, has_before_after, weeks_for_template, filter_qs_by_attr, has_before_after, weeks_for_template,
) )
from ...helpers.formats.en.formats import SHORT_MONTH_DAY_FORMAT, WEEK_FORMAT
from ...helpers.http import redirect_to_url
from . import ( from . import (
CartMixin, EventViewMixin, allow_frame_if_namespaced, get_cart, CartMixin, EventViewMixin, allow_frame_if_namespaced, get_cart,
iframe_entry_view_wrapper, iframe_entry_view_wrapper,
@@ -913,8 +921,58 @@ class EventAuth(View):
except: except:
raise PermissionDenied(_('Please go back and try again.')) raise PermissionDenied(_('Please go back and try again.'))
else: else:
if 'event_access' not in parentdata: if 'child_session_{}'.format(request.event.pk) not in parentdata:
raise PermissionDenied(_('Please go back and try again.')) raise PermissionDenied(_('Please go back and try again.'))
request.session['pretix_event_access_{}'.format(request.event.pk)] = parent request.session['pretix_event_access_{}'.format(request.event.pk)] = parent
return redirect_to_url(eventreverse(request.event, 'presale:event.index'))
if "next" in self.request.GET and url_has_allowed_host_and_scheme(
url=self.request.GET.get("next"), allowed_hosts=request.host, require_https=True):
return redirect_to_url(self.request.GET.get('next'))
else:
return redirect_to_url(eventreverse(request.event, 'presale:event.index'))
class TimemachineForm(forms.Form):
now_dt = forms.SplitDateTimeField(
label=_('Fake date time'),
widget=SplitDateTimePickerWidget(),
initial=lambda: now().astimezone(get_current_timezone()),
)
class EventTimeMachine(EventViewMixin, TemplateView):
template_name = 'pretixpresale/event/timemachine.html'
def setup(self, request, *args, **kwargs):
super().setup(request, *args, **kwargs)
if not has_time_machine_permission(request, request.event):
raise PermissionDenied(_('You are not allowed to access time machine mode.'))
if not request.event.testmode:
raise PermissionDenied(_('This feature is only available in test mode.'))
self.timemachine_form = TimemachineForm(
data=request.method == 'POST' and request.POST or None,
initial=(
{'now_dt': parser.parse(request.session.get(f'timemachine_now_dt:{request.event.pk}', None))}
if request.session.get(f'timemachine_now_dt:{request.event.pk}', None) else {}
)
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['timemachine_form'] = self.timemachine_form
return ctx
def post(self, request, *args, **kwargs):
if request.POST.get("timemachine_disable"):
del request.session[f'timemachine_now_dt:{request.event.pk}']
messages.success(self.request, _('Time machine disabled!'))
return redirect(self.get_success_url())
elif self.timemachine_form.is_valid():
request.session[f'timemachine_now_dt:{request.event.pk}'] = str(self.timemachine_form.cleaned_data['now_dt'])
return redirect(eventreverse(request.event, "presale:event.index"))
else:
return self.get(request)
def get_success_url(self) -> str:
return eventreverse(self.request.event, 'presale:event.timemachine')

View File

@@ -0,0 +1 @@
document.forms[0].submit();

View File

@@ -194,7 +194,7 @@ div.front-page {
padding: 5px; padding: 5px;
text-align: center; text-align: center;
a { a, .btn-link {
text-decoration: underline; text-decoration: underline;
color: white; color: white;
} }

View File

@@ -39,6 +39,7 @@ $body-bg: #f5f5f5 !default;
h1 a, .btn { h1 a, .btn {
text-decoration: none; text-decoration: none;
} }
a .fa:first-child { margin-right: 0.5ch }
/* /*
a, .btn-link { a, .btn-link {
text-decoration: underline; text-decoration: underline;

View File

@@ -41,6 +41,8 @@ def env():
TEMPLATE_FRONT_PAGE = Template("{% load eventurl %} {% eventurl event 'presale:event.index' %}") TEMPLATE_FRONT_PAGE = Template("{% load eventurl %} {% eventurl event 'presale:event.index' %}")
TEMPLATE_KWARGS = Template("{% load eventurl %} {% eventurl event 'presale:event.checkout' step='payment' %}") TEMPLATE_KWARGS = Template("{% load eventurl %} {% eventurl event 'presale:event.checkout' step='payment' %}")
TEMPLATE_ABSEVENTURL = Template("{% load eventurl %} {% abseventurl event 'presale:event.checkout' step='payment' %}")
TEMPLATE_ABSMAINURL = Template("{% load eventurl %} {% absmainurl 'control:event.settings' organizer=event.organizer.slug event=event.slug %}")
@pytest.mark.django_db @pytest.mark.django_db
@@ -77,6 +79,40 @@ def test_event_custom_domain_kwargs(env):
assert rendered == 'http://foobar/2015/checkout/payment/' assert rendered == 'http://foobar/2015/checkout/payment/'
@pytest.mark.django_db
def test_abseventurl_event_main_domain(env):
rendered = TEMPLATE_ABSEVENTURL.render(Context({
'event': env[1]
})).strip()
assert rendered == 'http://example.com/mrmcd/2015/checkout/payment/'
@pytest.mark.django_db
def test_abseventurl_event_custom_domain(env):
KnownDomain.objects.create(domainname='foobar', organizer=env[0])
rendered = TEMPLATE_ABSEVENTURL.render(Context({
'event': env[1]
})).strip()
assert rendered == 'http://foobar/2015/checkout/payment/'
@pytest.mark.django_db
def test_absmainurl_main_domain(env):
rendered = TEMPLATE_ABSMAINURL.render(Context({
'event': env[1]
})).strip()
assert rendered == 'http://example.com/control/event/mrmcd/2015/settings/'
@pytest.mark.django_db
def test_absmainurl_custom_domain(env):
KnownDomain.objects.create(domainname='foobar', organizer=env[0])
rendered = TEMPLATE_ABSMAINURL.render(Context({
'event': env[1]
})).strip()
assert rendered == 'http://example.com/control/event/mrmcd/2015/settings/'
@pytest.mark.django_db @pytest.mark.django_db
def test_only_kwargs(env): def test_only_kwargs(env):
with pytest.raises(TemplateSyntaxError): with pytest.raises(TemplateSyntaxError):

View File

@@ -58,6 +58,8 @@ from pretix.base.services.cart import CartError, CartManager, error_messages
from pretix.testutils.scope import classscope from pretix.testutils.scope import classscope
from pretix.testutils.sessions import get_cart_session_key from pretix.testutils.sessions import get_cart_session_key
from .test_timemachine import TimemachineTestMixin
class CartTestMixin: class CartTestMixin:
@scopes_disabled() @scopes_disabled()
@@ -4274,3 +4276,89 @@ class CartSeatingTest(CartTestMixin, TestCase):
self.cm.commit() self.cm.commit()
assert not CartPosition.objects.filter(cart_id=self.session_key).exists() assert not CartPosition.objects.filter(cart_id=self.session_key).exists()
class CartTimemachineTest(CartTestMixin, TimemachineTestMixin, TestCase):
def test_before_presale_timemachine(self):
self._login_with_permission(self.orga)
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._login_with_permission(self.orga)
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._login_with_permission(self.orga)
self._enable_test_mode()
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._login_with_permission(self.orga)
self._enable_test_mode()
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._login_with_permission(self.orga)
self._enable_test_mode()
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)

View File

@@ -53,9 +53,12 @@ from pretix.base.models.items import (
from pretix.base.services.cart import CartManager from pretix.base.services.cart import CartManager
from pretix.base.services.orders import OrderError, _perform_order from pretix.base.services.orders import OrderError, _perform_order
from pretix.base.services.tax import VATIDFinalError, VATIDTemporaryError from pretix.base.services.tax import VATIDFinalError, VATIDTemporaryError
from pretix.base.timemachine import time_machine_now_assigned
from pretix.testutils.scope import classscope from pretix.testutils.scope import classscope
from pretix.testutils.sessions import get_cart_session_key from pretix.testutils.sessions import get_cart_session_key
from .test_timemachine import TimemachineTestMixin
class BaseCheckoutTestCase: class BaseCheckoutTestCase:
@scopes_disabled() @scopes_disabled()
@@ -122,7 +125,7 @@ class BaseCheckoutTestCase:
}] }]
class CheckoutTestCase(BaseCheckoutTestCase, TestCase): class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
def _enable_reverse_charge(self): def _enable_reverse_charge(self):
self.tr19.eu_reverse_charge = True self.tr19.eu_reverse_charge = True
@@ -2545,6 +2548,98 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
assert op.valid_from.isoformat() == '2023-01-20T11:00:00+00:00' assert op.valid_from.isoformat() == '2023-01-20T11:00:00+00:00'
assert op.valid_until.isoformat() == '2023-01-20T13:00:00+00:00' assert op.valid_until.isoformat() == '2023-01-20T13:00:00+00:00'
@freeze_time("2023-01-18 10:00:00+00:00")
def test_validity_requested_with_time_machine(self):
self._login_with_permission(self.orga)
self._enable_test_mode()
self._set_time_machine_now(now() - timedelta(days=10))
self.ticket.available_from = now() - timedelta(days=11)
self.ticket.available_until = now() - timedelta(days=9)
self.ticket.validity_mode = Item.VALIDITY_MODE_DYNAMIC
self.ticket.validity_dynamic_duration_days = 1
self.ticket.validity_dynamic_start_choice = True
self.ticket.validity_dynamic_start_choice_day_limit = 5
self.ticket.save()
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=42, listed_price=42, price_after_voucher=42, expires=now() + timedelta(minutes=10)
)
# Date too far in the future, expected to fail
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from' % cr1.id: '2024-01-17',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-requested_valid_from' % cr1.id: '2023-01-10',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1.refresh_from_db()
assert cr1.requested_valid_from.isoformat() == '2023-01-10T00:00:00+00:00'
self._set_payment()
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
with scopes_disabled():
self.assertEqual(len(doc.select(".thank-you")), 1)
self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists())
self.assertEqual(Order.objects.count(), 1)
self.assertEqual(OrderPosition.objects.count(), 1)
op = OrderPosition.objects.get()
assert op.valid_from.isoformat() == '2023-01-10T00:00:00+00:00'
assert op.valid_until.isoformat() == '2023-01-10T23:59:59+00:00'
@freeze_time("2023-01-18 10:00:00+00:00")
def test_dynamic_validity_with_time_machine(self):
self._login_with_permission(self.orga)
self._enable_test_mode()
self._set_time_machine_now(now() + timedelta(days=10))
self.ticket.available_from = now() + timedelta(days=3)
self.ticket.validity_mode = Item.VALIDITY_MODE_DYNAMIC
self.ticket.validity_dynamic_duration_days = 1
self.ticket.validity_dynamic_start_choice = False
self.ticket.save()
with scopes_disabled():
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=42, listed_price=42, price_after_voucher=42, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1.refresh_from_db()
with time_machine_now_assigned(now() + timedelta(days=10)):
assert cr1.predicted_validity[0].isoformat() == '2023-01-28T10:00:00+00:00'
assert cr1.predicted_validity[1].isoformat() == '2023-01-28T23:59:59+00:00'
self._set_payment()
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
with scopes_disabled():
self.assertEqual(len(doc.select(".thank-you")), 1)
self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists())
self.assertEqual(Order.objects.count(), 1)
self.assertEqual(OrderPosition.objects.count(), 1)
op = OrderPosition.objects.get()
assert op.valid_from.isoformat() == '2023-01-28T10:00:00+00:00'
assert op.valid_until.isoformat() == '2023-01-28T23:59:59+00:00'
def test_voucher(self): def test_voucher(self):
with scopes_disabled(): with scopes_disabled():
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event, price_mode='set', v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event, price_mode='set',
@@ -3486,6 +3581,28 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
doc = BeautifulSoup(response.content.decode(), "lxml") doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1) self.assertEqual(len(doc.select(".thank-you")), 1)
def test_before_presale_timemachine(self):
self._login_with_permission(self.orga)
self._enable_test_mode()
self._set_time_machine_now(now() + timedelta(days=4))
self.event.presale_start = now() + timedelta(days=3)
self.event.save()
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
self._set_payment()
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
assert "test mode" in response.content.decode()
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
with scopes_disabled():
assert Order.objects.last().testmode
assert Order.objects.last().code[1] == "0"
def test_create_testmode_order_in_testmode(self): def test_create_testmode_order_in_testmode(self):
self.event.testmode = True self.event.testmode = True
self.event.save() self.event.save()

View File

@@ -0,0 +1,43 @@
#
# 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/>.
#
from django_scopes.state import scopes_disabled
from pretix.base.models import Team, User
class TimemachineTestMixin:
@scopes_disabled()
def _login_with_permission(self, orga):
self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
self.team1 = Team.objects.create(organizer=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[f'timemachine_now_dt:{self.event.pk}'] = str(dt)
session.save()
def _enable_test_mode(self):
self.event.testmode = True
self.event.save()