mirror of
https://github.com/pretix/pretix.git
synced 2026-05-09 15:54:03 +00:00
Time machine mode [Z#23129725] (#3961)
Allows organizers to test their shop as if it were a different date and time. Implemented using a time_machine_now() function which is used instead of regular now(), which can overlay the real date time with a value from a ContextVar, assigned from a session value in EventMiddleware. For more information, see doc/development/implementation/timemachine.rst --------- Co-authored-by: Richard Schreiber <schreiber@rami.io> Co-authored-by: Raphael Michel <michel@rami.io>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -111,9 +111,40 @@
|
||||
{% if request.event.testmode %}
|
||||
{% if request.sales_channel.testmode_supported %}
|
||||
<div class="alert alert-warning">
|
||||
<p><strong><span class="sr-only">{% trans "Warning" context "alert-messages" %}:</span>
|
||||
{% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %}
|
||||
<p><strong>
|
||||
<span class="sr-only">{% trans "Warning" context "alert-messages" %}:</span>
|
||||
{% trans "This ticket shop is currently in test mode." %}
|
||||
</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>
|
||||
{% else %}
|
||||
<div class="alert alert-danger">
|
||||
@@ -122,6 +153,8 @@
|
||||
</strong></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
{% endif %}
|
||||
{% if messages %}
|
||||
{% for message in messages %}
|
||||
@@ -152,9 +185,13 @@
|
||||
{% if request.event.testmode %}
|
||||
{% if request.sales_channel.testmode_supported %}
|
||||
<div class="alert alert-testmode alert-warning">
|
||||
<p><strong><span class="sr-only">{% trans "Warning" context "alert-messages" %}:</span>
|
||||
{% trans "This ticket shop is currently in test mode. Please do not perform any real purchases as your order might be deleted without notice." %}
|
||||
<p><strong>
|
||||
<span class="sr-only">{% trans "Warning" context "alert-messages" %}:</span>
|
||||
{% trans "This ticket shop is currently in test mode." %}
|
||||
</strong></p>
|
||||
<p>
|
||||
{% trans "Please do not perform any real purchases as your order might be deleted without notice." %}
|
||||
</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-testmode alert-danger">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -176,6 +176,8 @@ event_patterns = [
|
||||
re_path(r'^(?P<subevent>\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'),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user