diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index f1155e84d0..f2c9c5db4f 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 fe02055557..e3e415f969 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 837c63a900..45fe0b3a70 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 c59f9c593b..2cfee01827 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 c8591b7052..72ef39fa8c 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 dac5458835..14f442402c 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 a849b04f76..b975d7426e 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 6ede426144..07ac3abc94 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 f56d9cba62..c36cb136cf 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', { + '
' + '' + '' + + '' + '
{{ $root.error }}
' + '
' @@ -618,6 +630,7 @@ Vue.component('pretix-button', { + '' + '' + '' + + '' + '' + '' + '' @@ -733,6 +746,9 @@ var shared_root_computed = { } } return has_priced || cnt_items > 1; + }, + widget_data_json: function () { + return JSON.stringify(this.widget_data); } }; @@ -757,6 +773,23 @@ var create_overlay = function (app) { app.$root.overlay = framechild; }; +function get_ga_client_id(tracking_id) { + if (typeof ga === "undefined") { + return null; + } + try { + var trackers = ga.getAll(); + var i, len; + for (i = 0, len = trackers.length; i < len; i += 1) { + if (trackers[i].get('trackingId') === tracking_id) { + return trackers[i].get('clientId'); + } + } + } catch (e) { + } + return null; +} + var create_widget = function (element) { var event_url = element.attributes.event.value; if (!event_url.match(/\/$/)) { @@ -766,6 +799,13 @@ var create_widget = function (element) { var subevent = element.attributes.subevent ? element.attributes.subevent.value : null; var skip_ssl = element.attributes["skip-ssl-check"] ? true : false; var disable_vouchers = element.attributes["disable-vouchers"] ? true : false; + var widget_data = JSON.parse(JSON.stringify(window.PretixWidget.widget_data)); + for (var i = 0; i < element.attributes.length; i++) { + var attrib = element.attributes[i]; + if (attrib.name.match(/^data-.*$/)) { + widget_data[attrib.name.replace(/^data-/, '')] = attrib.value; + } + } if (element.tagName !== "pretix-widget") { element.innerHTML = ""; @@ -786,6 +826,7 @@ var create_widget = function (element) { skip_ssl: skip_ssl, error: null, display_add_to_cart: false, + widget_data: widget_data, loading: 1, widget_id: 'pretix-widget-' + widget_id, vouchers_exist: false, @@ -815,6 +856,13 @@ var create_button = function (element) { var raw_items = element.attributes.items ? element.attributes.items.value : ""; var skip_ssl = element.attributes["skip-ssl-check"] ? true : false; var button_text = element.innerHTML; + var widget_data = JSON.parse(JSON.stringify(window.PretixWidget.widget_data)); + for (var i = 0; i < element.attributes.length; i++) { + var attrib = element.attributes[i]; + if (attrib.name.match(/^data-.*$/)) { + widget_data[attrib.name.replace(/^data-/, '')] = attrib.value; + } + } if (element.tagName !== "pretix-button") { element.innerHTML = "" + element.innerHTML + ""; @@ -840,6 +888,7 @@ var create_button = function (element) { voucher_code: voucher, items: items, error: null, + widget_data: widget_data, widget_id: 'pretix-widget-' + widget_id, button_text: button_text } @@ -856,27 +905,35 @@ var create_button = function (element) { /* Find all widgets on the page and render them */ widgetlist = []; buttonlist = []; -document.createElement("pretix-widget"); -document.createElement("pretix-button"); -docReady(function () { - var widgets = document.querySelectorAll("pretix-widget, div.pretix-widget-compat"); - var wlength = widgets.length; - for (var i = 0; i < wlength; i++) { - var widget = widgets[i]; - widgetlist.push(create_widget(widget)); - } +window.PretixWidget.buildWidgets = function () { + document.createElement("pretix-widget"); + document.createElement("pretix-button"); + docReady(function () { + var widgets = document.querySelectorAll("pretix-widget, div.pretix-widget-compat"); + var wlength = widgets.length; + for (var i = 0; i < wlength; i++) { + var widget = widgets[i]; + widgetlist.push(create_widget(widget)); + } - var buttons = document.querySelectorAll("pretix-button, div.pretix-button-compat"); - var blength = buttons.length; - for (var i = 0; i < blength; i++) { - var button = buttons[i]; - buttonlist.push(create_button(button)); - } -}); + var buttons = document.querySelectorAll("pretix-button, div.pretix-button-compat"); + var blength = buttons.length; + for (var i = 0; i < blength; i++) { + var button = buttons[i]; + buttonlist.push(create_button(button)); + } + }); +}; +if (typeof window.pretixWidgetCallback !== "undefined") { + window.pretixWidgetCallback(); +} +if (window.PretixWidget.build_widgets) { + window.PretixWidget.buildWidgets(); +} /* Set a global variable for debugging. In DEBUG mode, siteglobals will be window, otherwise it will be something unnamed. */ -siteglobals.pretixwidget = { +siteglobals.pretixwidget_debug = { 'Vue': Vue, 'widgets': widgetlist, 'buttons': buttonlist diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index f5ea46bdc4..44ba27a5ef 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -1,4 +1,5 @@ import datetime +import json from datetime import timedelta from decimal import Decimal @@ -107,6 +108,91 @@ class CartTest(CartTestMixin, TestCase): self.assertIsNone(objs[0].variation) self.assertEqual(objs[0].price, 23) + def test_widget_data_post(self): + self.event.settings.attendee_names_asked = True + self.event.settings.attendee_emails_asked = True + q = self.event.questions.create( + event=self.event, question='What is your shoe size?', type=Question.TYPE_NUMBER, + required=True + ) + q.items.add(self.ticket) + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'widget_data': json.dumps({ + 'attendee-name-full-name': 'John Doe', + 'email': 'foo@example.com', + 'question-' + q.identifier: '43' + }) + }, follow=True) + self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug), + target_status_code=200) + 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.assertEqual(objs[0].attendee_email, "foo@example.com") + self.assertEqual(objs[0].attendee_name, "John Doe") + a = objs[0].answers.first() + self.assertEqual(a.answer, "43") + self.assertEqual(a.question, q) + + def test_widget_data_ignored_unknown_or_unasked(self): + self.event.settings.attendee_names_asked = False + self.event.settings.attendee_emails_asked = False + q = self.event.questions.create( + event=self.event, question='What is your shoe size?', type=Question.TYPE_NUMBER, + required=True + ) + q.items.add(self.ticket) + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + 'widget_data': json.dumps({ + 'attendee-name-full-name': 'John Doe', + 'email': 'foo@example.com', + 'question-' + q.identifier: 'bla' + }) + }, follow=True) + self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug), + target_status_code=200) + 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) + assert not objs[0].attendee_email + assert not objs[0].attendee_name + assert not objs[0].answers.exists() + + def test_widget_data_session(self): + self.event.settings.attendee_names_asked = True + self.event.settings.attendee_emails_asked = True + q = self.event.questions.create( + event=self.event, question='What is your shoe size?', type=Question.TYPE_NUMBER, + required=True + ) + q.items.add(self.ticket) + self._set_session('widget_data', { + 'attendee-name': 'John Doe', + 'email': 'foo@example.com', + 'question-' + q.identifier: '43' + }) + 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) + 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.assertEqual(objs[0].attendee_email, "foo@example.com") + self.assertEqual(objs[0].attendee_name, "John Doe") + a = objs[0].answers.first() + self.assertEqual(a.answer, "43") + self.assertEqual(a.question, q) + def _set_session(self, key, value): session = self.client.session session['carts'][get_cart_session_key(self.client, self.event)][key] = value