Compare commits

...
Author SHA1 Message Date
Raphael MichelandRichard Schreiber 8e57596b1c Update src/pretix/presale/templates/pretixpresale/event/index.html
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-09-10 10:05:46 +02:00
Raphael Michel fbaa5e9cbb Event index: Add subevent time and name to <title> (Z#23246174) 2026-09-10 09:27:17 +02:00
Ferdinand Malcher 6025c7942f Product pictures: fix CSS to always fit image to viewport (#6527) 2026-09-09 14:10:15 +02:00
dependabot[bot] 23a3412154 Update django-statici18n requirement from ==2.7.* to ==2.8.* (#6529)
Updates the requirements on [django-statici18n](https://github.com/zyegfryed/django-statici18n) to permit the latest version.
- [Changelog](https://github.com/zyegfryed/django-statici18n/blob/main/docs/changelog.rst)
- [Commits](https://github.com/zyegfryed/django-statici18n/compare/v2.7.0...v2.8.0)

---
updated-dependencies:
- dependency-name: django-statici18n
  dependency-version: 2.8.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-09 14:08:54 +02:00
Raphael MichelandRichard Schreiber 3635f6fb0c Widget: Add data-fix equivalent for attendee data (Z#23202714) (#6533)
* Widget: Add data-fix equivalent for attendee data (Z#23202714)

* Update src/pretix/presale/checkoutflow.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Update src/pretix/presale/checkoutflow.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

* Update src/pretix/presale/checkoutflow.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-09-09 14:06:41 +02:00
Kara Engelhardt 2aadfa2ce4 RRuleForm: allow selecting the fourth occurence of a weekday of a month (Z#23245893)
The fourth often is the same as the last, but not always. A weekday can appear five times in a month
2026-09-09 12:21:42 +02:00
Raphael Michel dd0d78242e PDF editor: Harden cached file access for background (Z#23242291) (#6446) 2026-09-09 11:13:29 +02:00
Raphael Michelandluelista 42c5de895c CachedFileField: Bind file access to session (Z#23242301) (#6447)
* CachedFileField: Bind file access to session (Z#23242301)

* Apply suggestion from @luelista

Co-authored-by: luelista <weller@rami.io>

---------

Co-authored-by: luelista <weller@rami.io>
2026-09-09 09:52:40 +02:00
Raphael Michelandluelista e91718e73b Password reset: Prevent race condition that uses token twice (Z#23242132) (#6448)
* Password reset: Prevent race condition that uses token twice

* Apply suggestion from @luelista

Co-authored-by: luelista <weller@rami.io>

---------

Co-authored-by: luelista <weller@rami.io>
2026-09-09 09:52:38 +02:00
Martin GrossandRaphael Michel 2c15d8b074 Event Settings: Make contact_url and imprint_url I18nURLField (Z#23238623) (#6506)
* Event Settings: Make contact_url and imprint_url I18nURLField (Z#23238623)

* isort

* Update doc/api/resources/events.rst

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-09-09 09:14:37 +02:00
Raphael MichelandLukas Bockstaller edb4069e18 Payment step: Allow to postpone payment choice on some sales channels (#6516)
* Payment step: Allow to postpone payment choice on some sales channels

* Add tests

* handle payment provider (de-)selection and partial payments (#6526)

---------

Co-authored-by: Lukas Bockstaller <bockstaller@pretix.eu>
2026-09-08 09:32:15 +02:00
23 changed files with 400 additions and 92 deletions
+9 -7
View File
@@ -566,7 +566,7 @@ organizer level.
Content-Type: application/json
{
"imprint_url": "https://pretix.eu",
"region": "DE",
}
@@ -579,12 +579,14 @@ organizer level.
Content-Type: application/json
{
"imprint_url":
"region":
{
"value": "https://pretix.eu",
"label": "Imprint URL",
"value": "DE",
"label": "Region",
"readonly": false,
"help_text": "This should point e.g. to a part of your website that has your contact details and legal information."
"help_text": "Will be used to determine date and time formatting as well as default country for customer
addresses and phone numbers. For formatting, this takes less priority than the language and
is therefore mostly relevant for languages used in different regions globally (like English)."
}
},
@@ -620,7 +622,7 @@ organizer level.
Content-Type: application/json
{
"imprint_url": "https://example.org/imprint/"
"region": "DE"
}
**Example response**:
@@ -632,7 +634,7 @@ organizer level.
Content-Type: application/json
{
"imprint_url": "https://example.org/imprint/",
"region": "DE",
}
+1 -1
View File
@@ -56,7 +56,7 @@ dependencies = [
"django-querytagger==0.0.3",
"django-redis==7.0.*",
"django-scopes==2.1.*",
"django-statici18n==2.7.*",
"django-statici18n==2.8.*",
"djangorestframework==3.17.*",
"dnspython==2.8.*",
"drf_ujson2==1.7.*",
+7 -40
View File
@@ -626,47 +626,14 @@ class Order(LockModel, LoggedModel):
self.save(update_fields=['last_modified'])
def set_expires(self, now_dt=None, subevents=None):
now_dt = now_dt or now()
tz = ZoneInfo(self.event.settings.timezone)
from pretix.base.services.payment import compute_payment_deadline
sales_channel_suffix = "_" + self.sales_channel.identifier.replace(".", "_")
if not (mode := self.event.settings.get(f'payment_term_mode{sales_channel_suffix}')):
mode = self.event.settings.get('payment_term_mode')
sales_channel_suffix = ""
if mode == 'days':
exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get(f'payment_term_days{sales_channel_suffix}', 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(f'payment_term_minutes{sales_channel_suffix}', as_type=int))
else:
raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode))
self.expires = exp_by_date
term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last:
if self.event.has_subevents and subevents:
terms = [
term_last.datetime(se).date()
for se in subevents
]
if not terms:
return
term_last = min(terms)
else:
term_last = term_last.datetime(self.event).date()
term_last = make_aware(datetime.combine(
term_last,
time(hour=23, minute=59, second=59)
), tz)
if term_last < self.expires:
self.expires = term_last
self.expires = compute_payment_deadline(
event=self.event,
sales_channel=self.sales_channel,
now_dt=now_dt,
subevents=subevents,
)
@cached_property
def tax_total(self):
+1
View File
@@ -1605,6 +1605,7 @@ def add_payment_to_cart_session(cart_session, provider, min_value: Decimal=None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
cart_session['payments_postpone'] = False
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
+18 -3
View File
@@ -961,7 +961,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False):
meta_info: dict, event: Event, sales_channel: SalesChannel, require_approval=False):
fees = []
# Pre-rounding, pre-fee total is used for fee calculation
total = sum([c.gross_price_before_rounding for c in positions])
@@ -1021,7 +1021,14 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
payments_assigned += to_pay
p['payment_amount'] = to_pay
if total != payments_assigned and not require_approval:
allow_postponed_payment = (
require_approval or
(
sales_channel.identifier in event.settings.payment_choice_postpone_allowed_channels and not payment_requests
)
)
if total != payments_assigned and not allow_postponed_payment:
raise OrderError(_("The selected payment methods do not cover the total balance."))
return fees
@@ -1043,7 +1050,15 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
# Final calculation of fees, also performs final rounding
try:
fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
fees = _apply_rounding_and_fees(
positions,
payment_requests,
address,
meta_info,
event,
sales_channel=sales_channel,
require_approval=require_approval
)
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
+76
View File
@@ -0,0 +1,76 @@
#
# 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 datetime, time, timedelta
from zoneinfo import ZoneInfo
from django.utils.timezone import make_aware, now
from pretix.base.models import Event, SalesChannel
from pretix.base.reldate import RelativeDateWrapper
def compute_payment_deadline(event: Event, sales_channel: SalesChannel, now_dt=None, subevents=None) -> datetime:
now_dt = now_dt or now()
tz = ZoneInfo(event.settings.timezone)
sales_channel_suffix = "_" + sales_channel.identifier.replace(".", "_")
if not (mode := event.settings.get(f'payment_term_mode{sales_channel_suffix}')):
mode = event.settings.get('payment_term_mode')
sales_channel_suffix = ""
if mode == 'days':
exp_by_date = now_dt.astimezone(tz) + timedelta(
days=event.settings.get(f'payment_term_days{sales_channel_suffix}', as_type=int))
exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0)
if 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=event.settings.get(f'payment_term_minutes{sales_channel_suffix}', as_type=int))
else:
raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode))
expires = exp_by_date
term_last = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if term_last:
if event.has_subevents and subevents:
terms = [
term_last.datetime(se).date()
for se in subevents
]
if not terms:
return expires
term_last = min(terms)
else:
term_last = term_last.datetime(event).date()
term_last = make_aware(datetime.combine(
term_last,
time(hour=23, minute=59, second=59)
), tz)
if term_last < expires:
return term_last
return expires
+23 -8
View File
@@ -1159,6 +1159,19 @@ DEFAULTS = {
"configured above."),
)
},
'payment_choice_postpone_allowed_channels': {
'default': [],
'type': list,
'form_class': forms.MultipleChoiceField,
'form_kwargs': dict(
label=_('Allow postponed payment choice for sales channels'),
help_text=_("If postponed payment is allowed on a sales channel, customers can complete their order without "
"selecting a payment method. This is useful whenever orders are not created by the same "
"person who is making the payment."),
widget=forms.CheckboxSelectMultiple,
choices=[],
)
},
'presale_start_show_date': {
'default': 'True',
'type': bool,
@@ -2301,25 +2314,27 @@ DEFAULTS = {
},
'contact_url': {
'default': None,
'type': str,
'serializer_class': serializers.URLField,
'form_class': forms.URLField,
'type': LazyI18nString,
'form_class': I18nURLFormField,
'form_kwargs': dict(
label=_("Contact URL"),
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
)
"Please note that you still need to add a contact email address that will be shared with all emails you send."),
widget=I18nTextInput,
),
'serializer_class': I18nURLField,
},
'imprint_url': {
'default': None,
'type': str,
'form_class': forms.URLField,
'type': LazyI18nString,
'form_class': I18nURLFormField,
'form_kwargs': dict(
label=_("Imprint URL"),
help_text=_("This should point e.g. to a part of your website that has your contact details and legal "
"information."),
widget=I18nTextInput,
),
'serializer_class': serializers.URLField,
'serializer_class': I18nURLField,
},
'privacy_url': {
'default': None,
+12 -1
View File
@@ -172,7 +172,9 @@ class CachedFileInput(forms.ClearableFileInput):
from ...base.models import CachedFile
v = super().value_from_datadict(data, files, name)
if v is None and data.get(name + '-cachedfile'): # An explicit "[x] clear" would be False, not None
return CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
v = CachedFile.objects.filter(id=data[name + '-cachedfile']).first()
if not v.allowed_for_session(self.request):
v = None
return v
def get_context(self, name, value, attrs):
@@ -244,6 +246,11 @@ class ExtFileField(ExtValidationMixin, SizeFileField):
class CachedFileField(ExtFileField):
widget = CachedFileInput
def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super().__init__(*args, **kwargs)
self.widget.request = self.request
def to_python(self, data):
from ...base.models import CachedFile
@@ -271,6 +278,8 @@ class CachedFileField(ExtFileField):
filename=data.name,
type=data.content_type,
)
if self.request:
cf.bind_to_session(self.request) # no salt because we want direct web access
cf.file.save(data.name, data.file)
cf.save()
data._uploaded_to = cf
@@ -294,6 +303,8 @@ class CachedFileField(ExtFileField):
filename=data.name,
type=data.content_type,
)
if self.request:
cf.bind_to_session(self.request) # no salt because we want direct web access
cf.file.save(data.name, data.file)
cf.save()
data._uploaded_to = cf
+8 -1
View File
@@ -855,14 +855,21 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
'payment_term_accept_late',
'payment_pending_hidden',
'payment_explanation',
'payment_choice_postpone_allowed_channels',
'tax_rule_payment',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
channels = list(self.obj.organizer.sales_channels.all())
self.fields['payment_choice_postpone_allowed_channels'].choices = [
(c.identifier, c.label) for c in channels
if c.type_instance.payment_restrictions_supported
]
self.term_channel_fields = {}
for c in self.obj.organizer.sales_channels.all():
for c in channels:
if c.type_instance.payment_restrictions_supported and c.identifier != "web":
# At the moment, it seems sufficient to allow this for the same channel types as other payment settings
# We can always introduce more flags later if needed
+2
View File
@@ -87,6 +87,7 @@ class RRuleForm(forms.Form):
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('4', pgettext_lazy('rrule', 'fourth')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
@@ -134,6 +135,7 @@ class RRuleForm(forms.Form):
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('4', pgettext_lazy('rrule', 'fourth')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
@@ -109,6 +109,7 @@
{% bootstrap_form_errors form layout="control" %}
{% bootstrap_field form.tax_rule_payment layout="control" %}
{% bootstrap_field form.payment_explanation layout="control" %}
{% bootstrap_field form.payment_choice_postpone_allowed_channels layout="control" %}
</fieldset>
</div>
{% if "event.settings.payment:write" in request.eventpermset %}
+12 -9
View File
@@ -64,6 +64,7 @@ from pretix.base.forms.auth import (
)
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
from pretix.helpers import OF_SELF
from pretix.helpers.http import get_client_ip, redirect_to_url
from pretix.helpers.ratelimit import rate_limit, rate_limit_reset
from pretix.helpers.security import handle_login_source, session_login
@@ -395,15 +396,17 @@ class Recover(TemplateView):
def post(self, request, *args, **kwargs):
if self.form.is_valid():
try:
user = User.objects.get(id=self.request.GET.get('id'), auth_backend='native')
except User.DoesNotExist:
return self.invalid('unknownuser')
if not default_token_generator.check_token(user, self.request.GET.get('token')):
return self.invalid('invalid')
user.set_password(self.form.cleaned_data['password'])
user.needs_password_change = False
user.save()
with transaction.atomic():
# Check token in transaction to prevent race condition
try:
user = User.objects.select_for_update(of=OF_SELF).get(id=self.request.GET.get('id'), auth_backend='native')
except User.DoesNotExist:
return self.invalid('unknownuser')
if not default_token_generator.check_token(user, self.request.GET.get('token')):
return self.invalid('invalid')
user.set_password(self.form.cleaned_data['password'])
user.needs_password_change = False
user.save()
messages.success(request, _('You can now login using your new password.'))
user.log_action('pretix.control.auth.user.forgot_password.recovered')
+5
View File
@@ -27,6 +27,7 @@ from decimal import Decimal
from io import BytesIO
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.core.files import File
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
@@ -193,6 +194,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
c.expires = now() + timedelta(days=7)
c.date = now()
c.filename = 'background_preview.pdf'
c.bind_to_session(request, "ticketoutput-pdf-background")
c.type = 'application/pdf'
c.save()
c.file.save('empty.pdf', ContentFile(buffer.read()))
@@ -218,6 +220,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
c.expires = now() + timedelta(days=7)
c.date = now()
c.filename = 'background_preview.pdf'
c.bind_to_session(request, "ticketoutput-pdf-background")
c.type = 'application/pdf'
c.file = fileobj
c.save()
@@ -303,5 +306,7 @@ class FontsCSSView(TemplateView):
class PdfView(TemplateView):
def get(self, request, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
if not cf.allowed_for_session(request, "ticketoutput-pdf-background"):
raise PermissionDenied()
resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf')
return resp
+11 -8
View File
@@ -56,18 +56,11 @@ from pretix.base.services.placeholders import FormPlaceholderMixin # noqa
class BaseMailForm(FormPlaceholderMixin, forms.Form):
subject = forms.CharField(label=_("Subject"))
message = forms.CharField(label=_("Message"))
attachment = CachedFileField(
label=_("Attachment"),
required=False,
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT,
help_text=_('Sending an attachment increases the chance of your email not arriving or being sorted into spam folders. We recommend only using PDFs '
'of no more than 2 MB in size.'),
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT
)
def __init__(self, *args, **kwargs):
event = self.event = kwargs.pop('event')
context_parameters = kwargs.pop('context_parameters')
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['subject'] = I18nFormField(
label=_('Subject'),
@@ -79,6 +72,16 @@ class BaseMailForm(FormPlaceholderMixin, forms.Form):
widget=I18nMarkdownTextarea, required=True,
locales=event.settings.get('locales'),
)
self.fields['attachment'] = CachedFileField(
label=_("Attachment"),
required=False,
ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_EMAIL_ATTACHMENT,
help_text=_(
'Sending an attachment increases the chance of your email not arriving or being sorted into spam folders. We recommend only using PDFs '
'of no more than 2 MB in size.'),
max_size=settings.FILE_UPLOAD_MAX_SIZE_EMAIL_ATTACHMENT,
request=request,
)
self._set_field_placeholders('subject', context_parameters, rich=False)
self._set_field_placeholders('message', context_parameters, rich=True)
+1
View File
@@ -157,6 +157,7 @@ class BaseSenderView(EventPermissionRequiredMixin, FormView):
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['context_parameters'] = self.context_parameters
kwargs['request'] = self.request
if 'from_log' in self.request.GET:
try:
from_log_id = self.request.GET.get('from_log')
+54 -3
View File
@@ -35,6 +35,7 @@ import copy
import inspect
import uuid
from collections import defaultdict
from datetime import time
from decimal import Decimal
from django import forms
@@ -52,6 +53,7 @@ from django.shortcuts import redirect
from django.utils import translation
from django.utils.functional import cached_property
from django.utils.html import conditional_escape
from django.utils.timezone import now
from django.utils.translation import (
get_language, gettext_lazy as _, pgettext_lazy,
)
@@ -71,6 +73,7 @@ from pretix.base.services.cart import (
from pretix.base.services.cross_selling import CrossSellingService
from pretix.base.services.memberships import validate_memberships_in_order
from pretix.base.services.orders import perform_order
from pretix.base.services.payment import compute_payment_deadline
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import EventTask
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -870,6 +873,28 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep):
'attendee_name_parts': d
})
wd = self.cart_session.get('widget_data', {})
if wd.get('attendee-fix', '') == 'true':
for k, v in wd.items():
if v and k.startswith('attendee-name'):
o.append({
'attendee_name_parts': {
'disabled': True,
}
})
elif v and k.startswith('email'):
o.append({
'attendee_email': {
'disabled': True,
}
})
elif v and k.startswith('question-'):
o.append({
k[9:].upper(): {
'disabled': True,
}
})
return o
@cached_property
@@ -1344,6 +1369,11 @@ class PaymentStep(CartMixin, TemplateFlowStep):
self.request = request
self.request.pci_dss_payment_page = True
if "postpone" in request.POST and self._allow_postpone:
self.cart_session['payments_postpone'] = True
self.cart_session['payments'] = []
return redirect_to_url(self.get_next_url(request))
if "remove_payment" in request.POST:
self._remove_payment(request.POST["remove_payment"])
return redirect_to_url(self.get_step_url(request))
@@ -1432,20 +1462,41 @@ class PaymentStep(CartMixin, TemplateFlowStep):
ctx['providers'] = self.provider_forms
ctx['show_fees'] = any(p['fee'] for p in self.provider_forms)
if len(self.provider_forms) == 1:
ctx['selected'] = self.provider_forms[0]['provider'].identifier
elif 'payment' in self.request.POST:
if 'payment' in self.request.POST:
ctx['selected'] = self.request.POST['payment']
elif self.cart_session.get('payments_postpone') and self._allow_postpone:
ctx['selected'] = ''
elif len(self.provider_forms) == 1:
ctx['selected'] = self.provider_forms[0]['provider'].identifier
elif self.single_use_payment:
ctx['selected'] = self.single_use_payment['provider']
else:
ctx['selected'] = ''
ctx['allow_postpone'] = self._allow_postpone
if self._allow_postpone:
now_dt = now()
ctx['payment_deadline'] = compute_payment_deadline(
event=self.request.event,
sales_channel=self.request.sales_channel,
subevents={p.subevent for p in ctx['cart']['raw']},
now_dt=now_dt,
)
if ctx['payment_deadline'].time() != time(hour=23, minute=59, second=59):
ctx['payment_deadline_minutes'] = int((ctx['payment_deadline'] - now_dt).total_seconds() // 60)
return ctx
@cached_property
def _allow_postpone(self):
return self.request.sales_channel.identifier in self.request.event.settings.payment_choice_postpone_allowed_channels
def _is_allowed(self, prov, request):
return prov.is_allowed(request, total=self._total_order_value)
def is_completed(self, request, warn=False):
if self.cart_session.get('payments_postpone') and self._allow_postpone:
return True
if not self.cart_session.get('payments'):
if warn:
messages.error(request, _('Please select a payment method to proceed.'))
@@ -128,6 +128,35 @@
{% endif %}
</div>
{% endif %}
{% if allow_postpone %}
<div class="panel panel-default">
<div class="panel-body row">
<div class="col-md-9 col-xs-12">
{% trans "Not sure yet? You can complete your order first and then select a payment method later." %}
<br>
<span class="text-muted">
{% if current_payments %}
{% trans "To do so, please first remove the payment methods you already selected above." %}
{% elif payment_deadline_minutes %}
{% blocktrans trimmed with minutes=payment_deadline_minutes %}
Your payment needs to be completed within {{ minutes }} minutes.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed with deadline=payment_deadline|date:"SHORT_DATE_FORMAT" %}
Your payment needs to be completed by {{ deadline }}.
{% endblocktrans %}
{% endif %}
</span>
</div>
<div class="col-md-3 col-xs-12 text-right flip">
<button name="postpone" value="on" class="btn btn-primary"
{% if current_payments %}disabled{% endif %}>
{% trans "Proceed without selection" %}
</button>
</div>
</div>
</div>
{% endif %}
<div class="row checkout-button-row">
<div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg"
@@ -23,7 +23,11 @@
{% endblocktrans %} ::
{% endif %}
{% elif subevent %}
{{ subevent.get_date_range_display }} ::
{% if subevent.name|upper != event.name|upper %}
{# The |upper is a trick to force LazyI18nString→str conversion before comparison #}
{{ subevent.name }} ::
{% endif %}
{{ subevent.get_date_range_display_with_times }} ::
{% endif %}
{% endblock %}
+6
View File
@@ -54,6 +54,7 @@ from pretix.base.models import Customer, InvoiceAddress, Order, OrderPosition
from pretix.base.services.mail import mail
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import customer_created, customer_signed_in
from pretix.helpers import OF_SELF
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.http import redirect_to_url
from pretix.multidomain.models import KnownDomain
@@ -280,6 +281,11 @@ class SetPasswordView(FormView):
def form_valid(self, form):
with transaction.atomic():
# Re-check token in transaction to prevent race condition
self.customer = Customer.objects.select_for_update(of=OF_SELF).get(pk=self.customer.pk)
if not TokenGenerator().check_token(self.customer, self.request.GET.get('token', '')):
return HttpResponseRedirect(self.get_success_url())
self.customer.set_password(form.cleaned_data['password'])
self.customer.is_verified = True
self.customer.save()
@@ -170,13 +170,14 @@ body.has-modal-dialog .container, body.has-modal-dialog #wrapper {
#lightbox-dialog {
width: fit-content;
max-width: 80%;
min-width: 24em;
min-width: calc(min(24em, 90%));
.modal-card-content {
padding: 2.5em;
}
img {
max-width: 100%;
max-height: calc(100dvh - 60px - 5em - 5em);
}
button {
+21 -7
View File
@@ -1434,8 +1434,12 @@ def test_get_event_settings(token_client, organizer, event):
'/api/v1/organizers/{}/events/{}/settings/'.format(organizer.slug, event.slug),
)
assert resp.status_code == 200
assert resp.data['imprint_url'] == "https://example.org"
assert resp.data['contact_url'] == "https://example.org/contact"
assert resp.data['imprint_url'] == {
"en": "https://example.org",
}
assert resp.data['contact_url'] == {
"en": "https://example.org/contact",
}
assert resp.data['seating_allow_blocked_seats_for_channel'] == []
resp = token_client.get(
@@ -1443,7 +1447,9 @@ def test_get_event_settings(token_client, organizer, event):
)
assert resp.status_code == 200
assert resp.data['imprint_url'] == {
"value": "https://example.org",
"value": {
"en": "https://example.org",
},
"label": "Imprint URL",
"help_text": "This should point e.g. to a part of your website that has your contact details and legal "
"information.",
@@ -1478,8 +1484,12 @@ def test_patch_event_settings(token_client, organizer, event, team):
format='json'
)
assert resp.status_code == 200
assert resp.data['contact_url'] == "https://example.com/contact"
assert resp.data['imprint_url'] == "https://example.com"
assert resp.data['contact_url'] == {
"en": "https://example.com/contact",
}
assert resp.data['imprint_url'] == {
"en": "https://example.com",
}
assert resp.data['seating_allow_blocked_seats_for_channel'] == ['web']
assert not resp.data['reusable_media_active']
event.settings.flush()
@@ -1542,8 +1552,12 @@ def test_patch_event_settings(token_client, organizer, event, team):
format='json'
)
assert resp.status_code == 200
assert resp.data['contact_url'] == "https://example.org/contact"
assert resp.data['imprint_url'] == "https://example.org"
assert resp.data['contact_url'] == {
"en": "https://example.org/contact",
}
assert resp.data['imprint_url'] == {
"en": "https://example.org",
}
event.settings.flush()
assert event.settings.contact_url == 'https://example.org/contact'
assert event.settings.imprint_url == 'https://example.org'
+5 -2
View File
@@ -25,6 +25,7 @@ from datetime import datetime
import pytest
from django.core.files.base import ContentFile
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from tests.const import SAMPLE_PNG
TEST_ORGANIZER_RES = {
@@ -165,9 +166,11 @@ def test_patch_settings(token_client, organizer):
format='json'
)
assert resp.status_code == 200
assert resp.data['contact_url'] == 'https://example.org/contact'
assert resp.data['contact_url'] == {
'en': 'https://example.org/contact',
}
organizer.settings.flush()
assert organizer.settings.contact_url == 'https://example.org/contact'
assert organizer.settings.contact_url == LazyI18nString('https://example.org/contact')
resp = token_client.patch(
'/api/v1/organizers/{}/settings/'.format(organizer.slug),
+91
View File
@@ -2372,6 +2372,97 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
assert p2.fee.value == Decimal("0.46")
assert o.total == Decimal("25.76")
def test_payment_postpone_not_allowed(self):
self.event.settings.set('payment_banktransfer__enabled', True)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'postpone': 'on',
}, follow=False)
assert 'Please select' in response.content.decode()
def test_payment_postpone_allowed(self):
self.event.settings.set('payment_banktransfer__enabled', True)
self.event.settings.payment_choice_postpone_allowed_channels = ['web']
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'postpone': 'on',
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select(".thank-you")), 1)
with scopes_disabled():
o = Order.objects.last()
assert not o.payments.exists()
def test_payment_postpone_cleared_on_selection(self):
self.event.settings.set('payment_banktransfer__enabled', True)
self.event.settings.payment_choice_postpone_allowed_channels = ['web']
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'postpone': 'on',
}, follow=False)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert self.client.session['carts'][self.session_key].get('payments_postpone')
# The only available provider must not be preselected while the choice is postponed
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('input[name="payment"]')), 1)
self.assertEqual(len(doc.select('input[name="payment"][checked]')), 0)
# Selecting a payment method takes the order out of the postponed state again
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'banktransfer',
}, follow=False)
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
assert not self.client.session['carts'][self.session_key].get('payments_postpone')
def test_payment_postpone_disabled_with_partial_payment(self):
self.event.settings.set('payment_banktransfer__enabled', True)
self.event.settings.payment_choice_postpone_allowed_channels = ['web']
gc = self.orga.issued_gift_cards.create(currency="EUR")
gc.transactions.create(value=20, acceptor=self.orga)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('button[name="postpone"]')), 1)
self.assertEqual(len(doc.select('button[name="postpone"][disabled]')), 0)
# Apply a gift card that only covers part of the total
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'giftcard',
'payment_giftcard-code': gc.secret,
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
# Postponing would silently drop the gift card, so it is no longer offered
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertEqual(len(doc.select('button[name="postpone"][disabled]')), 1)
def test_premature_confirm(self):
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),