diff --git a/src/pretix/helpers/payment.py b/src/pretix/helpers/payment.py
index 8d19d56fd..9950e85f2 100644
--- a/src/pretix/helpers/payment.py
+++ b/src/pretix/helpers/payment.py
@@ -25,6 +25,19 @@ import text_unidecode
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
+EPC_QR_ALLOWED_CHARS = set(
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
+ "abcdefghijklmnopqrstuvwxyz"
+ "0123456789/-?:().,'+ "
+)
+
+
+def epc_qr_field(value):
+ return ''.join(
+ char for char in text_unidecode.unidecode(str(value or ''))
+ if char in EPC_QR_ALLOWED_CHARS
+ )
+
def dotdecimal(value):
return str(value).replace(",", ".")
@@ -77,7 +90,7 @@ def euro_epc_qr(
return {
"id": "girocode",
"label": "EPC-QR",
- "qr_data": "\n".join(text_unidecode.unidecode(str(d or '')) for d in [
+ "qr_data": "\n".join(epc_qr_field(d) for d in [
"BCD", # Service Tag: ‘BCD’
"002", # Version: V2
"2", # Character set: ISO 8859-1
diff --git a/src/pretix/presale/templates/pretixpresale/event/payment_qr_codes.html b/src/pretix/presale/templates/pretixpresale/event/payment_qr_codes.html
index 9c1500f08..f45394c23 100644
--- a/src/pretix/presale/templates/pretixpresale/event/payment_qr_codes.html
+++ b/src/pretix/presale/templates/pretixpresale/event/payment_qr_codes.html
@@ -1,5 +1,6 @@
{% load i18n %}
{% load static %}
+{% load escapejson %}
{% if payment_qr_codes %}
@@ -41,4 +42,4 @@
");
+ var qrText = this.getAttribute("type") === "application/json" && this.textContent.startsWith('"') ?
+ JSON.parse(this.textContent) : $(this).html();
$div.insertBefore($(this));
$div.qrcode(
{
- text: $(this).html(),
+ text: qrText,
correctLevel: 0, // M
width: $(this).attr("data-size") ? parseInt($(this).attr("data-size")) : 256,
height: $(this).attr("data-size") ? parseInt($(this).attr("data-size")) : 256,
diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js
index f4f66f70c..f3eb4067f 100644
--- a/src/pretix/static/pretixpresale/js/ui/main.js
+++ b/src/pretix/static/pretixpresale/js/ui/main.js
@@ -131,10 +131,12 @@ var form_handlers = function (el) {
el.find("script[data-replace-with-qr]").each(function () {
var $div = $("
");
+ var qrText = this.getAttribute("type") === "application/json" && this.textContent.startsWith('"') ?
+ JSON.parse(this.textContent) : $(this).html();
$div.insertBefore($(this));
$div.qrcode(
{
- text: $(this).html(),
+ text: qrText,
correctLevel: 0, // M
width: $(this).attr("data-size") ? parseInt($(this).attr("data-size")) : 256,
height: $(this).attr("data-size") ? parseInt($(this).attr("data-size")) : 256,
@@ -345,7 +347,7 @@ function setup_basics(el) {
}).on('click', function (event) {
setCurrentTab(this);
});
-
+
var firstTab = tabs.first().get(0);
var lastTab = tabs.last().get(0);
setCurrentTab(tabs.filter('[aria-selected=true]').get(0));
@@ -658,7 +660,7 @@ $(function () {
var currentTimeDisplayParts = [];
timeFormatParts.forEach(function(format) {
currentTimeDisplayParts.push([format, $("").appendTo(currentTimeDisplay)])
- });
+ });
var duration = this.getAttribute("data-duration").split(":").reduce(function(previousValue, currentValue, currentIndex) {
return previousValue + (currentIndex ? parseInt(currentValue, 10) * 60 : parseInt(currentValue, 10) * 60 * 60);
}, 0);
@@ -671,7 +673,7 @@ $(function () {
currentTimeBar.remove();
return;
}
-
+
var offset = thisCalendar.querySelector("h3").getBoundingClientRect().width;
var dx = Math.round(offset + (thisCalendar.scrollWidth-offset)*(currentTimeDelta/duration));
currentTimeDisplayParts.forEach(function(part) {
diff --git a/src/tests/helpers/test_payment.py b/src/tests/helpers/test_payment.py
index 82ab754d6..ea99faa2d 100644
--- a/src/tests/helpers/test_payment.py
+++ b/src/tests/helpers/test_payment.py
@@ -23,6 +23,8 @@ from datetime import timedelta
from decimal import Decimal
import pytest
+from django.template.loader import render_to_string
+from django.utils.safestring import SafeData
from django.utils.timezone import now
from pretix.base.models import Event, Organizer
@@ -77,6 +79,31 @@ TESTVERANST-12345
'&bic=BYLADEM1MIL&amount=123%2C00&reason=TESTVERANST-12345¤cy=EUR')
+@pytest.mark.django_db
+def test_payment_qr_codes_euro_keeps_allowed_apostrophe_unescaped(env):
+ o, event = env
+ codes = generate_payment_qr_codes(
+ event=event,
+ code='TESTVERANST-12345',
+ amount=Decimal('123.00'),
+ bank_details_sepa_bic='BYLADEM1MIL',
+ bank_details_sepa_iban='DE37796500000069799047',
+ bank_details_sepa_name='Bits\'n"Bugs',
+ )
+
+ qr_data = codes[0]['qr_data']
+ assert '\nBits\'nBugs\n' in qr_data
+ assert not isinstance(qr_data, SafeData)
+
+ html = render_to_string(
+ 'pretixpresale/event/payment_qr_codes.html',
+ {'payment_qr_codes': codes},
+ )
+ assert 'type="application/json"' in html
+ assert "\\nBits'nBugs\\n" in html
+ assert ''' not in html
+
+
@pytest.mark.django_db
def test_payment_qr_codes_swiss(env):
o, event = env