diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt
index f1155e84d..f2c9c5db4 100644
--- a/doc/spelling_wordlist.txt
+++ b/doc/spelling_wordlist.txt
@@ -1,5 +1,6 @@
addon
addons
+Analytics
anonymize
api
auditability
diff --git a/doc/user/events/widget.rst b/doc/user/events/widget.rst
index fe0205555..e3e415f96 100644
--- a/doc/user/events/widget.rst
+++ b/doc/user/events/widget.rst
@@ -149,8 +149,94 @@ Just as the widget, the button supports the optional attributes ``voucher`` and
You can style the button using the ``pretix-button`` CSS class.
-.. versionchanged:: 1.13
+Dynamically loading the widget
+------------------------------
- The pretix Button has been added in version 1.13.
+If you need to control the way or timing the widget loads, for example because you want to modify user data (see
+below) dynamically via JavaScript, you can register a listener that we will call before creating the widget::
+
+
+
+If you want, you can suppress us loading the widget and/or modify the user data passed to the widget::
+
+
+
+If you then later want to trigger loading the widgets, just call ``window.PretixWidget.buildWidgets()``.
+
+
+Passing user data to the widget
+-------------------------------
+
+If you display the widget in a restricted area of your website and you want to pre-fill fields in the checkout process
+with known user data to save your users some typing and increase conversions, you can pass additional data attributes
+with that information::
+
+
+
+
+This works for the pretix Button as well. Currently, the following attributes are understood by pretix itself:
+
+* ``data-email`` will pre-fill the order email field as well as the attendee email field (if enabled).
+
+* ``data-question-IDENTIFIER`` will pre-fill the answer for the question with the given identifier. You can view and set
+ identifiers in the *Questions* section of the backend.
+
+* Depending on the person name scheme configured in your event settings, you can pass one or more of
+ ``data-attendee-name-full-name``, ``data-attendee-name-given-name``, ``data-attendee-name-family-name``,
+ ``data-attendee-name-middle-name``, ``data-attendee-name-title``, ``data-attendee-name-calling-name``,
+ ``data-attendee-name-latin-transcription``. If you don't know or don't care, you can also just pass a string as
+ ``data-attendee-name``, which will pre-fill the last part of the name, whatever that is.
+
+Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
+Hosted or pretix Enterprise are active, you can pass the following fields:
+
+* If you use the campaigns plugin, you can pass a campaign ID as a value to ``data-campaign``. This way, all orders
+ made through this widget will be counted towards this campaign.
+
+* If you use the tracking plugin, you can pass a Google Analytics User ID to enable cross-domain tracking. This will
+ require you to dynamically load the widget, like this::
+
+
+
+
+.. versionchanged:: 2.3
+
+ Data passing options have been added in pretix 2.3. If you use a self-hosted version of pretix, they only work
+ fully if you configured a redis server.
.. _Let's Encrypt: https://letsencrypt.org/
diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py
index 837c63a90..45fe0b3a7 100644
--- a/src/pretix/base/models/items.py
+++ b/src/pretix/base/models/items.py
@@ -750,7 +750,7 @@ class Question(LoggedModel):
@staticmethod
def _clean_identifier(event, code, instance=None):
- qs = Question.objects.filter(event=event, identifier=code)
+ qs = Question.objects.filter(event=event, identifier__iexact=code)
if instance:
qs = qs.exclude(pk=instance.pk)
if qs.exists():
diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index c59f9c593..2cfee0182 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -4,6 +4,7 @@ from decimal import Decimal
from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
+from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Q
from django.dispatch import receiver
@@ -17,9 +18,11 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
+from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
+from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.templatetags.rich_text import rich_text
from pretix.celery_app import app
from pretix.presale.signals import (
@@ -99,7 +102,7 @@ class CartManager:
AddOperation: 30
}
- def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None):
+ def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None):
self.event = event
self.cart_id = cart_id
self.now_dt = now()
@@ -111,6 +114,7 @@ class CartManager:
self._variations_cache = {}
self._expiry = None
self.invoice_address = invoice_address
+ self._widget_data = widget_data or {}
@property
def positions(self):
@@ -605,12 +609,37 @@ class CartManager:
if isinstance(op, self.AddOperation):
for k in range(available_count):
- new_cart_positions.append(CartPosition(
+ cp = CartPosition(
event=self.event, item=op.item, variation=op.variation,
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax
- ))
+ )
+ if self.event.settings.attendee_names_asked:
+ scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
+ if 'attendee-name' in self._widget_data:
+ cp.attendee_name_parts = {'_legacy': self._widget_data['attendee-name']}
+ if any('attendee-name-{}'.format(k.replace('_', '-')) in self._widget_data for k, l, w
+ in scheme['fields']):
+ cp.attendee_name_parts = {
+ k: self._widget_data.get('attendee-name-{}'.format(k.replace('_', '-')), '')
+ for k, l, w in scheme['fields']
+ }
+ if self.event.settings.attendee_emails_asked and 'email' in self._widget_data:
+ cp.attendee_email = self._widget_data.get('email')
+
+ cp._answers = {}
+ for k, v in self._widget_data.items():
+ if not k.startswith('question-'):
+ continue
+ q = cp.item.questions.filter(ask_during_checkin=False, identifier__iexact=k[9:]).first()
+ if q:
+ try:
+ cp._answers[q] = q.clean_answer(v)
+ except ValidationError:
+ pass
+
+ new_cart_positions.append(cp)
elif isinstance(op, self.ExtendOperation):
if available_count == 1:
op.position.expires = self._expiry
@@ -621,7 +650,11 @@ class CartManager:
else:
raise AssertionError("ExtendOperation cannot affect more than one item")
- CartPosition.objects.bulk_create(new_cart_positions)
+ for p in new_cart_positions:
+ if p._answers:
+ p.save()
+ _save_answers(p, {}, p._answers)
+ CartPosition.objects.bulk_create([p for p in new_cart_positions if not p._answers])
return err
def commit(self):
@@ -702,7 +735,7 @@ def get_fees(event, request, total, invoice_address, provider):
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
- invoice_address: int=None) -> None:
+ invoice_address: int=None, widget_data=None) -> None:
"""
Adds a list of items to a user's cart.
:param event: The event ID in question
@@ -722,7 +755,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
try:
try:
- cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia)
+ cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data)
cm.add_new_items(items)
cm.commit()
except LockTimeoutException:
diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py
index c8591b705..72ef39fa8 100644
--- a/src/pretix/presale/checkoutflow.py
+++ b/src/pretix/presale/checkoutflow.py
@@ -309,7 +309,10 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
@cached_property
def contact_form(self):
initial = {
- 'email': self.cart_session.get('email', '')
+ 'email': (
+ self.cart_session.get('email', '') or
+ self.cart_session.get('widget_data', {}).get('email', '')
+ )
}
initial.update(self.cart_session.get('contact_form_data', {}))
return ContactForm(data=self.request.POST if self.request.method == "POST" else None,
diff --git a/src/pretix/presale/templates/pretixpresale/event/voucher.html b/src/pretix/presale/templates/pretixpresale/event/voucher.html
index dac545883..14f442402 100644
--- a/src/pretix/presale/templates/pretixpresale/event/voucher.html
+++ b/src/pretix/presale/templates/pretixpresale/event/voucher.html
@@ -237,6 +237,9 @@
{% endif %}
+ {% if "widget_data" in request.GET %}
+
+ {% endif %}
{% endif %}
{% endblock %}
diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py
index a849b04f7..b975d7426 100644
--- a/src/pretix/presale/views/cart.py
+++ b/src/pretix/presale/views/cart.py
@@ -1,8 +1,10 @@
+import json
import mimetypes
import os
from django.conf import settings
from django.contrib import messages
+from django.core.cache import caches
from django.db.models import Q
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect
@@ -33,6 +35,11 @@ from pretix.presale.views.event import (
)
from pretix.presale.views.robots import NoSearchIndexViewMixin
+try:
+ widget_data_cache = caches['redis']
+except:
+ widget_data_cache = caches['default']
+
class CartActionMixin:
@@ -266,11 +273,20 @@ def get_or_create_cart_id(request, create=True):
cart_data = {}
if prefix and 'take_cart_id' in request.GET and current_id:
new_id = current_id
+ cached_widget_data = widget_data_cache.get('widget_data_{}'.format(current_id))
+ if cached_widget_data:
+ cart_data['widget_data'] = cached_widget_data
else:
if not create:
return None
new_id = generate_cart_id(request, prefix=prefix)
+ if 'widget_data' not in cart_data and 'widget_data' in request.GET:
+ try:
+ cart_data['widget_data'] = json.loads(request.GET.get('widget_data'))
+ except ValueError:
+ pass
+
if 'carts' not in request.session:
request.session['carts'] = {}
if new_id not in request.session['carts']:
@@ -349,10 +365,24 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
}
def post(self, request, *args, **kwargs):
+ cart_id = get_or_create_cart_id(self.request)
+ if "widget_data" in request.POST:
+ try:
+ widget_data = json.loads(request.POST.get("widget_data", "{}"))
+ except ValueError:
+ widget_data = {}
+ else:
+ widget_data_cache.set('widget_data_{}'.format(cart_id), widget_data, 600)
+ cs = cart_session(request)
+ cs['widget_data'] = widget_data
+ else:
+ cs = cart_session(request)
+ widget_data = cs.get('widget_data', {})
+
items = self._items_from_post_data()
if items:
- return self.do(self.request.event.id, items, get_or_create_cart_id(self.request), translation.get_language(),
- self.invoice_address.pk)
+ return self.do(self.request.event.id, items, cart_id, translation.get_language(),
+ self.invoice_address.pk, widget_data)
else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({
@@ -449,6 +479,10 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, TemplateView):
def get(self, request, *args, **kwargs):
if 'iframe' in request.GET and 'require_cookie' not in request.GET:
return redirect(request.get_full_path() + '&require_cookie=1')
+
+ if len(self.request.GET.get('widget_data', '{}')) > 3:
+ # We've been passed data from a widget, we need to create a cart session to store it.
+ get_or_create_cart_id(request)
return super().get(request, *args, **kwargs)
diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py
index 6ede42614..07ac3abc9 100644
--- a/src/pretix/presale/views/event.py
+++ b/src/pretix/presale/views/event.py
@@ -189,6 +189,9 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView):
return redirect(eventreverse(request.event, 'presale:event.index', kwargs=kwargs) + '?require_cookie=true&cart_id={}'.format(
request.GET.get('take_cart_id')
))
+ elif request.GET.get('iframe', '') == '1' and len(self.request.GET.get('widget_data', '{}')) > 3:
+ # We've been passed data from a widget, we need to create a cart session to store it.
+ get_or_create_cart_id(request)
elif 'require_cookie' in request.GET and settings.SESSION_COOKIE_NAME not in request.COOKIES:
# Cookies are in fact not supported
r = render(request, 'pretixpresale/event/cookies.html', {
diff --git a/src/pretix/static/pretixpresale/js/widget/widget.js b/src/pretix/static/pretixpresale/js/widget/widget.js
index f56d9cba6..c36cb136c 100644
--- a/src/pretix/static/pretixpresale/js/widget/widget.js
+++ b/src/pretix/static/pretixpresale/js/widget/widget.js
@@ -3,6 +3,11 @@
/* This is embedded in an isolation wrapper that exposes siteglobals as the global
scope. */
+window.PretixWidget = {
+ 'build_widgets': true,
+ 'widget_data': {}
+};
+
var Vue = module.exports;
var strings = {
@@ -457,6 +462,9 @@ var shared_methods = {
return;
}
var redirect_url = this.$root.voucherFormTarget + '&voucher=' + this.voucher + '&subevent=' + this.$root.subevent;
+ if (this.$root.widget_data) {
+ redirect_url += '&widget_data=' + escape(this.$root.widget_data_json);
+ }
var iframe = this.$root.overlay.$children[0].$refs['frame-container'].children[0];
this.$root.overlay.frame_loading = true;
iframe.src = redirect_url;
@@ -466,6 +474,9 @@ var shared_methods = {
if (this.$root.cart_id) {
redirect_url += '&take_cart_id=' + this.$root.cart_id;
}
+ if (this.$root.widget_data) {
+ redirect_url += '&widget_data=' + escape(this.$root.widget_data_json);
+ }
if (this.$root.useIframe) {
var iframe = this.$root.overlay.$children[0].$refs['frame-container'].children[0];
this.$root.overlay.frame_loading = true;
@@ -572,6 +583,7 @@ Vue.component('pretix-widget', {
+ '