Compare commits

..

7 Commits

Author SHA1 Message Date
Mira Weller
0cad355deb add test case for SPAYD 2025-12-03 11:13:17 +01:00
Mira Weller
921f758b04 display bezahlcode only for DE IBANs 2025-12-03 11:13:10 +01:00
Mira Weller
22b41a80bf code style 2025-12-02 14:40:14 +01:00
Mira Weller
0b593c186a add test cases
note: bezahlcode encoding changed slightly due to actually url-encoding all parameters (comma is now encoded as %2C). i tested with ING app, it accepts both.

before: 'bank://singlepaymentsepa?name=Verein%20f%C3%BCr%20Testzwecke%20e.V.&iban=DE37796500000069799047&bic=BYLADEM1MIL&amount=123,00&reason=TESTVERANST-12345&currency=EUR'

after: 'bank://singlepaymentsepa?name=Verein%20f%C3%BCr%20Testzwecke%20e.V.&iban=DE37796500000069799047&bic=BYLADEM1MIL&amount=123%2C00&reason=TESTVERANST-12345&currency=EUR'
2025-12-02 14:01:35 +01:00
Mira Weller
e7ff5fe54c refactor all payment qr codes into separate functions 2025-12-02 13:27:06 +01:00
Mira Weller
588ff48db9 add czech SPAYD qr code standard 2025-12-02 13:20:06 +01:00
Mira Weller
cc4ad998e1 refactor payment qr codes 2025-12-02 13:15:41 +01:00
13 changed files with 477 additions and 572 deletions

View File

@@ -1,188 +0,0 @@
from ast import literal_eval
from collections import namedtuple
from datetime import datetime
from typing import Union
from django import forms
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Event
from pretix.control.forms import SplitDateTimeField
from pretix.control.forms.widgets import Select2
SubEventSelection = namedtuple(
typename='SubEventSelection',
field_names=['selection', 'subevents', 'start', 'end', ],
defaults=['subevent', None, None, None],
)
subeventselectionparts = namedtuple(
typename='subeventselectionparts',
field_names=['selection', 'subevents', 'start', 'end']
)
class SubEventSelectionWrapper:
def __init__(self, data: Union[None, SubEventSelection]):
self.data = data
def get_queryset(self, event: Event):
if self.data.selection == 'subevent':
if self.data.subevents is None:
return event.subevents.all()
else:
return event.subevents.filter(pk=self.data.subevents)
elif self.data.selection == 'timerange':
if self.data.start and self.data.end:
return event.subevents.filter(date_from__lte=self.data.start,
date_from__gte=self.data.end)
elif self.data.start:
return event.subevents.filter(date_from__gte=self.data.start)
elif self.data.end:
return event.subevents.filter(date_from__lte=self.data.end)
return event.subevents.all()
def to_string(self) -> str:
if self.data:
if self.data.selection == 'subevent':
return 'SUBEVENT/pk/{}'.format(self.data.subevents.pk)
elif self.data.selection == 'timerange':
if self.data.start and self.data.end:
return 'SUBEVENT/range/{}/{}'.format(self.data.start.isoformat(), self.data.end.isoformat())
elif self.data.start:
return 'SUBEVENT/from/{}'.format(self.data.start)
elif self.data.end:
return 'SUBEVENT/to/{}'.format(self.data.end)
return 'SUBEVENT'
@classmethod
def from_string(cls, input: str):
data = SubEventSelection(selection='subevent')
if input.startswith('SUBEVENT'):
parts = input.split('/')
if len(parts) == 1:
data = SubEventSelection(selection='subevent')
elif parts[1] == 'pk':
data = SubEventSelection(
selection='subevent',
subevents=literal_eval(parts[2])
)
elif parts[1] == 'range':
data = SubEventSelection(
selection="timerange",
start=datetime.fromisoformat(parts[2]),
end=datetime.fromisoformat(parts[3]),
)
elif parts[1] == 'from':
data = SubEventSelection(
selection="timerange",
start=datetime.fromisoformat(parts[2]),
)
elif parts[1] == 'to':
data = SubEventSelection(
selection="timerange",
end=datetime.fromisoformat(parts[3]),
)
return SubEventSelectionWrapper(
data=data
)
class SubeventSelectionWidget(forms.MultiWidget):
template_name = 'pretixcontrol/forms/widgets/subeventselection.html'
parts = SubEventSelection
def __init__(self, event: Event, status_choices, subevent_choices, *args, **kwargs):
widgets = subeventselectionparts(
selection=forms.RadioSelect(
choices=status_choices,
),
subevents=Select2(
attrs={
'class': 'simple-subevent-choice',
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}),
'data-placeholder': pgettext_lazy('subevent', 'All dates')
},
),
start=SplitDateTimePickerWidget(),
end=SplitDateTimePickerWidget(),
)
widgets.subevents.choices = subevent_choices
super().__init__(widgets=widgets, *args, **kwargs)
def decompress(self, value):
if isinstance(value, str):
value = SubEventSelectionWrapper.from_string(value)
if isinstance(value, subeventselectionparts):
return value
return subeventselectionparts(selection='subevent', start=None, end=None, subevents=None)
class SubeventSelectionField(forms.MultiValueField):
widget = SubeventSelectionWidget
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
choices = [
("subevent", _("Subevent")),
("timerange", _("Timerange"))
]
fields = SubEventSelection(
selection=forms.ChoiceField(
choices=choices,
required=True,
),
subevents=forms.ModelChoiceField(
required=False,
queryset=self.event.subevents,
empty_label=pgettext_lazy('subevent', 'All dates')
),
start=SplitDateTimeField(
required=False,
),
end=SplitDateTimeField(
required=False,
),
)
kwargs['widget'] = SubeventSelectionWidget(
event=self.event,
status_choices=choices,
subevent_choices=fields.subevents.widget.choices,
)
super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs
)
def compress(self, data_list):
if not data_list:
return None
return SubEventSelectionWrapper(data=SubEventSelection(*data_list)).to_string()
def clean(self, value):
data = subeventselectionparts(*value)
if data.selection == "timerange":
if (data.start != ["", ""] and data.end != ["", ""]) and data.end < data.start:
raise ValidationError(_("The end date must be after the start date."))
if (data.start == ["", ""]) and (data.end == ["", ""]):
raise ValidationError(_('At least one of start and end must be specified.'))
return super().clean(value)

