diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index b70c40b009..cb10601ab3 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -576,9 +576,11 @@ class EventSettingsSerializer(serializers.Serializer): 'attendee_company_required', 'confirm_texts', 'order_email_asked_twice', + 'payment_term_mode', 'payment_term_days', - 'payment_term_last', 'payment_term_weekdays', + 'payment_term_minutes', + 'payment_term_last', 'payment_term_expire_automatically', 'payment_term_accept_late', 'payment_explanation', diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index b49d61d703..e3fa0497ef 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -12,7 +12,9 @@ from django.utils.timezone import now from django.utils.translation import get_language, gettext_lazy as _ from inlinestyler.utils import inline_css -from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber +from pretix.base.i18n import ( + LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber, +) from pretix.base.models import Event from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import ( @@ -315,9 +317,8 @@ def base_placeholders(sender, **kwargs): lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency) ), SimpleFunctionalMailTextPlaceholder( - 'expire_date', ['event', 'order'], lambda event, order: LazyDate(order.expires.astimezone(event.timezone)), + 'expire_date', ['event', 'order'], lambda event, order: LazyExpiresDate(order.expires.astimezone(event.timezone)), lambda event: LazyDate(now() + timedelta(days=15)) - # TODO: This used to be "date" in some placeholders, add a migration! ), SimpleFunctionalMailTextPlaceholder( 'url', ['order', 'event'], lambda order, event: build_absolute_uri( diff --git a/src/pretix/base/i18n.py b/src/pretix/base/i18n.py index bd737e34d8..d6ed7d2aa0 100644 --- a/src/pretix/base/i18n.py +++ b/src/pretix/base/i18n.py @@ -27,6 +27,21 @@ class LazyDate: return date_format(self.value, "SHORT_DATE_FORMAT") +class LazyExpiresDate: + def __init__(self, expires): + self.value = expires + + def __format__(self, format_spec): + return self.__str__() + + def __str__(self): + at_end_of_day = self.value.hour == 23 and self.value.minute == 59 and self.value.second >= 59 + if at_end_of_day: + return date_format(self.value, "SHORT_DATE_FORMAT") + else: + return date_format(self.value, "SHORT_DATETIME_FORMAT") + + class LazyCurrencyNumber: def __init__(self, value, currency): self.value = value diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index b65c65cc7f..f892307aff 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -392,13 +392,19 @@ class Order(LockModel, LoggedModel): def set_expires(self, now_dt=None, subevents=None): now_dt = now_dt or now() tz = pytz.timezone(self.event.settings.timezone) - exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int)) - exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0) - if self.event.settings.get('payment_term_weekdays'): - if exp_by_date.weekday() == 5: - exp_by_date += timedelta(days=2) - elif exp_by_date.weekday() == 6: - exp_by_date += timedelta(days=1) + mode = self.event.settings.get('payment_term_mode') + if mode == 'days': + exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get('payment_term_days', as_type=int)) + exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0) + if self.event.settings.get('payment_term_weekdays'): + if exp_by_date.weekday() == 5: + exp_by_date += timedelta(days=2) + elif exp_by_date.weekday() == 6: + exp_by_date += timedelta(days=1) + elif mode == 'minutes': + exp_by_date = now_dt.astimezone(tz) + timedelta(minutes=self.event.settings.get('payment_term_minutes', as_type=int)) + else: + raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode)) self.expires = exp_by_date diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 6276af88b5..3e900eb5f3 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -423,6 +423,29 @@ DEFAULTS = { "if you want.") ) }, + 'payment_term_mode': { + 'default': 'days', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=( + ('days', _("in days")), + ('minutes', _("in minutes")) + ), + ), + 'form_kwargs': dict( + label=_("Set payment term"), + widget=forms.RadioSelect, + required=True, + choices=( + ('days', _("in days")), + ('minutes', _("in minutes")) + ), + help_text=_("If using days, the order will expire at the end of the last day. " + "Using minutes is more exact, but should only be used for real-time payment methods.") + ) + }, 'payment_term_days': { 'default': '14', 'type': int, @@ -430,11 +453,16 @@ DEFAULTS = { 'serializer_class': serializers.IntegerField, 'form_kwargs': dict( label=_('Payment term in days'), + widget=forms.NumberInput( + attrs={ + 'data-display-dependency': '#id_payment_term_mode_0', + 'data-required-if': '#id_payment_term_mode_0' + }, + ), help_text=_("The number of days after placing an order the user has to pay to preserve their reservation. If " "you use slow payment methods like bank transfer, we recommend 14 days. If you only use real-time " "payment methods, we recommend still setting two or three days to allow people to retry failed " "payments."), - required=True, validators=[MinValueValidator(0), MaxValueValidator(1000000)] ), @@ -443,18 +471,6 @@ DEFAULTS = { MaxValueValidator(1000000)] ) }, - 'payment_term_last': { - 'default': None, - 'type': RelativeDateWrapper, - 'form_class': RelativeDateField, - 'serializer_class': SerializerRelativeDateField, - 'form_kwargs': dict( - label=_('Last date of payments'), - help_text=_("The last date any payments are accepted. This has precedence over the number of " - "days configured above. If you use the event series feature and an order contains tickets for " - "multiple dates, the earliest date will be used."), - ) - }, 'payment_term_weekdays': { 'default': 'True', 'type': bool, @@ -464,7 +480,49 @@ DEFAULTS = { label=_('Only end payment terms on weekdays'), help_text=_("If this is activated and the payment term of any order ends on a Saturday or Sunday, it will be " "moved to the next Monday instead. This is required in some countries by civil law. This will " - "not effect the last date of payments configured above."), + "not effect the last date of payments configured below."), + widget=forms.CheckboxInput( + attrs={ + 'data-display-dependency': '#id_payment_term_mode_0', + 'data-required-if': '#id_payment_term_mode_0' + }, + ), + ) + }, + 'payment_term_minutes': { + 'default': '30', + 'type': int, + 'form_class': forms.IntegerField, + 'serializer_class': serializers.IntegerField, + 'form_kwargs': dict( + label=_('Payment term in minutes'), + help_text=_("The number of minutes after placing an order the user has to pay to preserve their reservation. " + "Only use this if you exclusively offer real-time payment methods. Please note that for technical resons, " + "the actual time frame might be a few minutes longer before the order is marked as expired."), + validators=[MinValueValidator(0), + MaxValueValidator(1440)], + widget=forms.NumberInput( + attrs={ + 'data-display-dependency': '#id_payment_term_mode_1', + 'data-required-if': '#id_payment_term_mode_1' + }, + ), + ), + 'serializer_kwargs': dict( + validators=[MinValueValidator(0), + MaxValueValidator(1440)] + ) + }, + 'payment_term_last': { + 'default': None, + 'type': RelativeDateWrapper, + 'form_class': RelativeDateField, + 'serializer_class': SerializerRelativeDateField, + 'form_kwargs': dict( + label=_('Last date of payments'), + help_text=_("The last date any payments are accepted. This has precedence over the terms " + "configured above. If you use the event series feature and an order contains tickets for " + "multiple dates, the earliest date will be used."), ) }, 'payment_term_expire_automatically': { diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index e015b00fe6..201cb6a311 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -584,9 +584,11 @@ class CancelSettingsForm(SettingsForm): class PaymentSettingsForm(SettingsForm): auto_fields = [ + 'payment_term_mode', 'payment_term_days', - 'payment_term_last', 'payment_term_weekdays', + 'payment_term_minutes', + 'payment_term_last', 'payment_term_expire_automatically', 'payment_term_accept_late', 'payment_explanation', @@ -599,6 +601,18 @@ class PaymentSettingsForm(SettingsForm): "will set the tax rate and reverse charge rules, other settings of the tax rule are ignored.") ) + def clean_payment_term_days(self): + value = self.cleaned_data.get('payment_term_days') + if self.cleaned_data.get('payment_term_mode') == 'days' and value is None: + raise ValidationError(_("This field is required.")) + return value + + def clean_payment_term_minutes(self): + value = self.cleaned_data.get('payment_term_minutes') + if self.cleaned_data.get('payment_term_mode') == 'minutes' and value is None: + raise ValidationError(_("This field is required.")) + return value + def clean(self): data = super().clean() settings_dict = self.obj.settings.freeze() diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index 3e0bc1de0f..7f1903d5b7 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -59,9 +59,11 @@
diff --git a/src/pretix/control/views/pdf.py b/src/pretix/control/views/pdf.py index 4204963088..c82bb4c582 100644 --- a/src/pretix/control/views/pdf.py +++ b/src/pretix/control/views/pdf.py @@ -50,9 +50,9 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView): f = self.request.FILES.get('background') error = False if f.size > self.maxfilesize: - error = _('The uploaded PDF file is to large.') + error = _('The uploaded PDF file is too large.') if f.size < self.minfilesize: - error = _('The uploaded PDF file is to small.') + error = _('The uploaded PDF file is too small.') if mimetypes.guess_type(f.name)[0] not in self.accepted_formats: error = _('Please only upload PDF files.') # if there was an error, add error message to response_data and return diff --git a/src/pretix/helpers/templatetags/expiresformat.py b/src/pretix/helpers/templatetags/expiresformat.py new file mode 100644 index 0000000000..4863016e7e --- /dev/null +++ b/src/pretix/helpers/templatetags/expiresformat.py @@ -0,0 +1,10 @@ +from django import template + +from pretix.base.i18n import LazyExpiresDate + +register = template.Library() + + +@register.filter +def format_expires(order): + return LazyExpiresDate(order.expires.astimezone(order.event.timezone)) diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index bbe26b94ba..2a62c3d90e 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -3,6 +3,7 @@ {% load bootstrap3 %} {% load eventsignal %} {% load money %} +{% load expiresformat %} {% load eventurl %} {% block title %}{% trans "Order details" %}{% endblock %} {% block content %} @@ -66,7 +67,7 @@ {% blocktrans trimmed with total=pending_sum|money:request.event.currency %} A payment of {{ total }} is still pending for this order. {% endblocktrans %} - {% blocktrans trimmed with date=order.expires|date:"SHORT_DATE_FORMAT" %} + {% blocktrans trimmed with date=order|format_expires %} Please complete your payment before {{ date }} {% endblocktrans %} {% if last_payment %} diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 66a466d1b9..8c514a4340 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -78,6 +78,20 @@ def test_expiry_weekdays(event): assert order.expires.weekday() == 0 +@pytest.mark.django_db +def test_expiry_minutes(event): + today = now() + event.settings.set('payment_term_days', 5) + event.settings.set('payment_term_mode', 'minutes') + event.settings.set('payment_term_minutes', 30) + event.settings.set('payment_term_weekdays', False) + order = _create_order(event, email='dummy@example.org', positions=[], + now_dt=today, payment_provider=FreeOrderProvider(event), + locale='de')[0] + assert (order.expires - today).days == 0 + assert (order.expires - today).seconds == 30 * 60 + + @pytest.mark.django_db def test_expiry_last(event): today = now() diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index 79c34eb2b4..157548c50a 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -375,6 +375,8 @@ class EventsTest(SoupTest): self.get_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug)) self.post_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug), { 'payment_term_days': '2', + 'payment_term_minutes': '30', + 'payment_term_mode': 'days', 'tax_rate_default': tr19.pk, }) self.event1.settings.flush()