diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst index c4571ade22..cb3fc8e04c 100644 --- a/doc/api/resources/checkin.rst +++ b/doc/api/resources/checkin.rst @@ -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**: diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index f14fd1b1b8..2ab607908e 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -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", } ], diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index db716a3154..62e095c3f9 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -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) diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 1eb05b4940..87477dd82c 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -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'), ) diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 3ac7b6792b..039f438d47 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -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) diff --git a/src/pretix/helpers/database.py b/src/pretix/helpers/database.py index 06360cb051..931ba35ee8 100644 --- a/src/pretix/helpers/database.py +++ b/src/pretix/helpers/database.py @@ -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! diff --git a/src/pretix/plugins/paypal2/payment.py b/src/pretix/plugins/paypal2/payment.py index 84f3edf605..31634ce004 100644 --- a/src/pretix/plugins/paypal2/payment.py +++ b/src/pretix/plugins/paypal2/payment.py @@ -23,7 +23,7 @@ import json import logging import urllib.parse from collections import OrderedDict -from datetime import timedelta +from datetime import datetime, timedelta from decimal import Decimal from django import forms @@ -645,7 +645,7 @@ class PaypalMethod(BasePaymentProvider): def _execute_payment(self, request: HttpRequest, payment: OrderPayment): payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk) if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED: - logger.warning('payment is already confirmed; possible return-view/webhook race-condition') + # payment is already confirmed; possible return-view/webhook race-condition return try: @@ -832,6 +832,7 @@ class PaypalMethod(BasePaymentProvider): payment.info = json.dumps(pp_captured_order.dict()) payment.save(update_fields=['info']) payment.confirm() + self.log_payment_duration(payment) except Quota.QuotaExceededException as e: raise PaymentException(str(e)) # Payment has not any captures yet - so it's probably in created status @@ -841,6 +842,20 @@ class PaypalMethod(BasePaymentProvider): if 'payment_paypal_oid' in request.session: del request.session['payment_paypal_oid'] + @staticmethod + def log_payment_duration(payment: OrderPayment): + try: + capture = payment.info_data["purchase_units"][0]["payments"]["captures"][0] + create_time: str | None = capture["create_time"] + update_time: str | None = capture["update_time"] + except (KeyError, IndexError, TypeError): + create_time = None + update_time = None + + if create_time is not None and update_time is not None: + duration = datetime.fromisoformat(update_time) - datetime.fromisoformat(create_time) + logger.info('{}: {} - paypal payment processing time'.format(str(payment.global_id), str(duration))) + def payment_pending_render(self, request, payment) -> str: retry = True try: diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py index 37340e22a4..7a125e4593 100644 --- a/src/pretix/plugins/paypal2/views.py +++ b/src/pretix/plugins/paypal2/views.py @@ -490,6 +490,7 @@ def webhook(request, *args, **kwargs): payment.info = json.dumps(sale.dict()) payment.save(update_fields=['info']) payment.confirm() + prov.log_payment_duration(payment) except Quota.QuotaExceededException: pass elif sale['status'] == 'APPROVED': diff --git a/src/pretix/testutils/db.py b/src/pretix/testutils/db.py new file mode 100644 index 0000000000..50692a48d1 --- /dev/null +++ b/src/pretix/testutils/db.py @@ -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 . +# +# 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 +# . +# +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) diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 6ea5066cb4..da27c78adf 100644 --- a/src/tests/api/test_checkinrpc.py +++ b/src/tests/api/test_checkinrpc.py @@ -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() diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index f169df7024..1560affe12 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -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']