View File

@@ -47,7 +47,6 @@ from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext as __, gettext_lazy as _
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
@@ -57,14 +56,11 @@ from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
Item, ItemCategory, ItemProgramTime, ItemVariation, Order, OrderPosition,
Question, QuestionOption, Quota,
Item, ItemCategory, ItemProgramTime, ItemVariation, Question,
QuestionOption, Quota,
)
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data
from pretix.base.subevent import (
SubeventSelectionField, SubEventSelectionWrapper,
)
from pretix.control.forms import (
ButtonGroupRadioSelect, ExtFileField, ItemMultipleChoiceField,
SalesChannelCheckboxSelectMultiple, SplitDateTimeField,
@@ -276,87 +272,6 @@ class QuestionOptionForm(I18nModelForm):
]
class QuestionFilterForm(forms.Form):
STATUS_VARIANTS = [
("", _("All orders")),
("p", _("Paid")),
("pv", _("Paid or confirmed")),
("n", _("Pending")),
("np", _("Pending or paid")),
("o", _("Pending (overdue)")),
("e", _("Expired")),
("ne", _("Pending or expired")),
("c", _("Canceled"))
]
status = forms.ChoiceField(
choices=STATUS_VARIANTS,
widget=forms.Select(
attrs={
'class': 'form-control',
}
),
required=False,
label=_("Status"),
initial="np",
)
item = forms.ChoiceField(
choices=[],
widget=forms.Select(
attrs={'class': 'form-control'}
),
required=False,
label=_("Items")
)
def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)
if self.event.has_subevents:
self.fields['subevent_selection'] = SubeventSelectionField(
event=self.event,
label=_("Subevents"),
help_text=_("Select the subevents that should be included in the statistics either by subevent or by the timerange in which they occur."),
)
self.fields['item'].choices = [('', _('All products'))] + [(item.id, item.name) for item in
Item.objects.filter(event=self.event)]
def filter_qs(self):
fdata = self.cleaned_data
opqs = OrderPosition.objects.filter(
order__event=self.event,
)
sub_event_qs = SubEventSelectionWrapper.from_string(fdata['subevent_selection']).get_queryset(self.event)
opqs = opqs.filter(subevent__in=sub_event_qs)
s = fdata.get("status", "np")
if s != "":
if s == 'o':
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == 'np':
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'pv':
opqs = opqs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
elif s == 'ne':
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
opqs = opqs.filter(order__status=s)
if s not in (Order.STATUS_CANCELED, ""):
opqs = opqs.filter(canceled=False)
if fdata.get("item", "") != "":
i = fdata.get("item", "")
opqs = opqs.filter(item_id__in=(i,))
return opqs
class QuotaForm(I18nModelForm):
itemvars = forms.MultipleChoiceField(
label=_("Products"),

View File

@@ -1,38 +0,0 @@
{% load i18n %}
<div class="subevent-selection col-lg-12">
{% for group_name, group_choices, group-index in widget.subwidgets.0.optgroups %}
{% for selopt in group_choices %}
<div class="radio">
<label class="col-lg-2">
<input type="radio" name="{{ widget.subwidgets.0.name }}" value="{{ selopt.value }}"
{% include "django/forms/widgets/attrs.html" with widget=selopt %} />
{{ selopt.label }}
</label>
{% if selopt.value == "subevent" %}
{% with widget.subwidgets.1 as widget %}
{% include widget.template_name %}
{% endwith %}
{% elif selopt.value == "timerange" %}
{% with widget.subwidgets.2 as widget %}
{% include widget.template_name %}
{% endwith %}
<span class="spacer">{% trans "until" %}</span>
{% with widget.subwidgets.3 as widget %}
{% include widget.template_name %}
{% endwith %}
{% endif %}
</div>
{% endfor %}
{% endfor %}
</div>

View File

@@ -5,11 +5,6 @@
{% load formset_tags %}
{% block title %}{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}{% endblock %}
{% block inside %}
{% for e in form.errors.values %}
<div class="alert alert-danger has-error">
{{ e }}
</div>
{% endfor %}
<h1>
{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}
<a href="{% url "control:event.items.questions.edit" event=request.event.slug organizer=request.event.organizer.slug question=question.pk %}"
@@ -25,24 +20,35 @@
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="col-lg-4 col-sm-6 col-xs-6">
{% bootstrap_label form.status.label %}
{% bootstrap_field form.status layout="inline" %}
</div>
<div class="col-lg-8 col-sm-6 col-xs-6">
{% bootstrap_label form.item.label %}
{% bootstrap_field form.item layout="inline" %}
</div>
<div class="col-lg-12 col-sm-6 col-xs-6">
{% bootstrap_label form.subevent_selection.label %}
{{ form.subevent_selection }}
<div class="help-block">
{{ form.subevent_selection.help_text }}
<div class="col-lg-2 col-sm-6 col-xs-6">
<select name="status" class="form-control">
<option value="" {% if request.GET.status == "" %}selected="selected"{% endif %}>{% trans "All orders" %}</option>
<option value="p" {% if request.GET.status == "p" %}selected="selected"{% endif %}>{% trans "Paid" %}</option>
<option value="pv" {% if request.GET.status == "pv" %}selected="selected"{% endif %}>{% trans "Paid or confirmed" %}</option>
<option value="n" {% if request.GET.status == "n" %}selected="selected"{% endif %}>{% trans "Pending" %}</option>
<option value="np" {% if request.GET.status == "np" or "status" not in request.GET %}selected="selected"{% endif %}>{% trans "Pending or paid" %}</option>
<option value="o" {% if request.GET.status == "o" %}selected="selected"{% endif %}>{% trans "Pending (overdue)" %}</option>
<option value="e" {% if request.GET.status == "e" %}selected="selected"{% endif %}>{% trans "Expired" %}</option>
<option value="ne" {% if request.GET.status == "ne" %}selected="selected"{% endif %}>{% trans "Pending or expired" %}</option>
<option value="c" {% if request.GET.status == "c" %}selected="selected"{% endif %}>{% trans "Canceled" %}</option>
</select>
</div>
<div class="col-lg-5 col-sm-6 col-xs-6">
<select name="item" class="form-control">
<option value="">{% trans "All products" %}</option>
{% for item in items %}
<option value="{{ item.id }}"
{% if request.GET.item|add:0 == item.id %}selected="selected"{% endif %}>
{{ item.name }}
</option>
{% endfor %}
</select>
</div>
{% if request.event.has_subevents %}
<div class="col-lg-5 col-sm-6 col-xs-6">
{% include "pretixcontrol/event/fragment_subevent_choice_simple.html" %}
</div>
</div>
{% endif %}
</div>
<div class="text-right">
<button class="btn btn-primary btn-lg" type="submit">

View File

@@ -38,7 +38,6 @@ from collections import OrderedDict, namedtuple
from itertools import groupby
from json.decoder import JSONDecodeError
from django import forms
from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.core.files import File
@@ -46,7 +45,6 @@ from django.db import transaction
from django.db.models import (
Count, Exists, F, OuterRef, Prefetch, ProtectedError, Q,
)
from django.dispatch import receiver
from django.forms.models import inlineformset_factory
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
@@ -65,10 +63,9 @@ from pretix.api.serializers.item import (
ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer,
ItemVariationSerializer,
)
from pretix.base.exporter import ListExporter
from pretix.base.forms import I18nFormSet
from pretix.base.models import (
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation,
CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, QuestionOption, Quota,
SeatCategoryMapping, Voucher,
)
@@ -76,13 +73,12 @@ from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tickets import invalidate_cache
from pretix.base.signals import quota_availability, register_data_exporters
from pretix.base.signals import quota_availability
from pretix.control.forms.item import (
CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm,
ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm,
ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm,
ItemVariationsFormSet, QuestionFilterForm, QuestionForm,
QuestionOptionForm, QuotaForm,
ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm,
)
from pretix.control.permissions import (
EventPermissionRequiredMixin, event_permission_required,
@@ -664,73 +660,46 @@ class QuestionMixin:
return ctx
class QuestionAnswerExporter(ListExporter):
identifier = 'question_answer_exporter'
verbose_name = _('Question answers exporter')
description = _('Download a spreadsheet containing question answers')
category = _('Order data')
@property
def additional_form_fields(self):
form = {
'question':
forms.ModelChoiceField(
label=_('Question'),
queryset=Question.objects.filter(event=self.event),
),
**QuestionFilterForm(event=self.event).fields
}
return form
def iterate_list(self, form_data):
question = Question.objects.filter(event=self.event).get(pk=form_data['question'])
opqs = QuestionFilterForm(event=self.event, data=form_data).order_position_queryset()
qs = QuestionAnswer.objects.filter(
question=question, orderposition__isnull=False,
)
qs = qs.filter(orderposition__in=opqs)
headers = [
_("Subevent"),
_("Event start time"),
_("Order"),
_("Order position"),
question.question
]
yield headers
yield self.ProgressSetTotal(total=qs.count())
for questionAnswer in qs.iterator(chunk_size=1000):
row = [
questionAnswer.orderposition.subevent.name,
questionAnswer.orderposition.subevent.date_from.replace(tzinfo=None),
questionAnswer.orderposition.order.code,
questionAnswer.orderposition.positionid,
questionAnswer.answer
]
yield row
@receiver(register_data_exporters, dispatch_uid="exporter_questions_exporter")
def register_data_exporter(sender, **kwargs):
return QuestionAnswerExporter
class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView):
class QuestionView(EventPermissionRequiredMixin, QuestionMixin, ChartContainingView, DetailView):
model = Question
template_name = 'pretixcontrol/items/question.html'
permission = 'can_change_items'
template_name_field = 'question'
def get_answer_statistics(self, opqs: OrderPosition):
def get_answer_statistics(self):
opqs = OrderPosition.objects.filter(
order__event=self.request.event,
)
qs = QuestionAnswer.objects.filter(
question=self.object, orderposition__isnull=False,
)
if self.request.GET.get("subevent", "") != "":
opqs = opqs.filter(subevent=self.request.GET["subevent"])
s = self.request.GET.get("status", "np")
if s != "":
if s == 'o':
opqs = opqs.filter(order__status=Order.STATUS_PENDING,
order__expires__lt=now().replace(hour=0, minute=0, second=0))
elif s == 'np':
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
elif s == 'pv':
opqs = opqs.filter(
Q(order__status=Order.STATUS_PAID) |
Q(order__status=Order.STATUS_PENDING, order__valid_if_pending=True)
)
elif s == 'ne':
opqs = opqs.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_EXPIRED])
else:
opqs = opqs.filter(order__status=s)
if s not in (Order.STATUS_CANCELED, ""):
opqs = opqs.filter(canceled=False)
if self.request.GET.get("item", "") != "":
i = self.request.GET.get("item", "")
opqs = opqs.filter(item_id__in=(i,))
qs = qs.filter(orderposition__in=opqs)
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
@@ -778,20 +747,8 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['items'] = self.object.items.all()
if self.request.GET:
ctx['form'] = QuestionFilterForm(
data=self.request.GET,
event=self.request.event,
)
else:
ctx['form'] = QuestionFilterForm(
event=self.request.event,
)
if ctx['form'].is_valid():
opqs = ctx['form'].filter_qs()
stats = self.get_answer_statistics(opqs)
ctx['stats'], ctx['total'] = stats
stats = self.get_answer_statistics()
ctx['stats'], ctx['total'] = stats
return ctx
def get_object(self, queryset=None) -> Question:

