From 28de4554e5f065ded9680e7de7a383d826bf8728 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 7 Jul 2026 19:20:19 +0200 Subject: [PATCH] API: Allow to simulate check-ins --- doc/api/resources/checkin.rst | 2 ++ src/pretix/api/serializers/checkin.py | 1 + src/pretix/api/views/checkin.py | 6 ++++ src/pretix/base/services/checkin.py | 7 +++-- src/pretix/helpers/database.py | 9 ++++++ src/tests/api/test_checkinrpc.py | 40 +++++++++++++++++++++++++++ 6 files changed, 62 insertions(+), 3 deletions(-) diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst index c4571ade2..cb3fc8e04 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/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index db716a315..62e095c3f 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 7bafddd3a..e35281de2 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -837,6 +837,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( @@ -1064,6 +1069,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 3ac7b6792..039f438d4 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 d9d8ffb16..3fc823998 100644 --- a/src/pretix/helpers/database.py +++ b/src/pretix/helpers/database.py @@ -285,3 +285,12 @@ def get_deterministic_ordering(model, ordering): # on the primary key to provide total ordering. ordering.append("-pk") return ordering + + +@contextlib.contextmanager +def conditional_atomic(do_atomic, **kwargs): + if do_atomic: + with transaction.atomic(**kwargs): + yield + else: + yield diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 0c5c387a9..dc2b6d2a0 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()