From 0dc95a22dfde02014cb606a2ed4b711eb63c9af6 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Mon, 27 Apr 2026 14:37:51 +0200 Subject: [PATCH] Add Reusable Media Exchange to Checkin API --- src/pretix/api/serializers/event.py | 2 + src/pretix/api/serializers/organizer.py | 1 + src/pretix/api/views/checkin.py | 20 ++++++++- src/pretix/base/media.py | 4 +- src/pretix/base/models/checkin.py | 2 + src/pretix/base/services/checkin.py | 41 ++++++++++++++++++- src/pretix/base/settings.py | 13 ++++++ src/pretix/control/forms/checkin.py | 5 +++ src/pretix/control/forms/organizer.py | 1 + .../pretixcontrol/checkin/simulator.html | 11 +++++ .../pretixcontrol/organizers/edit.html | 1 + src/pretix/control/views/checkin.py | 5 ++- .../static/pretixcontrol/scss/main.scss | 3 ++ 13 files changed, 103 insertions(+), 6 deletions(-) diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index ca163f0d33..b4dcc91e3a 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -871,6 +871,7 @@ class EventSettingsSerializer(SettingsSerializer): 'og_image', 'name_scheme', 'reusable_media_active', + 'reusable_media_usage_enforced', 'reusable_media_type_barcode', 'reusable_media_type_barcode_identifier_length', 'reusable_media_type_nfc_uid', @@ -885,6 +886,7 @@ class EventSettingsSerializer(SettingsSerializer): readonly_fields = [ # These are read-only since they are currently only settable on organizers, not events 'reusable_media_active', + 'reusable_media_usage_enforced', 'reusable_media_type_barcode', 'reusable_media_type_barcode_identifier_length', 'reusable_media_type_nfc_uid', diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 47c141aeef..9d595f7624 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -605,6 +605,7 @@ class OrganizerSettingsSerializer(SettingsSerializer): 'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_no', 'reusable_media_active', + 'reusable_media_usage_enforced', 'reusable_media_type_barcode', 'reusable_media_type_barcode_identifier_length', 'reusable_media_type_nfc_uid', diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index c6567a4d5c..f8f6982ce2 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -69,7 +69,7 @@ from pretix.base.models import ( from pretix.base.models.orders import PrintLog from pretix.base.permissions import AnyPermissionOf from pretix.base.services.checkin import ( - CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, + CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, RequiredMediaExchangeError, ) from pretix.base.signals import checkin_annulled from pretix.helpers import OF_SELF @@ -454,7 +454,8 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce, untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported, - source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False): + media_exchange_supported, source_type='barcode', legacy_url_support=False, simulate=False, + gate=None, use_order_locale=False): if not checkinlists: raise ValidationError('No check-in list passed.') @@ -463,6 +464,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, device = auth if isinstance(auth, Device) else None gate = gate or (auth.gate if isinstance(auth, Device) else None) + media = None context = { 'request': request, @@ -744,6 +746,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, datetime=datetime, questions_supported=questions_supported, canceled_supported=canceled_supported, + media_exchange_supported=media_exchange_supported, user=user, auth=auth, type=checkin_type, @@ -752,6 +755,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, from_revoked_secret=from_revoked_secret, simulate=simulate, gate=gate, + reusable_media=media, ) except RequiredQuestionsError as e: return Response({ @@ -764,6 +768,16 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, ], 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, }, status=400) + except RequiredMediaExchangeError as e: + return Response({ + 'status': 'exchange', + 'require_attention': op.require_checkin_attention, + 'checkin_texts': op.checkin_texts, + 'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data, + 'media_policy': e.media_policy, + 'media_type': e.media_type, + 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, + }, status=400) except CheckInError as e: if not simulate: op.order.log_action('pretix.event.checkin.denied', data={ @@ -913,6 +927,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): pdf_data=self.request.query_params.get('pdf_data', 'false').lower() == 'true', questions_supported=self.request.data.get('questions_supported', True), canceled_supported=self.request.data.get('canceled_supported', False), + media_exchange_supported=self.request.data.get('media_exchange_supported', False), request=self.request, # this is not clean, but we need it in the serializers for URL generation legacy_url_support=True, ) @@ -949,6 +964,7 @@ class CheckinRPCRedeemView(views.APIView): questions_supported=s.validated_data['questions_supported'], use_order_locale=s.validated_data['use_order_locale'], canceled_supported=True, + media_exchange_supported=s.validated_data.get('media_exchange_supported', False), request=self.request, # this is not clean, but we need it in the serializers for URL generation legacy_url_support=False, ) diff --git a/src/pretix/base/media.py b/src/pretix/base/media.py index 47a1be987e..45c31ca0f2 100644 --- a/src/pretix/base/media.py +++ b/src/pretix/base/media.py @@ -89,7 +89,7 @@ class NfcUidMediaType(BaseMediaType): icon = 'pretixbase/img/media/nfc_uid.svg' medium_created_by_server = False supports_giftcard = True - supports_orderposition = False + supports_orderposition = True def handle_unknown(self, organizer, identifier, user, auth): from pretix.base.models import GiftCard, ReusableMedium @@ -129,7 +129,7 @@ class NfcMf0aesMediaType(BaseMediaType): icon = 'pretixbase/img/media/nfc_secure.svg' medium_created_by_server = False supports_giftcard = True - supports_orderposition = False + supports_orderposition = True def handle_new(self, organizer, medium, user, auth): from pretix.base.models import GiftCard diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 0926cedee4..5b02fd105a 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -351,6 +351,7 @@ class Checkin(models.Model): REASON_UNAPPROVED = 'unapproved' REASON_INVALID_TIME = 'invalid_time' REASON_ANNULLED = 'annulled' + REASON_ALREADY_EXCHANGED = 'already_exchanged' REASONS = ( (REASON_CANCELED, _('Order canceled')), (REASON_INVALID, _('Unknown ticket')), @@ -366,6 +367,7 @@ class Checkin(models.Model): (REASON_UNAPPROVED, _('Order not approved')), (REASON_INVALID_TIME, _('Ticket not valid at this time')), (REASON_ANNULLED, _('Check-in annulled')), + (REASON_ALREADY_EXCHANGED, _('Ticket already exchanged')), ) successful = models.BooleanField( diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index ecf4cf7a9f..650e0cff33 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -867,6 +867,15 @@ class RequiredQuestionsError(Exception): super().__init__(msg) +class RequiredMediaExchangeError(Exception): + def __init__(self, msg, code, media_policy, media_type): + self.msg = msg + self.code = code + self.media_policy = media_policy + self.media_type = media_type + super().__init__(msg) + + def _save_answers(op, answers, given_answers): def _create_answer(question, answer): try: @@ -939,7 +948,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False, - gate=None): + gate=None, media_exchange_supported=False, reusable_media=None): """ Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is not valid at this time. @@ -951,6 +960,8 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, questions are not filled out. :param ignore_unpaid: When set to True, this will succeed even when the order is unpaid. :param questions_supported: When set to False, questions are ignored + :param media_exchange_supported: When set to False, media exchanges are ignored and access with un-exchanged media + might be permitted :param nonce: A random nonce to prevent race conditions. :param datetime: The datetime of the checkin, defaults to now. :param simulate: If true, the check-in is not saved. @@ -1100,6 +1111,34 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, 'incomplete', require_answers ) + media_exchange_supported = True + + required_media_policy = op.item.media_policy + required_media_type = op.item.media_type + linked_media = op.linked_media + require_media_exchange = required_media_policy and required_media_type and not linked_media.exists() + if require_media_exchange and not force and media_exchange_supported: + raise RequiredMediaExchangeError( + _('You need to exchange your ticket to complete this check-in.'), + 'exchange', + required_media_policy, + required_media_type + ) + + require_reusable_media_usage = required_media_policy and required_media_type and op.organizer.settings.reusable_media_usage_enforced + if require_reusable_media_usage and not force: + if not reusable_media and not linked_media.exists() and media_exchange_supported: + raise RequiredMediaExchangeError( + _('You need to exchange your ticket to complete this check-in.'), + 'exchange', + required_media_policy, + required_media_type + ) + elif not reusable_media and linked_media.exists(): + raise CheckInError( + _('This ticket has already been exchanged - use the reusable media instead.'), + 'already_exchanged', + ) device = None if isinstance(auth, Device): diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 5e2be57132..d20a3c47cc 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -217,6 +217,19 @@ DEFAULTS = { "later.") ) }, + 'reusable_media_usage_enforced': { + 'default': 'False', + 'type': bool, + 'form_class': forms.BooleanField, + 'serializer_class': serializers.BooleanField, + 'form_kwargs': dict( + label=_("Enforce the usage of issued re-usable media for check-in"), + help_text=_("If enabled, a ticket barcode will not be accepted anymore, if a re-usable media has been " + "created and linked to a ticket. Keeping this option turned off will treat the re-usable " + "medium and ticket as equals."), + widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-reusable_media_active'}), + ) + }, 'reusable_media_type_barcode': { 'default': 'False', 'type': bool, diff --git a/src/pretix/control/forms/checkin.py b/src/pretix/control/forms/checkin.py index c1dee0443f..51d21dd977 100644 --- a/src/pretix/control/forms/checkin.py +++ b/src/pretix/control/forms/checkin.py @@ -192,6 +192,11 @@ class CheckinListSimulatorForm(forms.Form): initial=True, required=False, ) + media_exchange_supported = forms.BooleanField( + label=_("Support for media exchange"), + initial=True, + required=False, + ) gate = SafeModelChoiceField( label=_('Gate'), empty_label=_('All gates'), diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index d0c48fc3d4..5f293723f6 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -627,6 +627,7 @@ class OrganizerSettingsForm(SettingsForm): 'cookie_consent_dialog_button_yes', 'cookie_consent_dialog_button_no', 'reusable_media_active', + 'reusable_media_usage_enforced', 'reusable_media_type_barcode', 'reusable_media_type_barcode_identifier_length', 'reusable_media_type_nfc_uid', diff --git a/src/pretix/control/templates/pretixcontrol/checkin/simulator.html b/src/pretix/control/templates/pretixcontrol/checkin/simulator.html index ff38299696..68da775a7c 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/simulator.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/simulator.html @@ -34,6 +34,7 @@ {% bootstrap_field form.gate layout="control" %} {% bootstrap_field form.ignore_unpaid layout="control" %} {% bootstrap_field form.questions_supported layout="control" %} + {% bootstrap_field form.media_exchange_supported layout="control" %}