Compare commits

..
Author SHA1 Message Date
Lukas Bockstaller 4d3074d143 flake8 . 2026-08-13 16:21:49 +02:00
Lukas Bockstaller bd10f29060 remove left over Constant 2026-08-13 16:15:52 +02:00
Lukas Bockstaller 6448ff024e rename method and change defaults 2026-08-13 16:13:53 +02:00
Lukas Bockstaller aaa59f94a5 check all capture elements 2026-08-13 12:54:10 +02:00
Lukas Bockstallerandpajowu 33d1b47c5a Apply suggestions from code review
Co-authored-by: pajowu <pajowu@pajowu.de>
2026-08-13 12:39:15 +02:00
Lukas Bockstaller d85a2c877e cleanup abort_pending_allowed methods 2026-08-13 12:09:07 +02:00
Lukas Bockstaller e78a35a9f0 add missing negation 2026-08-12 15:32:37 +02:00
Lukas Bockstaller 459014eaaa formatting 2026-08-11 13:51:22 +02:00
Lukas Bockstaller 94f1511abb add timeout to paypal after which a pending payment can be canceled 2026-08-11 13:50:44 +02:00
Lukas Bockstaller 41fc743360 extend BasePaymentProvider to gate aborting pending payments on a payment per payment basis 2026-08-11 13:48:58 +02:00
Lukas Bockstaller c9b171e6e0 mark approved payment as pending 2026-08-11 10:36:22 +02:00
Lukas Bockstaller 3c67f65c03 move payment into pending on PENDING_REVIEW webhook 2026-08-10 17:01:53 +02:00
Raphael MichelandRichard Schreiber f25c233e91 Fix performance issues in download reminder (#6393)
* Fix performance issues in download reminder

* Update src/pretix/base/services/orders.py

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

* Fixes after review

* Fix check in loop

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-08-10 16:13:54 +02:00
robbi5 5cf28f1b81 Fix typo in devicesecurity blocking checkinrpc.annul (#6467) 2026-08-10 15:58:15 +02:00
Raphael Michel d08216d8c5 API: Allow to simulate check-ins (#6360)
* API: Allow to simulate check-ins

* Add missing file
2026-08-07 18:59:01 +02:00
Lukas Bockstaller 4a28689690 log paypal payment durations (#6461)
* log payment processing durations

* remove log noise

* fix attribute access

* remove debugging import
2026-08-07 16:34:36 +02:00
luelista 958f75b109 Add tests to prevent reintroducing CSP nonces (Z#23240534) (#6409)
As discussed in PR #6387
2026-08-07 14:36:46 +02:00
Richard Schreiber 81f58456e5 Fix API-docs example for addon_to on order-change (#6451)
* Fix API-docs example for addon_to on order-change

* Update orders.rst
2026-08-07 11:03:36 +02:00
19 changed files with 449 additions and 104 deletions
+2
View File
@@ -71,6 +71,8 @@ Checking a ticket in
:>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``.
:>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``.
:>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``.
:>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response
object will not reflect the simulated changes.
**Example request**:
+2 -2
View File
@@ -2038,7 +2038,7 @@ Manipulating individual positions
* ``order`` (mandatory, specified as a string mapping to a ``code``)
* ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position)
* ``addon_to`` (optional, specified as an integer mapping to ``positionid`` - the number of the position within the order, see :ref:`_order-position-resource` - of the parent position)
* ``item`` (mandatory)
@@ -2348,7 +2348,7 @@ otherwise, such as splitting an order or changing fees.
"subevent": 562,
"seat": "seat-guid-2",
"price": "99.99",
"addon_to": 12374,
"addon_to": 1,
"attendee_name": "Peter",
}
],
+3 -3
View File
@@ -115,7 +115,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'),
('POST', 'api-v1:reusablemedium-lookup'),
@@ -154,7 +154,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'),
)
@@ -191,7 +191,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile):
('GET', 'api-v1:event.settings'),
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('POST', 'api-v1:checkinrpc.annull'),
('POST', 'api-v1:checkinrpc.annul'),
('GET', 'api-v1:checkinrpc.search'),
)
+1
View File
@@ -90,6 +90,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
answers = serializers.JSONField(required=False, allow_null=True)
exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
exchange_medium_identifier = serializers.CharField(required=False)
simulate = serializers.BooleanField(default=False, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+6
View File
@@ -839,6 +839,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
)
if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate
if simulate:
raise CheckInError(
gettext('You cannot simulate a medium exchange.'),
'error'
)
with transaction.atomic():
# Do exchange and check-in atomically, i.e. both succeed or both fail
medium = perform_media_exchange(
@@ -1066,6 +1071,7 @@ class CheckinRPCRedeemView(views.APIView):
legacy_url_support=False,
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
simulate=s.validated_data.get('simulate'),
)
+21 -4
View File
@@ -330,9 +330,24 @@ class BasePaymentProvider:
payment method. This returns ``False`` by default which is no guarantee that
aborting a pending payment can never happen, it just hides the frontend button
to avoid users accidentally committing double payments.
If the decision doesn't depend on the specific payment, then only implementing
``abort_pending_allowed`` is enough, ``payment_abort_pending_allowed(payment: OrderPayment)``
is expected to take this into account.
As a consumer only evaluate ``payment_abort_pending_allowed(payment: OrderPayment)``
to check if aborting this pending payment is possible.
"""
return False
def _payment_abort_pending_allowed(self, payment: OrderPayment) -> bool:
"""
Experimental: This might change during upcomming releases.
Whether or not a user can abort a payment in pending state to switch to another
payment method. This returns ``self.abort_pending_allowed`` by default which is
no guarantee that aborting a pending payment can never happen, it just hides the
frontend button to avoid users accidentally committing double payments.
"""
return self.abort_pending_allowed
@property
def requires_invoice_immediately(self):
"""
@@ -1019,10 +1034,12 @@ class BasePaymentProvider:
On success, you should set ``payment.state = OrderPayment.PAYMENT_STATE_CANCELED`` (or call the super method).
On failure, you should raise a PaymentException.
"""
if payment.state == OrderPayment.PAYMENT_STATE_PENDING and not self.abort_pending_allowed:
raise PaymentException(_(
"This payment is already being processed and can not be canceled any more."
))
if payment.state == OrderPayment.PAYMENT_STATE_PENDING:
if not self._payment_abort_pending_allowed(payment):
raise PaymentException(_(
"This payment is already being processed and cannot be canceled any more."
))
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
payment.save(update_fields=['state'])
+4 -3
View File
@@ -40,7 +40,7 @@ import dateutil
import dateutil.parser
from dateutil.tz import datetime_exists
from django.core.files import File
from django.db import IntegrityError, transaction
from django.db import IntegrityError
from django.db.models import (
BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min,
OuterRef, Q, Subquery, TextField, Value, When,
@@ -59,6 +59,7 @@ from pretix.base.models import (
)
from pretix.base.signals import checkin_created, periodic_task
from pretix.helpers import OF_SELF
from pretix.helpers.database import conditional_atomic
from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
from pretix.helpers.jsonlogic_query import (
@@ -1043,10 +1044,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
if not simulate:
_save_answers(op, answers, given_answers)
with transaction.atomic():
with conditional_atomic(not simulate):
# Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
opqs = OrderPosition.all.select_related("order", "item")
if type != Checkin.TYPE_EXIT:
if type != Checkin.TYPE_EXIT and not simulate:
opqs = opqs.select_for_update(of=OF_SELF)
op = opqs.get(pk=op.pk)
+92 -71
View File
@@ -48,12 +48,12 @@ from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db import models, transaction
from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
Value,
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Subquery,
Sum, Value,
)
from django.db.models.functions import Coalesce, Greatest
from django.db.models.functions import Cast, Greatest
from django.db.transaction import get_connection
from django.dispatch import receiver
from django.utils.functional import cached_property
@@ -71,7 +71,7 @@ from pretix.base.models import (
Membership, Order, OrderPayment, OrderPosition, Quota, Seat,
SeatCategoryMapping, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.event import Event_SettingsStore, SubEvent
from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
generate_secret,
@@ -1494,83 +1494,104 @@ def send_expiry_warnings(sender, **kwargs):
@scopes_disabled()
def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
qs = Order.objects.annotate(
first_date=Coalesce(
Min('all_positions__subevent__date_from'),
F('event__date_from')
events = Event.objects.filter(
Q(has_subevents=False, date_from__gte=now()) |
(Q(has_subevents=True) & Q(Exists(
SubEvent.objects.filter(event_id=OuterRef('id'), date_from__gte=now())
)))
).annotate(
reminder_days=Subquery(
Event_SettingsStore.objects.filter(
object=OuterRef('id'),
key='mail_days_download_reminder'
).exclude(
value="None"
).annotate(
val=Cast(F("value"), output_field=models.IntegerField()),
).values("val")
)
).filter(
download_reminder_sent=False,
datetime__lte=now() - timedelta(hours=2),
first_date__gte=today,
).only(
'pk', 'event_id', 'sales_channel', 'datetime',
).order_by('event_id')
event_id = None
days = None
event = None
reminder_days__isnull=False,
).order_by()
for o in qs:
if o.event_id != event_id:
days = o.event.settings.get('mail_days_download_reminder', as_type=int)
event = o.event
event_id = o.event_id
for event in events.iterator(chunk_size=10_000):
qs = event.orders.filter(
download_reminder_sent=False,
datetime__lte=now() - timedelta(hours=2),
)
if days is None:
continue
if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
continue
reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
if now() < reminder_date or o.datetime > reminder_date:
continue
with transaction.atomic():
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
positions = list(o.positions_with_tickets)
if not positions:
if event.has_subevents:
qs = qs.annotate(
first_date=Min('all_positions__subevent__date_from')
).filter(
Q(first_date__gte=today)
)
else:
event_reminder_date = (event.date_from - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0)
if now() < event_reminder_date:
continue
if not o.ticket_download_available:
qs = qs.only(
'pk', 'event_id', 'sales_channel', 'datetime',
).order_by()
for o in qs:
if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
continue
if o.status != Order.STATUS_PAID:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
if event.has_subevents:
reminder_date = ((o.first_date or event.date_from) - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0)
else:
reminder_date = event_reminder_date
if now() < reminder_date or o.datetime > reminder_date:
continue
with transaction.atomic():
o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
if o.download_reminder_sent:
# Race condition
continue
positions = list(o.positions_with_tickets)
if not positions:
continue
with language(o.locale, o.event.settings.region):
o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
if not o.ticket_download_available:
continue
if event.settings.mail_send_download_reminder_attendee:
for p in positions:
if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
if now() < reminder_date:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
if o.status != Order.STATUS_PAID:
if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending):
continue
with language(o.locale, o.event.settings.region):
o.download_reminder_sent = True
o.save(update_fields=['download_reminder_sent'])
email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
if event.settings.mail_send_download_reminder_attendee:
for p in positions:
if p.subevent_id:
reminder_date = (p.subevent.date_from - timedelta(days=event.reminder_days)).replace(
hour=0, minute=0, second=0, microsecond=0
)
if now() < reminder_date:
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p)
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
+1 -1
View File
@@ -2930,7 +2930,7 @@ Your {event} team""")) # noqa: W291
},
'mail_days_download_reminder': {
'type': int,
'default': None
'default': None # when this default is changed, send_download_reminders needs to change
},
'mail_send_download_reminder_attendee': {
'type': bool,
+9
View File
@@ -288,6 +288,15 @@ def get_deterministic_ordering(model, ordering):
return ordering
@contextlib.contextmanager
def conditional_atomic(do_atomic, **kwargs):
if do_atomic:
with transaction.atomic(**kwargs):
yield
else:
yield
class IgnoreOnSQLiteMixin:
# Mixin to allow defining PostgreSQL-specific indexes that will just not be created
# on SQLite. SQLite is supported for testing only anyways!
+53 -11
View File
@@ -23,7 +23,7 @@ import json
import logging
import urllib.parse
from collections import OrderedDict
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from decimal import Decimal
from django import forms
@@ -193,6 +193,31 @@ class PaypalSettingsHolder(BasePaymentProvider):
}
)
)),
('allow_retries_during_compliance_hold',
forms.BooleanField(
label=_('Allow further payments during compliance hold'),
help_text=_(
'PayPals fraud prevention might block processing of individual payments for a considerable amount '
'of time. The payment is marked as "pending" during this time window. You can allow your customers to '
'start another payment attempts during that window. This might result in them being charged twice if the'
'original payment is approved.'
),
required=False
)),
('timeout_payment_during_compliance_hold',
forms.IntegerField(
label=_('Timeout further payment attempts'),
help_text=_(
'Time duration in minutes after which another payment attempt is possible, while the last payment is '
'still under investigation.'
),
required=False,
widget=forms.NumberInput(
attrs={
'data-checkbox-dependency': '#id_payment_paypal_allow_retries_during_compliance_hold',
}
)
)),
]
@@ -515,8 +540,16 @@ class PaypalMethod(BasePaymentProvider):
'XPF': 0,
}))
@property
def abort_pending_allowed(self):
def _payment_abort_pending_allowed(self, payment) -> bool:
if not self.settings.get('allow_retries_during_compliance_hold', as_type=bool, default=False):
return False
if payment.info_data.get('create_time', False):
create_time = datetime.fromisoformat(payment.info_data['create_time'])
duration = self.settings.get('timeout_payment_during_compliance_hold', as_type=int, default=10)
if datetime.now(tz=timezone.utc) - create_time > timedelta(minutes=duration):
return True
return False
def _create_paypal_order(self, request, payment=None, cart_total=None):
@@ -678,6 +711,8 @@ class PaypalMethod(BasePaymentProvider):
else:
pp_captured_order = response.result
payment.info = json.dumps(pp_captured_order.dict())
if pp_captured_order.status == 'APPROVED':
payment.state = OrderPayment.PAYMENT_STATE_PENDING
payment.save()
try:
@@ -857,14 +892,20 @@ class PaypalMethod(BasePaymentProvider):
logger.info('{}: {} - paypal payment processing time'.format(str(payment.global_id), str(duration)))
def payment_pending_render(self, request, payment) -> str:
retry = True
stuck_in_compliance = False
retry = self._payment_abort_pending_allowed(payment)
try:
if (
payment.info
and payment.info_data['purchase_units'][0]['payments']['captures'][0]['status'] == 'PENDING'
):
retry = False
except (KeyError, IndexError):
for purchase_unit in payment.info_data['purchase_units']:
for capture in purchase_unit['payments']['captures']:
if capture['status'] == "PENDING":
stuck_in_compliance = True
except KeyError:
pass
try:
if payment.info_data.get('status') == "APPROVED":
stuck_in_compliance = True
except (KeyError):
pass
error = payment.info_data.get("error", {})
@@ -872,7 +913,8 @@ class PaypalMethod(BasePaymentProvider):
template = get_template('pretixplugins/paypal2/pending.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'retry': retry, 'order': payment.order, 'is_known_issue': is_known_issue}
'stuck_in_compliance': stuck_in_compliance, 'retry': retry, 'order': payment.order,
'is_known_issue': is_known_issue}
return template.render(ctx)
def matching_id(self, payment: OrderPayment):
+2
View File
@@ -166,6 +166,8 @@ def signal_process_response(sender, request: HttpRequest, response: HttpResponse
settings_hierarkey.add_default('payment_paypal_debug_buyer_country', '', str)
settings_hierarkey.add_default('payment_paypal_method_wallet', True, bool)
settings_hierarkey.add_default('payment_paypal_allow_retries_during_compliance_hold', False, bool)
settings_hierarkey.add_default('payment_paypal_timeout_payment_during_compliance_hold', 10, int)
def _nonce(request):
@@ -6,9 +6,12 @@
Your payment has failed due to a known issue within PayPal. Please try again, there is a high chance of the
payment succeeding on a second or third attempt. You can also try other payment methods, if available.
{% endblocktrans %}</div>
{% else %}
{% elif stuck_in_compliance %}
<p>{% blocktrans trimmed %}
Our attempt to execute your payment via PayPal has failed. Please try again or contact us.
Your payment is being processed by PayPal. This takes longer than usual. You can wait until PayPal
acknowledges the payment or you can try paying again with this or another payment method.
This might result in you being charged twice in case PayPal allows your initial payment attempt.
Please contact us to resolve this case.
{% endblocktrans %}</p>
{% endif %}
{% else %}
+7 -4
View File
@@ -471,8 +471,8 @@ def webhook(request, *args, **kwargs):
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED):
if sale['status'] == 'COMPLETED':
any_captures = False
all_captures_completed = True
any_pending_review = False
for purchaseunit in sale['purchase_units']:
for capture in purchaseunit['payments']['captures']:
try:
@@ -483,9 +483,9 @@ def webhook(request, *args, **kwargs):
if capture['status'] not in ('COMPLETED', 'REFUNDED', 'PARTIALLY_REFUNDED'):
all_captures_completed = False
else:
any_captures = True
if any_captures and all_captures_completed:
if capture['status_details']['reason'] == "PENDING_REVIEW":
any_pending_review = True
if all_captures_completed:
try:
payment.info = json.dumps(sale.dict())
payment.save(update_fields=['info'])
@@ -493,6 +493,9 @@ def webhook(request, *args, **kwargs):
prov.log_payment_duration(payment)
except Quota.QuotaExceededException:
pass
if any_pending_review and payment.state != OrderPayment.PAYMENT_STATE_PENDING:
payment.state = OrderPayment.PAYMENT_STATE_PENDING
payment.save(update_fields=['state'])
elif sale['status'] == 'APPROVED':
try:
request.session['payment_paypal_oid'] = payment.info_data['id']
+4 -3
View File
@@ -349,7 +349,7 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
pp = lp.payment_provider
ctx['last_payment_info'] = pp.payment_pending_render(self.request, ctx['last_payment'])
if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed:
if lp.state == OrderPayment.PAYMENT_STATE_PENDING and not pp._payment_abort_pending_allowed(lp):
ctx['can_pay'] = False
ctx['can_pay'] = ctx['can_pay'] and self.order._can_be_paid() is True
@@ -611,7 +611,8 @@ class OrderPayChangeMethod(EventViewMixin, OrderDetailMixin, TemplateView):
if self.open_payment:
pp = self.open_payment.payment_provider
if self.open_payment.state == OrderPayment.PAYMENT_STATE_PENDING and not pp.abort_pending_allowed:
if self.open_payment.state == OrderPayment.PAYMENT_STATE_PENDING and not pp._payment_abort_pending_allowed(
self.open_payment):
messages.error(request, _('A payment is currently pending for this order.'))
return redirect(self.get_order_url())
@@ -1718,7 +1719,7 @@ class OrderChangeMixin:
if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING:
for p in self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_PENDING):
if not p.payment_provider.abort_pending_allowed:
if not p.payment_provider._payment_abort_pending_allowed(p):
raise OrderError(_('You may not change your order in a way that requires additional payment while '
'we are processing your current payment. Please check back after your current '
'payment has been accepted.'))
+25
View File
@@ -0,0 +1,25 @@
#
# 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/>.
#
def readonly_db(execute, sql, params, many, context):
if not sql.lower().startswith("select"):
raise Exception(f"Should not write anything to the database, but detected query: {sql}")
return execute(sql, params, many, context)
+40
View File
@@ -25,6 +25,7 @@ from unittest import mock
import pytest
from django.core.files.base import ContentFile
from django.db import connection
from django.utils.timezone import now
from django_countries.fields import Country
from django_scopes import scopes_disabled
@@ -36,6 +37,7 @@ from pretix.api.serializers.item import QuestionSerializer
from pretix.base.models import (
Checkin, InvoiceAddress, Item, Order, OrderPosition, ReusableMedium,
)
from pretix.testutils.db import readonly_db
# Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering
# the same backend code paths (for now). However, this is SUCH a critical part of pretix that we don't want to take
@@ -1739,3 +1741,41 @@ def test_exchange_create_gift_card(token_client, organizer, clist, event, order,
with scopes_disabled():
rm = ReusableMedium.objects.get(identifier="0412345")
assert rm.linked_giftcard.currency == "EUR"
@pytest.mark.django_db
def test_simulate(token_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
with connection.execute_wrapper(readonly_db):
resp = _redeem(token_client, organizer, clist, p.secret, {"simulate": True})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
with scopes_disabled():
assert not p.checkins.exists()
@pytest.mark.django_db
def test_simulate_no_exchange(token_client, organizer, clist, event, order, item):
organizer.settings.reusable_media_type_nfc_uid = True
item.media_type = "nfc_uid"
item.media_policy = Item.MEDIA_POLICY_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="nfc_uid",
identifier="12345678",
organizer=organizer,
)
with connection.execute_wrapper(readonly_db):
resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", {
"source_type": "barcode",
"exchange_medium_type": "nfc_uid",
"exchange_medium_identifier": "12345678",
"simulate": True,
})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'error'
with scopes_disabled():
assert not rm.linked_orderpositions.exists()
+25
View File
@@ -224,3 +224,28 @@ def test_one_view(logged_in_client, url, expected, event, item, item_category, o
)
response = logged_in_client.get(url)
assert response.status_code == expected
# Do not reintroduce any CSP nonces into control responses, as discussed in PR #6387
if response['Content-Type'] != 'application/json':
assert 'script-src' in response['Content-Security-Policy']
assert 'nonce-' not in response['Content-Security-Policy']
@pytest.mark.parametrize('url', [
'/control/login',
'/',
'/{orga}/{event}/',
])
@pytest.mark.django_db
def test_csp_header_unauthenticated(client, url, event):
# Do not reintroduce any CSP nonces into most presale responses, as discussed in PR #6387
with scope(organizer=event.organizer):
url = url.format(
event=event.slug, orga=event.organizer.slug,
)
event.live = True
event.save()
response = client.get(url)
assert response.status_code == 200
assert 'script-src' in response['Content-Security-Policy']
assert 'nonce-' not in response['Content-Security-Policy']
+147
View File
@@ -244,6 +244,61 @@ def get_test_refund():
}
def get_test_order_review_pending():
return {'id': '806440346Y391300T',
'intent': 'CAPTURE',
'status': 'COMPLETED',
'purchase_units': [{'reference_id': 'default',
'amount': {'currency_code': 'EUR', 'value': '43.59'},
'payee': {'email_address': 'dummy-facilitator@dummy.dummy',
'merchant_id': 'G6R2B9YXADKWW'},
'description': 'Order JWJGC for PayPal v2',
'custom_id': 'Order PAYPALV2-JWJGC',
'soft_descriptor': 'MARTINFACIL',
'payments': {'captures': [{'id': '22A4162004478570J',
'status': 'PENDING',
'status_details': {
'reason': 'PENDING_REVIEW'
},
'amount': {'currency_code': 'EUR', 'value': '43.59'},
'final_capture': True,
'disbursement_mode': 'INSTANT',
'seller_protection': {'status': 'ELIGIBLE',
'dispute_categories': [
'ITEM_NOT_RECEIVED',
'UNAUTHORIZED_TRANSACTION']},
'seller_receivable_breakdown': {
'gross_amount': {'currency_code': 'EUR',
'value': '43.59'},
'paypal_fee': {'currency_code': 'EUR', 'value': '1.18'},
'net_amount': {'currency_code': 'EUR',
'value': '42.41'}},
'custom_id': 'Order PAYPALV2-JWJGC',
'links': [{
'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J',
'rel': 'self',
'method': 'GET'},
{
'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund',
'rel': 'refund',
'method': 'POST'},
{
'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T',
'rel': 'up',
'method': 'GET'}],
'create_time': '2022-04-28T12:00:22Z',
'update_time': '2022-04-28T12:00:22Z'}]}}],
'payer': {'name': {'given_name': 'test', 'surname': 'buyer'},
'email_address': 'dummy@dummy.dummy',
'payer_id': 'Q739JNKWH67HE',
'address': {'country_code': 'DE'}},
'create_time': '2022-04-28T11:59:59Z',
'update_time': '2022-04-28T12:00:22Z',
'links': [{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T',
'rel': 'self',
'method': 'GET'}]}
class Object():
pass
@@ -690,3 +745,95 @@ def test_webhook_refund2(env, client, monkeypatch):
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
@pytest.mark.django_db
def test_webhook_pending_payment(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
with scopes_disabled():
order.payments.update(state=OrderPayment.PAYMENT_STATE_CREATED)
pp_order = Result(get_test_order_review_pending())
mock_orders_get_request = MagicMock(return_value=pp_order)
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", mock_orders_get_request)
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
with scopes_disabled():
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
reference="806440346Y391300T")
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_CREATED
client.post('/_paypal/webhook/', json.dumps(
{
"id": "WH-0AH02875JL566842H-2AF03788XV8252724",
"create_time": "2022-04-28T12:00:37.077Z",
"resource_type": "capture",
"event_type": "PAYMENT.CAPTURE.PENDING",
"summary": "Payment pending for € 43.59 EUR",
"resource": {
"update_time": "2022-04-28T12:00:22Z",
"create_time": "2022-04-28T11:59:59Z",
"amount": {
"currency_code": "EUR",
"value": "43.59"
},
"custom_id": "Order PAYPALV2-JWJGC",
"final_capture": True,
"id": "22A4162004478570J",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/5M631111V9599860P",
"method": "GET",
"rel": "self"
},
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/5M631111V9599860P/refund",
"method": "POST",
"rel": "refund"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T",
"method": "GET",
"rel": "up"
}
],
"payee": {
"email_address": "sb-ybfun52428692@business.example.com",
"merchant_id": "DLM8QKDR3CFZW"
},
"seller_protection": {
"status": "NOT_ELIGIBLE"
},
"status": "PENDING",
"status_details": {
"reason": "PENDING_REVIEW"
},
"supplementary_data": {
"related_ids": {
"order_id": "9L827155WD164573M"
}
}
},
"links": [
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-0AH02875JL566842H-2AF03788XV8252724",
"method": "GET",
"rel": "self"
},
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-0AH02875JL566842H-2AF03788XV8252724/resend",
"method": "POST",
"rel": "resend"
}
],
"event_version": "1.0",
"resource_version": "2.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
with scopes_disabled():
assert order.payments.first().state == OrderPayment.PAYMENT_STATE_PENDING