upstream/v2026.1.0 #12

Merged
simon merged 241 commits from upstream/v2026.1.0 into master 2026-02-03 21:56:32 +00:00
7 changed files with 329 additions and 31 deletions
Showing only changes of commit 0fc2d6134f - Show all commits

View File

@@ -443,6 +443,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts',
'customer_accounts_native',
'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'imprint_url',

View File

@@ -180,6 +180,19 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
)
},
'customer_accounts_require_login_for_order_access': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require login to access order confirmation pages"),
help_text=_("If enabled, users who were logged in at the time of purchase must also log in to access their order information. "
"If a customer account is created while placing an order, the restriction only becomes active after the customer "
"account is activated."),
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
)
},
'customer_accounts_link_by_email': {
'default': 'False',
'type': bool,

View File

@@ -474,6 +474,7 @@ class OrganizerSettingsForm(SettingsForm):
'customer_accounts',
'customer_accounts_native',
'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed',
'contact_mail',
'imprint_url',

View File

@@ -132,6 +132,7 @@
<legend>{% trans "Customer accounts" %}</legend>
{% bootstrap_field sform.customer_accounts layout="control" %}
{% bootstrap_field sform.customer_accounts_native layout="control" %}
{% bootstrap_field sform.customer_accounts_require_login_for_order_access layout="control" %}
{% bootstrap_field sform.customer_accounts_link_by_email layout="control" %}
{% bootstrap_field sform.name_scheme layout="control" %}
{% bootstrap_field sform.name_scheme_titles layout="control" %}

View File

@@ -42,6 +42,7 @@ import os
import re
from collections import OrderedDict, defaultdict
from decimal import Decimal
from urllib.parse import quote
from django import forms
from django.conf import settings
@@ -103,13 +104,55 @@ logger = logging.getLogger(__name__)
class OrderDetailMixin(NoSearchIndexViewMixin):
def _allow_anonymous_access(self):
return not (self.request.organizer.settings.customer_accounts and
self.request.organizer.settings.customer_accounts_require_login_for_order_access)
def verify_order_access(self):
o = self.order
if o is None:
raise Http404(_('Unknown order code or not authorized to access this order.'))
if o is False:
login_url = eventreverse(self.request.organizer, 'presale:organizer.customer.login', kwargs={})
if hasattr(self.request, "event_domain") and self.request.event_domain:
next_url = quote(self.request.scheme + "://" + self.request.get_host() + self.request.get_full_path())
return redirect_to_url(f'{login_url}?next={next_url}&request_cross_domain_customer_auth=true')
else:
next_url = quote(self.request.get_full_path())
return redirect_to_url(f'{login_url}?next={next_url}')
return None
@cached_property
def order(self):
"""
Returns the order object when access is permitted, returns `False` when the
order exists but requires authentication, and returns `None` when the order
does not exist or access is denied entirely.
"""
try:
return self.request.event.orders.filter().select_related('event').get_with_secret_check(
order = self.request.event.orders.filter().select_related('event').get_with_secret_check(
code=self.kwargs['order'], received_secret=self.kwargs['secret'], tag=None,
)
if has_event_access_permission(self.request, 'can_view_orders'):
return order
if order.customer is None or not order.customer.is_verified or self._allow_anonymous_access():
return order
if not self.request.customer:
return False
if order.customer_id == self.request.customer.pk:
return order
return None
except Order.DoesNotExist:
return None
@@ -119,6 +162,13 @@ class OrderDetailMixin(NoSearchIndexViewMixin):
'secret': self.order.secret
})
def dispatch(self, request, *args, **kwargs):
resp = self.verify_order_access()
if resp:
return resp
return super().dispatch(request, *args, **kwargs)
class OrderPositionDetailMixin(NoSearchIndexViewMixin):
@cached_property
@@ -157,8 +207,6 @@ class OrderPositionDetailMixin(NoSearchIndexViewMixin):
@method_decorator(xframe_options_exempt, 'dispatch')
class OrderOpen(EventViewMixin, OrderDetailMixin, View):
def get(self, request, *args, **kwargs):
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
if self.order.check_email_confirm_secret(kwargs.get('hash')) and not self.order.email_known_to_work:
self.order.log_action('pretix.event.order.contact.confirmed')
self.order.email_known_to_work = True
@@ -239,8 +287,6 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
def get(self, request, *args, **kwargs):
self.kwargs = kwargs
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
if self.order.status == Order.STATUS_PENDING:
payment_to_complete = self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_CREATED, process_initiated=False).first()
if payment_to_complete:
@@ -360,8 +406,11 @@ class OrderPaymentStart(EventViewMixin, OrderDetailMixin, TemplateView):
def dispatch(self, request, *args, **kwargs):
self.request = request
self.request.pci_dss_payment_page = True
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
resp = self.verify_order_access()
if resp:
return resp
if (self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED)
or self.payment.state != OrderPayment.PAYMENT_STATE_CREATED
or not self.payment.payment_provider.is_enabled
@@ -428,8 +477,11 @@ class OrderPaymentConfirm(EventViewMixin, OrderDetailMixin, TemplateView):
def dispatch(self, request, *args, **kwargs):
self.request = request
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
resp = self.verify_order_access()
if resp:
return resp
if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED or self.order._can_be_paid() is not True:
messages.error(request, _('The payment for this order cannot be continued.'))
return redirect(self.get_order_url())
@@ -495,8 +547,11 @@ class OrderPaymentComplete(EventViewMixin, OrderDetailMixin, View):
def dispatch(self, request, *args, **kwargs):
self.request = request
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
resp = self.verify_order_access()
if resp:
return resp
if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED or self.order._can_be_paid() is not True:
messages.error(request, _('The payment for this order cannot be continued.'))
return redirect(self.get_order_url())
@@ -541,8 +596,11 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView):
def dispatch(self, request, *args, **kwargs):
self.request = request
self.request.pci_dss_payment_page = True
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
resp = self.verify_order_access()
if resp:
return resp
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) or self.order._can_be_paid() is not True:
messages.error(request, _('The payment method for this order cannot be changed.'))
return redirect(self.get_order_url())
@@ -727,8 +785,6 @@ class OrderInvoiceCreate(EventViewMixin, OrderDetailMixin, View):
def dispatch(self, request, *args, **kwargs):
self.request = request
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
@@ -860,8 +916,11 @@ class OrderModify(EventViewMixin, OrderDetailMixin, OrderQuestionsViewMixin, Tem
def dispatch(self, request, *args, **kwargs):
self.request = request
self.kwargs = kwargs
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
resp = self.verify_order_access()
if resp:
return resp
if not self.order.can_modify_answers:
messages.error(request, _('You cannot modify this order'))
return redirect(self.get_order_url())
@@ -947,8 +1006,11 @@ class OrderCancel(EventViewMixin, OrderDetailMixin, TemplateView):
def dispatch(self, request, *args, **kwargs):
self.request = request
self.kwargs = kwargs
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
resp = self.verify_order_access()
if resp:
return resp
if not self.order.user_cancel_allowed:
messages.error(request, _('You cannot cancel this order.'))
return redirect(self.get_order_url())
@@ -995,14 +1057,7 @@ class OrderCancelDo(EventViewMixin, OrderDetailMixin, AsyncAction, View):
def get_error_url(self):
return self.get_order_url()
def get(self, request, *args, **kwargs):
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
if not self.order.user_cancel_allowed:
messages.error(request, _('You cannot cancel this order.'))
return redirect(self.get_order_url())
@@ -1293,9 +1348,6 @@ class OrderPositionDownload(OrderDownloadMixin, EventViewMixin, OrderPositionDet
class InvoiceDownload(EventViewMixin, OrderDetailMixin, View):
def get(self, request, *args, **kwargs):
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
try:
invoice = Invoice.objects.get(
event=self.request.event,
@@ -1688,8 +1740,11 @@ class OrderChange(OrderChangeMixin, EventViewMixin, OrderDetailMixin, TemplateVi
def dispatch(self, request, *args, **kwargs):
self.request = request
self.kwargs = kwargs
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
resp = self.verify_order_access()
if resp:
return resp
if not self.order.user_change_allowed:
messages.error(request, _('You cannot change this order.'))
return redirect(self.get_order_url())

View File

@@ -2611,3 +2611,11 @@ def test_approve_cancellation_request(client, env):
doc = BeautifulSoup(response.content.decode(), "lxml")
assert doc.select('input[name=refund-new-giftcard]')[0]['value'] == '10.00'
assert not env[2].cancellation_requests.exists()
@pytest.mark.django_db
def test_view_as_user(client, env):
client.login(email='dummy@dummy.dummy', password='dummy')
response = client.get('/%s/%s/order/%s/%s/' % (env[0].organizer.slug, env[0].slug, env[2].code, env[2].secret))
assert response.status_code == 200
assert env[2].code in response.content.decode()

View File

@@ -1738,3 +1738,222 @@ class OrdersTest(BaseOrdersTest):
self.order.secret, a.pk, match.group(1))
)
assert response.status_code == 404
def test_require_login_for_order_access_disabled_unauth(self):
self.orga.settings.customer_accounts = True
with scopes_disabled():
customer = self.orga.customers.create(email='john@example.org', is_verified=True)
customer.set_password("foo")
customer.save()
self.order.customer = customer
self.order.save()
self.orga.settings.customer_accounts_require_login_for_order_access = False
response = self.client.get(
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)
)
assert response.status_code == 200
doc = BeautifulSoup(response.content.decode(), "lxml")
assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".order-details")[0].text.lower()
assert "Peter" in response.content.decode()
assert "Lukas" not in response.content.decode()
response = self.client.get(
'/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
self.ticket_pos.positionid, self.ticket_pos.web_secret)
)
assert response.status_code == 200
doc = BeautifulSoup(response.content.decode(), "lxml")
assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".order-details")[0].text.lower()
assert "Peter" in response.content.decode()
assert "Lukas" not in response.content.decode()
def test_require_login_for_order_access_enabled_unauth(self):
self.orga.settings.customer_accounts = True
with scopes_disabled():
customer = self.orga.customers.create(email='john@example.org', is_verified=True)
customer.set_password("foo")
customer.save()
self.order.customer = customer
self.order.save()
self.orga.settings.customer_accounts_require_login_for_order_access = True
response = self.client.get(
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)
)
assert response.status_code == 302
response = self.client.get(
'/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
self.ticket_pos.positionid, self.ticket_pos.web_secret)
)
assert response.status_code == 200
doc = BeautifulSoup(response.content.decode(), "lxml")
assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".order-details")[0].text.lower()
assert "Peter" in response.content.decode()
assert "Lukas" not in response.content.decode()
def test_require_login_for_order_access_disabled_auth(self):
self.orga.settings.customer_accounts = True
with scopes_disabled():
customer = self.orga.customers.create(email='john@example.org', is_verified=True)
customer.set_password("foo")
customer.save()
self.order.customer = customer
self.order.save()
self.orga.settings.customer_accounts_require_login_for_order_access = False
response = self.client.post('/%s/account/login' % (self.orga.slug), {
'email': 'john@example.org',
'password': 'foo',
})
assert response.status_code == 302
response = self.client.get(
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)
)
assert response.status_code == 200
doc = BeautifulSoup(response.content.decode(), "lxml")
assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".order-details")[0].text.lower()
assert "Peter" in response.content.decode()
assert "Lukas" not in response.content.decode()
response = self.client.get(
'/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
self.ticket_pos.positionid, self.ticket_pos.web_secret)
)
assert response.status_code == 200
doc = BeautifulSoup(response.content.decode(), "lxml")
assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".order-details")[0].text.lower()
assert "Peter" in response.content.decode()
assert "Lukas" not in response.content.decode()
def test_require_login_for_order_access_enabled_auth(self):
self.orga.settings.customer_accounts = True
with scopes_disabled():
customer = self.orga.customers.create(email='john@example.org', is_verified=True)
customer.set_password("foo")
customer.save()
self.order.customer = customer
self.order.save()
self.orga.settings.customer_accounts_require_login_for_order_access = True
response = self.client.post('/%s/account/login' % (self.orga.slug), {
'email': 'john@example.org',
'password': 'foo',
})
assert response.status_code == 302
response = self.client.get(
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)
)
assert response.status_code == 200
doc = BeautifulSoup(response.content.decode(), "lxml")
assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".order-details")[0].text.lower()
assert "Peter" in response.content.decode()
assert "Lukas" not in response.content.decode()
response = self.client.get(
'/%s/%s/ticket/%s/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code,
self.ticket_pos.positionid, self.ticket_pos.web_secret)
)
assert response.status_code == 200
doc = BeautifulSoup(response.content.decode(), "lxml")
assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".order-details")[0].text.lower()
assert "Peter" in response.content.decode()
assert "Lukas" not in response.content.decode()
def test_require_login_for_order_access_accounts_disabled(self):
self.orga.settings.customer_accounts = True
with scopes_disabled():
customer = self.orga.customers.create(email='john@example.org', is_verified=True)
customer.set_password("foo")
customer.save()
self.order.customer = customer
self.order.save()
self.orga.settings.customer_accounts = False
self.orga.settings.customer_accounts_require_login_for_order_access = True
response = self.client.get(
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)
)
assert response.status_code == 200
doc = BeautifulSoup(response.content.decode(), "lxml")
assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".order-details")[0].text.lower()
assert "Peter" in response.content.decode()
assert "Lukas" not in response.content.decode()
def test_require_login_for_order_access_enabled_wrong_customer(self):
self.orga.settings.customer_accounts = True
with scopes_disabled():
customer = self.orga.customers.create(email='john@example.org', is_verified=True)
customer.set_password("foo")
customer.save()
self.order.customer = customer
self.order.save()
with scopes_disabled():
customer = self.orga.customers.create(email='jill@example.org', is_verified=True)
customer.set_password("bar")
customer.save()
self.orga.settings.customer_accounts_require_login_for_order_access = True
response = self.client.post('/%s/account/login' % (self.orga.slug), {
'email': 'jill@example.org',
'password': 'bar',
})
assert response.status_code == 302
response = self.client.get(
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)
)
assert response.status_code == 404
def test_require_login_for_order_access_enabled_unverified_account(self):
self.orga.settings.customer_accounts = True
with scopes_disabled():
customer = self.orga.customers.create(email='john@example.org', is_verified=False)
customer.set_password("foo")
customer.save()
self.order.customer = customer
self.order.save()
self.orga.settings.customer_accounts_require_login_for_order_access = True
response = self.client.get(
'/%s/%s/order/%s/%s/' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret)
)
assert response.status_code == 200
doc = BeautifulSoup(response.content.decode(), "lxml")
assert len(doc.select(".cart-row")) > 0
assert "pending" in doc.select(".order-details")[0].text.lower()
assert "Peter" in response.content.decode()
assert "Lukas" not in response.content.decode()