diff --git a/src/pretix/control/templates/pretixcontrol/event/transfer_session.html b/src/pretix/control/templates/pretixcontrol/event/transfer_session.html
new file mode 100644
index 0000000000..cc4d1e8cc6
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/event/transfer_session.html
@@ -0,0 +1,9 @@
+{% load eventurl %}
+{% load static %}
+
+
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py
index 20fe27845f..eb6b4a0a38 100644
--- a/src/pretix/control/urls.py
+++ b/src/pretix/control/urls.py
@@ -247,6 +247,7 @@ urlpatterns = [
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'^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'^delete/$', event.EventDelete.as_view(), name='event.delete'),
re_path(r'^comment/$', event.EventComment.as_view(),
diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py
index 47a1feab71..cb4838dd67 100644
--- a/src/pretix/control/views/event.py
+++ b/src/pretix/control/views/event.py
@@ -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):
permission = 'can_change_event_settings'
template_name = 'pretixcontrol/event/delete.html'
diff --git a/src/pretix/multidomain/templatetags/eventurl.py b/src/pretix/multidomain/templatetags/eventurl.py
index 95ab785116..5803288832 100644
--- a/src/pretix/multidomain/templatetags/eventurl.py
+++ b/src/pretix/multidomain/templatetags/eventurl.py
@@ -27,7 +27,7 @@ from django.urls import NoReverseMatch
from django.utils.encoding import smart_str
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()
@@ -45,11 +45,13 @@ class EventURLNode(URLNode):
for k, v in self.kwargs.items()
}
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 = ''
try:
if self.absolute:
url = build_absolute_uri(event, view_name, kwargs=kwargs)
+ elif self.event is False:
+ url = mainreverse(view_name, kwargs)
else:
url = eventreverse(event, view_name, kwargs=kwargs)
except NoReverseMatch:
@@ -65,21 +67,34 @@ class EventURLNode(URLNode):
return url
-@register.tag
-def eventurl(parser, token, absolute=False):
+def multidomainurl(parser, token, has_event, absolute):
"""
- 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()
- if len(bits) < 3:
- raise TemplateSyntaxError("'%s' takes at least two arguments, an event and the name of a url()." % bits[0])
- viewname = parser.compile_filter(bits[2])
- event = parser.compile_filter(bits[1])
+ tagname = bits[0]
+ if has_event:
+ if len(bits) < 3:
+ 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 = {}
asvar = None
- bits = bits[3:]
if len(bits) >= 2 and bits[-2] == 'as':
asvar = bits[-1]
bits = bits[:-2]
@@ -88,16 +103,26 @@ def eventurl(parser, token, absolute=False):
for bit in bits:
match = kwarg_re.match(bit)
if not match:
- raise TemplateSyntaxError("Malformed arguments to eventurl tag")
+ raise TemplateSyntaxError("Malformed arguments to %s tag" % tagname)
name, value = match.groups()
if name:
kwargs[name] = parser.compile_filter(value)
else:
- raise TemplateSyntaxError('Event urls only have keyword arguments.')
+ raise TemplateSyntaxError('Multidomain urls only have keyword arguments.')
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
def abseventurl(parser, token):
"""
@@ -105,4 +130,12 @@ def abseventurl(parser, token):
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)
diff --git a/src/pretix/multidomain/urlreverse.py b/src/pretix/multidomain/urlreverse.py
index f764fc1f38..fe7716edd4 100644
--- a/src/pretix/multidomain/urlreverse.py
+++ b/src/pretix/multidomain/urlreverse.py
@@ -180,7 +180,7 @@ def build_absolute_uri(obj, urlname, kwargs=None):
"""
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
:type name: str
: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.
: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:
return reversedurl
return urljoin(settings.SITE_URL, reversedurl)
diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py
index 622e2b4bca..4e85143349 100644
--- a/src/pretix/presale/checkoutflow.py
+++ b/src/pretix/presale/checkoutflow.py
@@ -73,6 +73,7 @@ from pretix.base.signals import validate_cart_addons
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.phone_format import phone_format
from pretix.base.templatetags.rich_text import rich_text_snippet
+from pretix.base.timemachine import time_machine_now
from pretix.base.views.tasks import AsyncAction
from pretix.celery_app import app
from pretix.helpers.http import redirect_to_url
@@ -706,7 +707,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep):
return self.do(self.request.event.id, data, get_or_create_cart_id(self.request),
invoice_address=self.invoice_address.pk, locale=get_language(),
- sales_channel=request.sales_channel.identifier)
+ sales_channel=request.sales_channel.identifier, override_now_dt=time_machine_now(default=None))
class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
@@ -1548,6 +1549,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep):
sales_channel=request.sales_channel.identifier,
shown_total=self.cart_session.get('shown_total'),
customer=self.cart_session.get('customer'),
+ override_now_dt=time_machine_now(default=None),
)
def get_success_message(self, value):
diff --git a/src/pretix/presale/middleware.py b/src/pretix/presale/middleware.py
index 4cbf52a839..81d4e137f1 100644
--- a/src/pretix/presale/middleware.py
+++ b/src/pretix/presale/middleware.py
@@ -37,6 +37,7 @@ from django.urls import resolve
from django_scopes import scope
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 .utils import _detect_event
@@ -68,7 +69,8 @@ class EventMiddleware:
if 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)
if hasattr(request, '_namespace') and request._namespace == 'presale' and hasattr(request, 'event'):
diff --git a/src/pretix/presale/templates/pretixpresale/event/base.html b/src/pretix/presale/templates/pretixpresale/event/base.html
index a3c2e48a81..63e8f5da34 100644
--- a/src/pretix/presale/templates/pretixpresale/event/base.html
+++ b/src/pretix/presale/templates/pretixpresale/event/base.html
@@ -111,9 +111,40 @@
{% if request.event.testmode %}
{% if request.sales_channel.testmode_supported %}
-
{% trans "Warning" context "alert-messages" %}:
- {% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %}
+
+ {% trans "Warning" context "alert-messages" %}:
+ {% trans "This ticket shop is currently in test mode." %}
+
+ {% trans "Please do not perform any real purchases as your order might be deleted without notice." %}
+
+ {% if request.now_dt_is_fake %}
+
+ {% 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 %}
+ {% trans "Change" %}
+
+ {% elif request.user.is_authenticated or request.event_access_user.is_authenticated %}
+
+ {% 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
+ time machine.
+ {% endblocktrans %}
+
+ {% elif request.event_domain or request.organizer_domain %}
+
+ {% 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
+ time machine.
+ {% endblocktrans %}
+ {% endwith %}
+
+ {% endif %}
{% else %}
@@ -122,6 +153,8 @@
{% endif %}
+
+
{% endif %}
{% if messages %}
{% for message in messages %}
@@ -152,9 +185,13 @@
{% if request.event.testmode %}
{% if request.sales_channel.testmode_supported %}
-
{% trans "Warning" context "alert-messages" %}:
- {% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %}
+
+ {% trans "Warning" context "alert-messages" %}:
+ {% trans "This ticket shop is currently in test mode." %}
+
+ {% trans "Please do not perform any real purchases as your order might be deleted without notice." %}
+
{% else %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/timemachine.html b/src/pretix/presale/templates/pretixpresale/event/timemachine.html
new file mode 100644
index 0000000000..80854bc1c0
--- /dev/null
+++ b/src/pretix/presale/templates/pretixpresale/event/timemachine.html
@@ -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 %}
+
+
+ {% trans "Time machine" %}
+
+
+
+{% endblock %}
\ No newline at end of file
diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py
index 83006cd195..fcc18b6482 100644
--- a/src/pretix/presale/urls.py
+++ b/src/pretix/presale/urls.py
@@ -176,6 +176,8 @@ event_patterns = [
re_path(r'^(?P
\d+)/widget/product_list$', pretix.presale.views.widget.WidgetAPIProductList.as_view(),
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
re_path(r'^account/logout$', pretix.presale.views.customer.LogoutView.as_view(), name='organizer.customer.logout'),
]
diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py
index 54a9b7c6c3..65aabbdc9a 100644
--- a/src/pretix/presale/utils.py
+++ b/src/pretix/presale/utils.py
@@ -38,6 +38,9 @@ from importlib import import_module
from urllib.parse import urljoin
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.http import Http404, HttpResponseForbidden
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.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.multidomain.urlreverse import (
get_event_domain, get_organizer_domain,
@@ -218,6 +222,17 @@ def customer_logout(request):
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)
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
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 = (
url.url_name == 'event.auth'
or (
request.user.is_authenticated
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:
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:
pass
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
# don't want it to show in our log files.
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:
return ret
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)
if getattr(request, 'event', None):
for receiver, r in process_response.send(request.event, request=request, response=response):
diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py
index c9b2968437..92c1880562 100644
--- a/src/pretix/presale/views/cart.py
+++ b/src/pretix/presale/views/cart.py
@@ -63,6 +63,7 @@ from pretix.base.services.cart import (
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
remove_cart_position,
)
+from pretix.base.timemachine import time_machine_now
from pretix.base.views.tasks import AsyncAction
from pretix.helpers.http import redirect_to_url
from pretix.multidomain.urlreverse import eventreverse
@@ -429,7 +430,8 @@ class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
def post(self, request, *args, **kwargs):
if 'voucher' in request.POST:
return self.do(self.request.event.id, request.POST.get('voucher'), get_or_create_cart_id(self.request),
- translation.get_language(), request.sales_channel.identifier)
+ translation.get_language(), request.sales_channel.identifier,
+ time_machine_now(default=None))
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
@@ -455,7 +457,8 @@ class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
if 'id' in request.POST:
try:
return self.do(self.request.event.id, int(request.POST.get('id')), get_or_create_cart_id(self.request),
- translation.get_language(), request.sales_channel.identifier)
+ translation.get_language(), request.sales_channel.identifier,
+ time_machine_now(default=None))
except ValueError:
return redirect_to_url(self.get_error_url())
else:
@@ -478,7 +481,7 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
def post(self, request, *args, **kwargs):
return self.do(self.request.event.id, get_or_create_cart_id(self.request), translation.get_language(),
- request.sales_channel.identifier)
+ request.sales_channel.identifier, time_machine_now(default=None))
@method_decorator(allow_cors_if_namespaced, 'dispatch')
@@ -534,7 +537,8 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
items = self._items_from_post_data()
if items:
return self.do(self.request.event.id, items, cart_id, translation.get_language(),
- self.invoice_address.pk, widget_data, self.request.sales_channel.identifier)
+ self.invoice_address.pk, widget_data, self.request.sales_channel.identifier,
+ time_machine_now(default=None))
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py
index 4dc33ba436..c709c7574c 100644
--- a/src/pretix/presale/views/event.py
+++ b/src/pretix/presale/views/event.py
@@ -42,24 +42,29 @@ from importlib import import_module
from urllib.parse import urlencode
import isoweek
+from dateutil import parser
+from django import forms
from django.conf import settings
+from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db.models import (
Count, Exists, IntegerField, OuterRef, Prefetch, Q, Value,
)
from django.db.models.lookups import Exact
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.formats import get_format
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.views import View
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView
from pretix.base.channels import get_all_sales_channels
+from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import (
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.quotas import QuotaAvailability
+from pretix.base.timemachine import has_time_machine_permission
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.presale.ical import get_public_ical
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,
)
-from ...helpers.formats.en.formats import SHORT_MONTH_DAY_FORMAT, WEEK_FORMAT
-from ...helpers.http import redirect_to_url
from . import (
CartMixin, EventViewMixin, allow_frame_if_namespaced, get_cart,
iframe_entry_view_wrapper,
@@ -913,8 +921,58 @@ class EventAuth(View):
except:
raise PermissionDenied(_('Please go back and try again.'))
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.'))
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')
diff --git a/src/pretix/static/pretixcontrol/js/send_form.js b/src/pretix/static/pretixcontrol/js/send_form.js
new file mode 100644
index 0000000000..01b3fda000
--- /dev/null
+++ b/src/pretix/static/pretixcontrol/js/send_form.js
@@ -0,0 +1 @@
+document.forms[0].submit();
diff --git a/src/pretix/static/pretixpresale/scss/_event.scss b/src/pretix/static/pretixpresale/scss/_event.scss
index 78cc79e071..e04fc19660 100644
--- a/src/pretix/static/pretixpresale/scss/_event.scss
+++ b/src/pretix/static/pretixpresale/scss/_event.scss
@@ -194,7 +194,7 @@ div.front-page {
padding: 5px;
text-align: center;
- a {
+ a, .btn-link {
text-decoration: underline;
color: white;
}
diff --git a/src/pretix/static/pretixpresale/scss/main.scss b/src/pretix/static/pretixpresale/scss/main.scss
index 5df5ceef74..55609f305c 100644
--- a/src/pretix/static/pretixpresale/scss/main.scss
+++ b/src/pretix/static/pretixpresale/scss/main.scss
@@ -39,6 +39,7 @@ $body-bg: #f5f5f5 !default;
h1 a, .btn {
text-decoration: none;
}
+a .fa:first-child { margin-right: 0.5ch }
/*
a, .btn-link {
text-decoration: underline;
diff --git a/src/tests/multidomain/test_templatetag.py b/src/tests/multidomain/test_templatetag.py
index ef7d32c42c..2f5ff058b4 100644
--- a/src/tests/multidomain/test_templatetag.py
+++ b/src/tests/multidomain/test_templatetag.py
@@ -41,6 +41,8 @@ def env():
TEMPLATE_FRONT_PAGE = Template("{% load eventurl %} {% eventurl event 'presale:event.index' %}")
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
@@ -77,6 +79,40 @@ def test_event_custom_domain_kwargs(env):
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
def test_only_kwargs(env):
with pytest.raises(TemplateSyntaxError):
diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py
index 57f54b1d7e..94839a8100 100644
--- a/src/tests/presale/test_cart.py
+++ b/src/tests/presale/test_cart.py
@@ -58,6 +58,8 @@ from pretix.base.services.cart import CartError, CartManager, error_messages
from pretix.testutils.scope import classscope
from pretix.testutils.sessions import get_cart_session_key
+from .test_timemachine import TimemachineTestMixin
+
class CartTestMixin:
@scopes_disabled()
@@ -4274,3 +4276,89 @@ class CartSeatingTest(CartTestMixin, TestCase):
self.cm.commit()
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)
diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py
index e05ef40790..09cd935e9e 100644
--- a/src/tests/presale/test_checkout.py
+++ b/src/tests/presale/test_checkout.py
@@ -53,9 +53,12 @@ from pretix.base.models.items import (
from pretix.base.services.cart import CartManager
from pretix.base.services.orders import OrderError, _perform_order
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.sessions import get_cart_session_key
+from .test_timemachine import TimemachineTestMixin
+
class BaseCheckoutTestCase:
@scopes_disabled()
@@ -122,7 +125,7 @@ class BaseCheckoutTestCase:
}]
-class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
+class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
def _enable_reverse_charge(self):
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_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):
with scopes_disabled():
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")
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):
self.event.testmode = True
self.event.save()
diff --git a/src/tests/presale/test_timemachine.py b/src/tests/presale/test_timemachine.py
new file mode 100644
index 0000000000..0eed1de6de
--- /dev/null
+++ b/src/tests/presale/test_timemachine.py
@@ -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 .
+#
+# 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
+# .
+#
+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()