View File

@@ -0,0 +1,210 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix 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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
from urllib.parse import quote, urlencode
import text_unidecode
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
def dotdecimal(value):
return str(value).replace(",", ".")
def commadecimal(value):
return str(value).replace(".", ",")
def generate_payment_qr_codes(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
out = []
for method in [
swiss_qrbill,
czech_spayd,
euro_epc_qr,
euro_bezahlcode,
]:
data = method(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
)
if data:
out.append(data)
return out
def euro_epc_qr(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
if event.currency != 'EUR' or not bank_details_sepa_iban:
return
return {
"id": "girocode",
"label": "EPC-QR",
"qr_data": "\n".join(text_unidecode.unidecode(str(d or '')) for d in [
"BCD", # Service Tag: BCD
"002", # Version: V2
"2", # Character set: ISO 8859-1
"SCT", # Identification code: SCT
bank_details_sepa_bic, # AT-23 BIC of the Beneficiary Bank
bank_details_sepa_name, # AT-21 Name of the Beneficiary
bank_details_sepa_iban, # AT-20 Account number of the Beneficiary
f"{event.currency}{dotdecimal(amount)}", # AT-04 Amount of the Credit Transfer in Euro
"", # AT-44 Purpose of the Credit Transfer
"", # AT-05 Remittance Information (Structured)
code, # AT-05 Remittance Information (Unstructured)
"", # Beneficiary to originator information
"",
]),
}
def euro_bezahlcode(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
if not bank_details_sepa_iban or bank_details_sepa_iban[:2] != 'DE':
return
if event.currency != 'EUR':
return
qr_data = "bank://singlepaymentsepa?" + urlencode({
"name": str(bank_details_sepa_name),
"iban": str(bank_details_sepa_iban),
"bic": str(bank_details_sepa_bic),
"amount": commadecimal(amount),
"reason": str(code),
"currency": str(event.currency),
}, quote_via=quote)
return {
"id": "bezahlcode",
"label": "BezahlCode",
"qr_data": mark_safe(qr_data),
"link": qr_data,
"link_aria_label": _("Open BezahlCode in your banking app to start the payment process."),
}
def swiss_qrbill(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
if not bank_details_sepa_iban or not bank_details_sepa_iban[:2] in ('CH', 'LI'):
return
if event.currency not in ('EUR', 'CHF'):
return
if not event.settings.invoice_address_from or not event.settings.invoice_address_from_country:
return
data_fields = [
'SPC',
'0200',
'1',
bank_details_sepa_iban,
'K',
bank_details_sepa_name[:70],
event.settings.invoice_address_from.replace('\n', ', ')[:70],
(event.settings.invoice_address_from_zipcode + ' ' + event.settings.invoice_address_from_city)[:70],
'',
'',
str(event.settings.invoice_address_from_country),
'', # rfu
'', # rfu
'', # rfu
'', # rfu
'', # rfu
'', # rfu
'', # rfu
str(amount),
event.currency,
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'NON',
'', # structured reference
code,
'EPD',
]
data_fields = [text_unidecode.unidecode(d or '') for d in data_fields]
qr_data = '\r\n'.join(data_fields)
return {
"id": "qrbill",
"label": "QR-bill",
"html_prefix": mark_safe(
'<svg class="banktransfer-swiss-cross" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.8 19.8">'
'<path stroke="#fff" stroke-width="1.436" d="M.7.7h18.4v18.4H.7z"/><path fill="#fff" d="M8.3 4h3.3v11H8.3z"/>'
'<path fill="#fff" d="M4.4 7.9h11v3.3h-11z"/></svg>'
),
"qr_data": qr_data,
"css_class": "banktransfer-swiss-cross-overlay",
}
def czech_spayd(
event,
code,
amount,
bank_details_sepa_bic,
bank_details_sepa_name,
bank_details_sepa_iban,
):
if not bank_details_sepa_iban or not bank_details_sepa_iban[:2] in ('CZ', 'SK'):
return
if event.currency not in ('EUR', 'CZK'):
return
qr_data = f"SPD*1.0*ACC:{bank_details_sepa_iban}*AM:{dotdecimal(amount)}*CC:{event.currency}*MSG:{code}"
return {
"id": "spayd",
"label": "SPAYD",
"qr_data": qr_data,
}

View File

@@ -46,12 +46,12 @@ from i18nfield.forms import I18nTextInput
from i18nfield.strings import LazyI18nString
from localflavor.generic.forms import BICFormField, IBANFormField
from localflavor.generic.validators import IBANValidator
from text_unidecode import unidecode
from pretix.base.forms import I18nMarkdownTextarea
from pretix.base.models import InvoiceAddress, Order, OrderPayment, OrderRefund
from pretix.base.payment import BasePaymentProvider
from pretix.base.templatetags.money import money_filter
from pretix.helpers.payment import generate_payment_qr_codes
from pretix.plugins.banktransfer.templatetags.ibanformat import ibanformat
from pretix.presale.views.cart import cart_session
@@ -313,51 +313,6 @@ class BankTransfer(BasePaymentProvider):
t += str(self.settings.get('bank_details', as_type=LazyI18nString))
return t
def swiss_qrbill(self, payment):
if not self.settings.get('bank_details_sepa_iban') or not self.settings.get('bank_details_sepa_iban')[:2] in ('CH', 'LI'):
return
if self.event.currency not in ('EUR', 'CHF'):
return
if not self.event.settings.invoice_address_from or not self.event.settings.invoice_address_from_country:
return
data_fields = [
'SPC',
'0200',
'1',
self.settings.get('bank_details_sepa_iban'),
'K',
self.settings.get('bank_details_sepa_name')[:70],
self.event.settings.invoice_address_from.replace('\n', ', ')[:70],
(self.event.settings.invoice_address_from_zipcode + ' ' + self.event.settings.invoice_address_from_city)[:70],
'',
'',
str(self.event.settings.invoice_address_from_country),
'', # rfu
'', # rfu
'', # rfu
'', # rfu
'', # rfu
'', # rfu
'', # rfu
str(payment.amount),
self.event.currency,
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'', # debtor address
'NON',
'', # structured reference
self._code(payment.order),
'EPD',
]
data_fields = [unidecode(d or '') for d in data_fields]
return '\r\n'.join(data_fields)
def payment_pending_render(self, request: HttpRequest, payment: OrderPayment):
template = get_template('pretixplugins/banktransfer/pending.html')
ctx = {
@@ -367,13 +322,18 @@ class BankTransfer(BasePaymentProvider):
'amount': payment.amount,
'payment_info': payment.info_data,
'settings': self.settings,
'swiss_qrbill': self.swiss_qrbill(payment),
'eu_barcodes': self.event.currency == 'EUR',
'payment_qr_codes': generate_payment_qr_codes(
event=self.event,
code=self._code(payment.order),
amount=payment.amount,
bank_details_sepa_bic=self.settings.get('bank_details_sepa_bic'),
bank_details_sepa_name=self.settings.get('bank_details_sepa_name'),
bank_details_sepa_iban=self.settings.get('bank_details_sepa_iban'),
) if self.settings.bank_details_type == "sepa" else None,
'pending_description': self.settings.get('pending_description', as_type=LazyI18nString),
'details': self.settings.get('bank_details', as_type=LazyI18nString),
'has_invoices': payment.order.invoices.exists(),
}
ctx['any_barcodes'] = ctx['swiss_qrbill'] or ctx['eu_barcodes']
return template.render(ctx, request=request)
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:

View File

@@ -1,7 +1,6 @@
{% load i18n %}
{% load l10n %}
{% load commadecimal %}
{% load static %}
{% load dotdecimal %}
{% load ibanformat %}
{% load money %}
@@ -17,7 +16,7 @@
{% endblocktrans %}</p>
<div class="row">
<div class="{% if settings.bank_details_type == "sepa" %}col-md-6{% else %}col-md-12{% endif %} col-xs-12">
<div class="{% if payment_qr_codes %}col-md-6{% else %}col-md-12{% endif %} col-xs-12">
<dl class="dl-horizontal">
<dt>{% trans "Reference code (important):" %}</dt><dd><b>{{ code }}</b></dd>
<dt>{% trans "Amount:" %}</dt><dd>{{ amount|money:event.currency }}</dd>
@@ -36,94 +35,7 @@
{% trans "We will send you an email as soon as we received your payment." %}
</p>
</div>
{% if settings.bank_details_type == "sepa" and any_barcodes %}
<div class="tabcontainer col-md-6 col-sm-6 hidden-xs text-center js-only blank-after">
<div id="banktransfer_qrcodes_tabs_content" class="tabpanels blank-after">
{% if swiss_qrbill %}
<div id="banktransfer_qrcodes_qrbill"
role="tabpanel"
tabindex="0"
aria-labelledby="banktransfer_qrcodes_qrbill_tab"
>
<div class="banktransfer-swiss-cross-overlay" role="figure" aria-labelledby="banktransfer_qrcodes_qrbill_tab banktransfer_qrcodes_label">
<svg class="banktransfer-swiss-cross" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 19.8 19.8"><path stroke="#fff" stroke-width="1.436" d="M.7.7h18.4v18.4H.7z"/><path fill="#fff" d="M8.3 4h3.3v11H8.3z"/><path fill="#fff" d="M4.4 7.9h11v3.3h-11z"/></svg>
<script type="text/plain" data-size="150" data-replace-with-qr data-desc="{% trans 'Scan this image with your banking apps QR-Reader to start the payment process.' %}">{{swiss_qrbill}}</script>
</div>
</div>
{% endif %}
{% if eu_barcodes %}
<div id="banktransfer_qrcodes_girocode"
role="tabpanel"
tabindex="0"
{{ swiss_qrbill|yesno:'hidden,' }}
aria-labelledby="banktransfer_qrcodes_girocode_tab"
>
<div role="figure" aria-labelledby="banktransfer_qrcodes_girocode_tab banktransfer_qrcodes_label">
<script type="text/plain" data-size="150" data-replace-with-qr data-desc="{% trans 'Scan this image with your banking apps QR-Reader to start the payment process.' %}">BCD
002
2
SCT
{{ settings.bank_details_sepa_bic }}
{{ settings.bank_details_sepa_name|unidecode }}
{{ settings.bank_details_sepa_iban }}
{{ event.currency }}{{ amount|dotdecimal }}
{{ code }}
</script>
</div>
</div>
<div id="banktransfer_qrcodes_bezahlcode"
role="tabpanel"
tabindex="0"
hidden
aria-labelledby="banktransfer_qrcodes_bezahlcode_tab"
>
<a aria-label="{% trans "Open BezahlCode in your banking app to start the payment process." %}" href="bank://singlepaymentsepa?name={{ settings.bank_details_sepa_name|urlencode }}&iban={{ settings.bank_details_sepa_iban }}&bic={{ settings.bank_details_sepa_bic }}&amount={{ amount|commadecimal }}&reason={{ code }}&currency={{ event.currency }}">
<div role="figure" aria-labelledby="banktransfer_qrcodes_bezahlcode_tab banktransfer_qrcodes_label">
<script type="text/plain" data-size="150" data-replace-with-qr data-desc="{% trans 'Scan this image with your banking apps QR-Reader to start the payment process.' %}">bank://singlepaymentsepa?name={{ settings.bank_details_sepa_name|urlencode }}&iban={{ settings.bank_details_sepa_iban }}&bic={{ settings.bank_details_sepa_bic }}&amount={{ amount|commadecimal }}&reason={{ code }}&currency={{ event.currency }}</script>
</div>
</a>
</div>
{% endif %}
</div>
<div id="banktransfer_qrcodes_tabs" role="tablist" aria-labelledby="banktransfer_qrcodes_label" class="blank-after btn-group">
{% if swiss_qrbill %}
<button
class="btn btn-default"
id="banktransfer_qrcodes_qrbill_tab"
type="button"
role="tab"
aria-controls="banktransfer_qrcodes_qrbill"
aria-selected="true"
tabindex="-1">QR-bill</button>
{% endif %}
{% if eu_barcodes %}
<button
class="btn btn-default"
id="banktransfer_qrcodes_girocode_tab"
type="button"
role="tab"
aria-controls="banktransfer_qrcodes_girocode"
aria-selected="{{ swiss_qrbill|yesno:"false,true" }}"
tabindex="-1">EPC-QR</button>
<button
class="btn btn-default"
id="banktransfer_qrcodes_bezahlcode_tab"
type="button"
role="tab"
aria-controls="banktransfer_qrcodes_bezahlcode"
aria-selected="false"
tabindex="-1">BezahlCode</button>
{% endif %}
</div>
<p class="text-muted" id="banktransfer_qrcodes_label">
{% trans "Scan the QR code with your banking app" %}
</p>
</div>
{% if payment_qr_codes %}
{% include "pretixpresale/event/payment_qr_codes.html" %}
{% endif %}
</div>
{% if swiss_qrbill %}
<link rel="stylesheet" href="{% static "pretixplugins/banktransfer/swisscross.css" %}">
{% endif %}
</div>

View File

@@ -711,7 +711,7 @@ class PaypalMethod(BasePaymentProvider):
description = '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {order} for {event}').format(
event=self.event.name,
event=request.event.name,
order=payment.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''

View File

@@ -0,0 +1,44 @@
{% load i18n %}
{% load static %}
{% if payment_qr_codes %}
<div class="tabcontainer col-md-6 col-sm-6 hidden-xs text-center js-only blank-after">
<div id="banktransfer_qrcodes_tabs_content" class="tabpanels blank-after">
{% for code_info in payment_qr_codes %}
<div id="banktransfer_qrcodes_{{ code_info.id }}"
role="tabpanel"
tabindex="0"
{% if not forloop.first %}hidden{% endif %}
aria-labelledby="banktransfer_qrcodes_{{ code_info.id }}_tab"
>
{% if code_info.link %}<a aria-label="{{ code_info.link_aria_label }}" href="{{ code_info.link }}">{% endif %}
<div class="{{ code_info.css_class }}" role="figure" aria-labelledby="banktransfer_qrcodes_{{ code_info.id }}_tab banktransfer_qrcodes_label">
{{ code_info.html_prefix }}
<script type="text/plain" data-size="150" data-replace-with-qr data-desc="{% trans 'Scan this image with your banking apps QR-Reader to start the payment process.' %}">{{ code_info.qr_data }}</script>
</div>
{% if code_info.link %}</a>{% endif %}
</div>
{% endfor %}
</div>
<div id="banktransfer_qrcodes_tabs" role="tablist" aria-labelledby="banktransfer_qrcodes_label" class="blank-after btn-group">
{% for code_info in payment_qr_codes %}
<button
class="btn btn-default"
id="banktransfer_qrcodes_{{ code_info.id }}_tab"
type="button"
role="tab"
aria-controls="banktransfer_qrcodes_{{ code_info.id }}"
aria-selected="{{ forloop.first|yesno:"true,false" }}"
tabindex="-1">{{ code_info.label }}</button>
{% endfor %}
</div>
<p class="text-muted" id="banktransfer_qrcodes_label">
{% trans "Scan the QR code with your banking app" %}
</p>
</div>
{% for code_info in payment_qr_codes %}
{% if code_info.id == "qrbill" %}
<link rel="stylesheet" href="{% static "pretixplugins/banktransfer/swisscross.css" %}">
{% endif %}
{% endfor %}
{% endif %}

View File

@@ -67,7 +67,7 @@ $panel-success-heading-bg: var(--pretix-brand-success-tint-50);
$panel-danger-border: var(--pretix-brand-danger-tint-50);
$panel-danger-heading-bg: var(--pretix-brand-danger-tint-50);
$panel-warning-border: var(--pretix-brand-warning-tint-50);
$panel-warning-heading-bg: var(--pretix-brand-warning-tint-50);
$panel-warning-heading-bg: var(--pretix-brand-warning-tine-50);
$panel-default-border: #e5e5e5 !default;
$panel-default-heading-bg: #e5e5e5 !default;

View File

@@ -216,20 +216,6 @@ td > .form-group > .checkbox {
.input-group-btn .btn {
padding-bottom: 8px;
}
.subevent-selection{
.splitdatetimerow{
max-width: 500px;
display: inline-block;
}
.spacer{
margin-left: 20px;
margin-right: 20px;
}
.select2{
max-width:500px;
display: inline-block;
}
}
.reldatetime {
input[type=text], select {
display: inline-block;

View File

@@ -0,0 +1,141 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix 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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
from datetime import timedelta
from decimal import Decimal
import pytest
from django.utils.timezone import now
from pretix.base.models import Event, Organizer
from pretix.helpers.payment import generate_payment_qr_codes
@pytest.fixture
def env():
o = Organizer.objects.create(name='Verein für Testzwecke e.V.', slug='testverein')
event = Event.objects.create(
organizer=o, name='Testveranstaltung', slug='testveranst',
date_from=now() + timedelta(days=10),
live=True, is_public=False, currency='EUR',
)
event.settings.invoice_address_from = 'Verein für Testzwecke e.V.'
event.settings.invoice_address_from_zipcode = '1234'
event.settings.invoice_address_from_city = 'Testhausen'
event.settings.invoice_address_from_country = 'CH'
return o, event
@pytest.mark.django_db
def test_payment_qr_codes_euro(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='Verein für Testzwecke e.V.',
)
assert len(codes) == 2
assert codes[0]['label'] == 'EPC-QR'
assert codes[0]['qr_data'] == '''BCD
002
2
SCT
BYLADEM1MIL
Verein fur Testzwecke e.V.
DE37796500000069799047
EUR123.00
TESTVERANST-12345
'''
assert codes[1]['label'] == 'BezahlCode'
assert codes[1]['qr_data'] == ('bank://singlepaymentsepa?name=Verein%20f%C3%BCr%20Testzwecke%20e.V.&iban=DE37796500000069799047'
'&bic=BYLADEM1MIL&amount=123%2C00&reason=TESTVERANST-12345&currency=EUR')
@pytest.mark.django_db
def test_payment_qr_codes_swiss(env):
o, event = env
codes = generate_payment_qr_codes(
event=event,
code='TESTVERANST-12345',
amount=Decimal('123.00'),
bank_details_sepa_bic='TESTCHXXXXX',
bank_details_sepa_iban='CH6389144757654882127',
bank_details_sepa_name='Verein für Testzwecke e.V.',
)
assert codes[0]['label'] == 'QR-bill'
assert codes[0]['qr_data'] == "\r\n".join([
"SPC",
"0200",
"1",
"CH6389144757654882127",
"K",
"Verein fur Testzwecke e.V.",
"Verein fur Testzwecke e.V.",
"1234 Testhausen",
"",
"",
"CH",
"",
"",
"",
"",
"",
"",
"",
"123.00",
"EUR",
"",
"",
"",
"",
"",
"",
"",
"NON",
"",
"TESTVERANST-12345",
"EPD",
])
@pytest.mark.django_db
def test_payment_qr_codes_spayd(env):
o, event = env
codes = generate_payment_qr_codes(
event=event,
code='TESTVERANST-12345',
amount=Decimal('123.00'),
bank_details_sepa_bic='TESTCZXXXXX',
bank_details_sepa_iban='CZ7450513769129174398769',
bank_details_sepa_name='Verein für Testzwecke e.V.',
)
assert len(codes) == 2
assert codes[0]['label'] == 'SPAYD'
assert codes[0]['qr_data'] == 'SPD*1.0*ACC:CZ7450513769129174398769*AM:123.00*CC:EUR*MSG:TESTVERANST-12345'
assert codes[1]['label'] == 'EPC-QR'