From 775fdd1ccb1c754a230564a5999234e79643f475 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Thu, 11 Jun 2026 16:25:13 +0200 Subject: [PATCH] Check-in API: Add reusable media exchange (#6115) * Add Reusable Media Exchange to Checkin API * isort * Remove debugging leftover * Apply suggestions from code review Co-authored-by: robbi5 * Add media_exchange_supported to CheckinRPCRedeemInputSerializer * SecurityProfiles: Add api-v1:reusablemedia-lookup and -detail for SCAN * Simplify media exchange checks * Apply suggestions from code review Co-authored-by: Raphael Michel * Wording: re-usable --> reusable * Deny checkins if media-exchange is required but device does not support it. * Remove media_exchange_supported-Flag: Checkin will always be denied if media needs to be exchanged; apps will fall back to explanation text * CheckinRPC: Also perform media exchange * Use media_policy from item, not as a checkinrpc parameter * my own review notes * Fixes, cleanup, rebase * block expired media * Fix query * add logging * Refactor link_action into media policy, gift card support * Block illegal policy-type combination * Drop add_to_reusable_medium, decide all by policy * Fix test failure * fix test on postgres * Expose reusable_media_usage_enforced to devies * Explicitly set update view --------- Co-authored-by: robbi5 Co-authored-by: Maximilian Richt Co-authored-by: Raphael Michel Co-authored-by: Raphael Michel Co-authored-by: Raphael Michel --- doc/api/resources/checkin.rst | 9 +- doc/api/resources/checkinlists.rst | 6 +- doc/api/resources/items.rst | 2 +- doc/api/resources/orders.rst | 3 +- src/pretix/api/auth/devicesecurity.py | 2 + src/pretix/api/serializers/checkin.py | 8 + src/pretix/api/serializers/event.py | 3 + src/pretix/api/serializers/order.py | 47 +- src/pretix/api/serializers/organizer.py | 1 + src/pretix/api/views/checkin.py | 54 +- src/pretix/api/views/media.py | 2 +- src/pretix/base/media.py | 34 +- src/pretix/base/models/checkin.py | 6 + src/pretix/base/models/items.py | 22 +- src/pretix/base/models/media.py | 5 +- src/pretix/base/services/cart.py | 4 +- src/pretix/base/services/checkin.py | 32 +- src/pretix/base/services/media.py | 178 ++++++- src/pretix/base/services/orders.py | 2 +- src/pretix/base/settings.py | 19 +- src/pretix/control/forms/organizer.py | 1 + src/pretix/control/logdisplay.py | 1 + .../pretixcontrol/checkin/simulator.html | 10 + .../pretixcontrol/organizers/edit.html | 1 + src/pretix/control/views/checkin.py | 4 +- src/pretix/static/jsi18n/en/djangojs.js | 1 - .../static/pretixcontrol/scss/main.scss | 3 + src/tests/api/test_checkinrpc.py | 488 +++++++++++++++++- src/tests/api/test_order_create.py | 16 +- src/tests/base/test_checkin.py | 2 +- src/tests/base/test_orders.py | 4 +- 31 files changed, 875 insertions(+), 95 deletions(-) diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst index 479fa28a37..c4571ade22 100644 --- a/doc/api/resources/checkin.rst +++ b/doc/api/resources/checkin.rst @@ -46,12 +46,14 @@ Checking a ticket in this request twice with the same nonce, the second request will also succeed but will always create only one check-in object even when the previous request was successful as well. This allows for a certain level of idempotency and enables you to re-try after a connection failure. + :json string status: ``"ok"``, ``"incomplete"``, or ``"error"`` + :>json string status: ``"ok"``, ``"incomplete"``, ``"exchange"``, or ``"error"`` :>json string reason: Reason code, only set on status ``"error"``, see below for possible values. :>json string reason_explanation: Human-readable explanation, only set on status ``"error"`` and reason ``"rules"``, can be null. :>json object position: Copy of the matching order position (if any was found). The contents are the same as the @@ -67,6 +69,8 @@ Checking a ticket in :>json object list: Excerpt of information about the matching :ref:`check-in list ` (if any was found), including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``. :>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"``. **Example request**: @@ -224,6 +228,9 @@ Checking a ticket in * ``ambiguous`` - Multiple tickets match scan, rejected. * ``revoked`` - Ticket code has been revoked. * ``unapproved`` - Order has not yet been approved. + * ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in. + * ``medium_invalid`` - Reusable medium identifier given was not found or is not valid. + * ``medium_exists`` - Reusable medium identifier already exists, but expected to be new. * ``error`` - Internal error. In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation`` diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index 6090772b58..3b6f506f4e 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -602,7 +602,8 @@ Order position endpoints We no longer recommend using this API if you're building a ticket scanning application, as it has a few design flaws that can lead to `security issues`_ or compatibility issues due to barcode content characters that are not - URL-safe. We recommend to use our new :ref:`check-in API ` instead. + URL-safe. We recommend to use our new :ref:`check-in API ` instead. Advanced features like medium + exchange are only supported on the new API. :query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never as an ``id``. This should be always set if you are passing through untrusted, scanned @@ -741,6 +742,9 @@ Order position endpoints * ``ambiguous`` - Multiple tickets match scan, rejected. * ``revoked`` - Ticket code has been revoked. * ``unapproved`` - Order has not yet been approved. + * ``already_exchanged`` - Ticket already has been exchanged for a reusable medium that must now be used for check-in. + * ``medium_invalid`` - Reusable medium identifier given was not found and could not be automatically created. + * ``medium_exists`` - Reusable medium identifier already exists, but expected to be new. In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation`` with a human-readable description of the violated rules. However, that field can also be missing or be ``null``. diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index a8a5a14822..ed81af0fed 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -131,7 +131,7 @@ allow_waitinglist boolean If ``false``, product when it is sold out. issue_giftcard boolean If ``true``, buying this product will yield a gift card. media_policy string Policy on how to handle reusable media (experimental feature). - Possible values are ``null``, ``"new"``, ``"reuse"``, and ``"reuse_or_new"``. + Possible values are ``null``, ``"new"``, ``"reuse"``, ``"reuse_or_new"``, ``"append"``, and ``"append_or_new"``. media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices. show_quota_left boolean Publicly show how many tickets are still available. If this is ``null``, the event default is used. diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index d345541506..8f0ff758a0 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -1069,8 +1069,7 @@ Creating orders * ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product) * ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected) - * ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID) - * ``add_to_reusable_medium`` (optional, causes the new ticket to be added to the given reusable medium, identified by its ID) + * ``use_reusable_medium`` (optional, causes the new ticket to be connected to the given reusable medium, identified by its ID) * ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run) * ``answers`` diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 18a99336bd..7fc06e9522 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -110,6 +110,8 @@ class PretixScanSecurityProfile(AllowListSecurityProfile): ('POST', 'api-v1:checkinrpc.redeem'), ('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:reusablemedium-list'), + ('POST', 'api-v1:reusablemedium-lookup'), + ('PATCH', 'api-v1:reusablemedium-detail') ) diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index 63759c0160..db716a3154 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -88,11 +88,19 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer): nonce = serializers.CharField(required=False, allow_null=True) datetime = serializers.DateTimeField(required=False, allow_null=True) 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) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event') + def validate(self, attrs): + exchange_fields = ["exchange_medium_type", "exchange_medium_identifier"] + if any(attrs.get(k) is None for k in exchange_fields) and not all(attrs.get(k) is None for k in exchange_fields): + raise ValidationError("If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.") + return attrs + class MiniCheckinListSerializer(I18nAwareModelSerializer): event = serializers.SlugRelatedField(slug_field='slug', read_only=True) diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index ca163f0d33..52411e8fda 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', @@ -970,6 +972,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'reusable_media_type_nfc_uid', 'reusable_media_type_nfc_mf0aes', 'reusable_media_type_nfc_mf0aes_random_uid', + 'reusable_media_usage_enforced', 'system_question_order', 'tax_rule_payment', 'tax_rule_cancellation', diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 0cfcb3a46f..7ce4c567d4 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1043,15 +1043,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): requested_valid_from = serializers.DateTimeField(required=False, allow_null=True) use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(), required=False, allow_null=True) - add_to_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(), - required=False, allow_null=True) class Meta: model = OrderPosition fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled', 'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until', - 'requested_valid_from', 'use_reusable_medium', 'add_to_reusable_medium', 'discount') + 'requested_valid_from', 'use_reusable_medium', 'discount') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1063,8 +1061,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): with scopes_disabled(): if 'use_reusable_medium' in self.fields: self.fields['use_reusable_medium'].queryset = ReusableMedium.objects.all() - if 'add_to_reusable_medium' in self.fields: - self.fields['add_to_reusable_medium'].queryset = ReusableMedium.objects.all() def validate_secret(self, secret): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): @@ -1080,9 +1076,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): ) return m - def validate_add_to_reusable_medium(self, m): - return self.validate_use_reusable_medium(m) - def validate_item(self, item): if item.event != self.context['event']: raise ValidationError( @@ -1157,12 +1150,6 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): {'discount': ['You can only specify a discount if you do the price computation, but price is not set.']} ) - if 'use_reusable_medium' in data and 'add_to_reusable_medium' in data: - raise ValidationError({ - 'use_reusable_medium': ['You can only specify either use_reusable_medium or add_to_reusable_medium.'], - 'add_to_reusable_medium': ['You can only specify either use_reusable_medium or add_to_reusable_medium.'], - }) - return data @@ -1602,7 +1589,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): pos_data['attendee_name_parts'] = { '_legacy': attendee_name } - pos = OrderPosition(**{k: v for k, v in pos_data.items() if k not in ('answers', '_quotas', 'use_reusable_medium', 'add_to_reusable_medium')}) + pos = OrderPosition(**{k: v for k, v in pos_data.items() if k not in ('answers', '_quotas', 'use_reusable_medium')}) if simulate: pos.order = order._wrapped else: @@ -1676,7 +1663,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer): for pos_data in positions_data: answers_data = pos_data.pop('answers', []) use_reusable_medium = pos_data.pop('use_reusable_medium', None) - add_to_reusable_medium = pos_data.pop('add_to_reusable_medium', None) pos = pos_data['__instance'] pos._calculate_tax(invoice_address=ia) @@ -1718,14 +1704,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer): answ.options.add(*options) if use_reusable_medium: - for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True): - use_reusable_medium.log_action( - 'pretix.reusable_medium.linked_orderposition.removed', - data={ - 'linked_orderposition': op_pk, - } - ) - use_reusable_medium.linked_orderpositions.set([pos]) + if pos.item.media_policy not in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW): + for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True): + use_reusable_medium.log_action( + 'pretix.reusable_medium.linked_orderposition.removed', + data={ + 'linked_orderposition': op_pk, + } + ) + use_reusable_medium.linked_orderpositions.set([pos]) + else: + use_reusable_medium.linked_orderpositions.add(pos) use_reusable_medium.log_action( 'pretix.reusable_medium.linked_orderposition.added', data={ @@ -1733,15 +1722,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): 'linked_orderposition': pos.pk, } ) - elif add_to_reusable_medium: - add_to_reusable_medium.linked_orderpositions.add(pos) - add_to_reusable_medium.log_action( - 'pretix.reusable_medium.linked_orderposition.added', - data={ - 'by_order': order.code, - 'linked_orderposition': pos.pk, - } - ) + use_reusable_medium.touch() if not simulate: for cp in delete_cps: 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 6652b96c9f..5016d54f9a 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -69,8 +69,10 @@ 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, RequiredMediaExchangeError, RequiredQuestionsError, SQLLogic, + perform_checkin, ) +from pretix.base.services.media import perform_media_exchange from pretix.base.signals import checkin_annulled from pretix.helpers import OF_SELF @@ -454,7 +456,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): + source_type='barcode', legacy_url_support=False, simulate=False, gate=None, use_order_locale=False, + exchange_medium_type=None, exchange_medium_identifier=None): if not checkinlists: raise ValidationError('No check-in list passed.') @@ -463,6 +466,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) + medium = None context = { 'request': request, @@ -522,7 +526,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, # with respecting the force option), or it's a reusable medium (-> proceed with that) if not op_candidates: try: - media = ReusableMedium.objects.active().filter( + medium = ReusableMedium.objects.active().filter( Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk'))) ).get( organizer_id=checkinlists[0].event.organizer_id, @@ -630,7 +634,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, }, status=400) else: - linked_ops = media.linked_orderpositions.all().select_related("order").prefetch_related("addons") + linked_ops = medium.linked_orderpositions.all().select_related("order").prefetch_related("addons") linked_event_ids = {op.order.event_id for op in linked_ops} if not any(event_id in list_by_event for event_id in linked_event_ids): # Medium exists but connected ticket is for the wrong event @@ -661,7 +665,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, op_candidates = [] for op in linked_ops: if op.order.event_id in list_by_event: - reusable_medium_used = media + reusable_medium_used = medium op_candidates.append(op) if list_by_event[op.order.event_id].addon_match: op_candidates += list(op.addons.all()) @@ -804,7 +808,14 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, locale = op.order.event.settings.locale with language(locale): try: - perform_checkin( + if exchange_medium_identifier and medium: + # Cannot scan a medium and then request to exchange it + raise CheckInError( + gettext('You cannot exchange a medium for a medium.'), + 'error' + ) + + checkin_args = dict( op=op, clist=list_by_event[op.order.event_id], given_answers=given_answers, @@ -822,7 +833,25 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, from_revoked_secret=from_revoked_secret, simulate=simulate, gate=gate, + reusable_medium=medium, ) + + if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate + with transaction.atomic(): + # Do exchange and check-in atomically, i.e. both succeed or both fail + medium = perform_media_exchange( + organizer=request.organizer, + media_type=exchange_medium_type, + identifier=exchange_medium_identifier, + link_orderposition=op, + user=user, + auth=auth, + ) + source_type = medium.media_type.identifier + checkin_args['reusable_medium'] = medium + perform_checkin(**checkin_args) + else: + perform_checkin(**checkin_args) except RequiredQuestionsError as e: return Response({ 'status': 'incomplete', @@ -834,6 +863,17 @@ 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, + 'reason_explanation': e.msg, + }, status=400) except CheckInError as e: if not simulate: op.order.log_action('pretix.event.checkin.denied', data={ @@ -1021,6 +1061,8 @@ class CheckinRPCRedeemView(views.APIView): canceled_supported=True, request=self.request, # this is not clean, but we need it in the serializers for URL generation legacy_url_support=False, + exchange_medium_type=s.validated_data.get('exchange_medium_type'), + exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'), ) diff --git a/src/pretix/api/views/media.py b/src/pretix/api/views/media.py index 7b1509128d..5c20079303 100644 --- a/src/pretix/api/views/media.py +++ b/src/pretix/api/views/media.py @@ -196,7 +196,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet): return Response({"result": None}) - @scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce + @scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some performance def list(self, request, **kwargs): date = serializers.DateTimeField().to_representation(now()) queryset = self.filter_queryset(self.get_queryset()) diff --git a/src/pretix/base/media.py b/src/pretix/base/media.py index 47a1be987e..f6a4b24fdf 100644 --- a/src/pretix/base/media.py +++ b/src/pretix/base/media.py @@ -26,6 +26,7 @@ from django.utils.translation import gettext_lazy as _ class BaseMediaType: medium_created_by_server = False + medium_created_from_unknown_supported = False supports_orderposition = False supports_giftcard = False @@ -56,7 +57,7 @@ class BaseMediaType: def is_active(self, organizer): return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False) - def handle_unknown(self, organizer, identifier, user, auth): + def handle_unknown(self, organizer, identifier, user, auth, force_create=False): pass def handle_new(self, organizer, medium, user, auth): @@ -88,23 +89,32 @@ class NfcUidMediaType(BaseMediaType): verbose_name = _('NFC UID-based') icon = 'pretixbase/img/media/nfc_uid.svg' medium_created_by_server = False + medium_created_from_unknown_supported = True supports_giftcard = True - supports_orderposition = False + supports_orderposition = True - def handle_unknown(self, organizer, identifier, user, auth): + def handle_unknown(self, organizer, identifier, user, auth, force_create=False): from pretix.base.models import GiftCard, ReusableMedium - if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool): + create_giftcard = organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool) + if create_giftcard or force_create: if identifier.startswith("08"): # Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random # UIDs on every read, so they won't be useful. return with transaction.atomic(): - gc = GiftCard.objects.create( - issuer=organizer, - expires=organizer.default_gift_card_expiry, - currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'), - ) + if create_giftcard: + gc = GiftCard.objects.create( + issuer=organizer, + expires=organizer.default_gift_card_expiry, + currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'), + ) + gc.log_action( + 'pretix.giftcards.created', + user=user, auth=auth, + ) + else: + gc = None m = ReusableMedium.objects.create( type=self.identifier, identifier=identifier, @@ -116,10 +126,6 @@ class NfcUidMediaType(BaseMediaType): 'pretix.reusable_medium.created.auto', user=user, auth=auth, ) - gc.log_action( - 'pretix.giftcards.created', - user=user, auth=auth, - ) return m @@ -129,7 +135,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..bd06bb11e0 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -346,11 +346,14 @@ class Checkin(models.Model): REASON_INCOMPLETE = 'incomplete' REASON_ALREADY_REDEEMED = 'already_redeemed' REASON_AMBIGUOUS = 'ambiguous' + REASON_MEDIUM_INVALID = 'medium_invalid' + REASON_MEDIUM_EXISTS = 'medium_exists' REASON_ERROR = 'error' REASON_BLOCKED = 'blocked' 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 +369,9 @@ 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')), + (REASON_MEDIUM_INVALID, _('Reusable medium invalid')), + (REASON_MEDIUM_EXISTS, _('Reusable medium already exists')), ) successful = models.BooleanField( diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index feb7dec8a5..79f1229d15 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -452,11 +452,16 @@ class Item(LoggedModel): MEDIA_POLICY_REUSE = 'reuse' MEDIA_POLICY_NEW = 'new' MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new' + MEDIA_POLICY_APPEND = 'append' + MEDIA_POLICY_APPEND_OR_NEW = 'append_or_new' MEDIA_POLICIES = ( - (None, _("Don't use re-usable media, use regular one-off tickets")), - (MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')), + (None, _("Don't use reusable media, use regular one-off tickets")), (MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')), - (MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')), + (MEDIA_POLICY_REUSE, _('Require an existing medium to be reused, replacing any previous tickets')), + (MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used, replacing any previous tickets')), + (MEDIA_POLICY_APPEND, _('Require an existing medium to be reused, adding to any previous tickets')), + (MEDIA_POLICY_APPEND_OR_NEW, + _('Require either an existing or a new medium to be used, adding to any previous tickets')), ) objects = ItemQuerySetManager() @@ -769,7 +774,7 @@ class Item(LoggedModel): null=True, blank=True, max_length=16, verbose_name=_('Reusable media policy'), help_text=_( - 'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. ' + 'If this product should be stored on a reusable physical medium, you can attach a physical media policy. ' 'This is not required for regular tickets, which just use a one-time barcode, but only for products like ' 'renewable season tickets or re-chargeable gift card wristbands. ' 'This is an advanced feature that also requires specific configuration of ticketing and printing settings.' @@ -778,7 +783,7 @@ class Item(LoggedModel): media_type = models.CharField( max_length=100, null=True, blank=True, - choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()], + choices=[(None, _("Don't use reusable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()], verbose_name=_('Reusable media type'), help_text=_( 'Select the type of physical medium that should be used for this product. Note that not all media types ' @@ -995,6 +1000,11 @@ class Item(LoggedModel): raise ValidationError(_('The selected media type does not support usage for tickets currently.')) if not mt.supports_giftcard and issue_giftcard: raise ValidationError(_('The selected media type does not support usage for gift cards currently.')) + if media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + if not mt.medium_created_by_server and not mt.medium_created_from_unknown_supported: + raise ValidationError(_('The selected media type requires all media to be registered in the system ' + 'prior to their usage. Therefore, the selected media policy does not make ' + 'sense for this media type.')) if issue_giftcard: raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, ' 'gift cards for some reusable media types can be created or re-charged directly ' @@ -2220,7 +2230,7 @@ class Quota(LoggedModel): class ItemMetaProperty(LoggedModel): """ An event can have ItemMetaProperty objects attached to define meta information fields - for its items. This information can be re-used for example in ticket layouts. + for its items. This information can be reused for example in ticket layouts. :param event: The event this property is defined for. :type event: Event diff --git a/src/pretix/base/models/media.py b/src/pretix/base/models/media.py index eabefae59f..097c7803f4 100644 --- a/src/pretix/base/models/media.py +++ b/src/pretix/base/models/media.py @@ -129,7 +129,10 @@ class ReusableMedium(LoggedModel): @property def is_expired(self): - return self.expires and self.expires > now() + return self.expires and self.expires < now() + + def touch(self): + self.save(update_fields=['updated']) class Meta: unique_together = (("identifier", "type", "organizer"),) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 99604e637c..c9ddf51a9a 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -287,11 +287,11 @@ def _check_position_constraints( raise CartPositionError(error_messages['unavailable']) # Invalid media policy for online sale - if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + if item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): mt = MEDIA_TYPES[item.media_type] if not mt.medium_created_by_server: raise CartPositionError(error_messages['media_usage_not_implemented']) - elif item.media_policy == Item.MEDIA_POLICY_REUSE: + elif item.media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND): raise CartPositionError(error_messages['media_usage_not_implemented']) # Item removed from sales channel diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index ecf4cf7a9f..3ac7b6792b 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, reusable_medium=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. @@ -955,6 +964,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, :param datetime: The datetime of the checkin, defaults to now. :param simulate: If true, the check-in is not saved. :param gate: The gate the check-in was performed at. + :param reusable_medium: The medium that is available for an exchange """ # !!!!!!!!! @@ -1035,7 +1045,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, with transaction.atomic(): # 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 + opqs = OrderPosition.all.select_related("order", "item") if type != Checkin.TYPE_EXIT: opqs = opqs.select_for_update(of=OF_SELF) op = opqs.get(pk=op.pk) @@ -1101,6 +1111,24 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, require_answers ) + required_media_policy = op.item.media_policy + required_media_type = op.item.media_type + require_a_medium = required_media_policy and required_media_type + linked_media = op.linked_media + if require_a_medium and not reusable_medium and not force: + if not linked_media.exists(): + raise RequiredMediaExchangeError( + _('Ticket needs to be exchanged to a suitable medium.'), + 'exchange', + required_media_policy, + required_media_type + ) + elif op.organizer.settings.reusable_media_usage_enforced: + raise CheckInError( + _('This ticket has already been exchanged for a reusable medium that now needs to be used instead.'), + 'already_exchanged', + ) + device = None if isinstance(auth, Device): device = auth diff --git a/src/pretix/base/services/media.py b/src/pretix/base/services/media.py index c431e79ae3..6327242194 100644 --- a/src/pretix/base/services/media.py +++ b/src/pretix/base/services/media.py @@ -23,10 +23,13 @@ import secrets from django.db import IntegrityError from django.db.models import Q +from django.utils.translation import gettext as _ from django_scopes import scopes_disabled -from pretix.base.models import GiftCardAcceptance -from pretix.base.models.media import MediumKeySet +from pretix.base.media import MEDIA_TYPES +from pretix.base.models import Checkin, GiftCardAcceptance, Item +from pretix.base.models.media import MediumKeySet, ReusableMedium +from pretix.base.services.checkin import CheckInError def create_nfc_mf0aes_keyset(organizer): @@ -70,3 +73,174 @@ def get_keysets_for_organizer(organizer): if new_set: sets.append(new_set) return sets + + +def perform_media_exchange(organizer, media_type, identifier, link_orderposition, user, auth): + """ + Create or retrieve a medium, then link the order position to it. Expected to be called in a transaction. + + :param organizer: Organizer to operate in + :param media_type: Type of medium to operate with + :param identifier: Identifier of the medium + :param link_orderposition: Position to link to the medium + :return: ReusableMedium + """ + medium = None + media_policy = link_orderposition.item.media_policy + + if media_type not in MEDIA_TYPES: # should be caught by serializer already + raise CheckInError( + _('Invalid medium type.'), + Checkin.REASON_ERROR, + reason=_('Invalid medium type.'), + ) + + if not MEDIA_TYPES[media_type].is_active(organizer): + raise CheckInError( + _('Medium type is not enabled for organizer.'), + Checkin.REASON_ERROR, + reason=_('Medium type is not enabled for organizer.'), + ) + + if link_orderposition.item.media_type != media_type: + raise CheckInError( + _('Incorrect medium type for product.'), + Checkin.REASON_PRODUCT, + reason=_('Incorrect medium type for product.'), + ) + + if link_orderposition.linked_media.exists(): + raise CheckInError( + _('Ticket is already exchanged for reusable medium.'), + Checkin.REASON_ALREADY_EXCHANGED, + reason=_('Ticket is already exchanged for reusable medium.'), + ) + + if media_policy in (Item.MEDIA_POLICY_APPEND, Item.MEDIA_POLICY_APPEND_OR_NEW, Item.MEDIA_POLICY_NEW): + link_action = "append" + else: + link_action = "replace" + + if media_policy in (Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_APPEND): + try: + medium = ReusableMedium.objects.get( + type=media_type, + identifier=identifier, + organizer=organizer, + ) + except ReusableMedium.DoesNotExist: + raise CheckInError( + _('Reusable medium not found.'), + Checkin.REASON_MEDIUM_INVALID, + reason=_('Reusable medium not found.'), + ) + else: + if medium.is_expired or not medium.active: + raise CheckInError( + _('Reusable medium is inactive or expired.'), + Checkin.REASON_MEDIUM_INVALID, + reason=_('Reusable medium is inactive or expired.'), + ) + + elif media_policy in (Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW): + try: + medium = ReusableMedium.objects.get( + type=media_type, + identifier=identifier, + organizer=organizer, + ) + except ReusableMedium.DoesNotExist: + if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported: + raise CheckInError( + _('Reusable medium not found and could not be created.'), + Checkin.REASON_MEDIUM_INVALID, + ) + + medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True) + if not medium: + raise CheckInError( + _('Reusable medium not found and could not be created.'), + Checkin.REASON_MEDIUM_INVALID, + ) + + if medium.is_expired or not medium.active: + raise CheckInError( + _('Reusable medium is inactive or expired.'), + Checkin.REASON_MEDIUM_INVALID, + reason=_('Reusable medium is inactive or expired.'), + ) + + elif media_policy == Item.MEDIA_POLICY_NEW: + if not MEDIA_TYPES[media_type].medium_created_from_unknown_supported: + raise CheckInError( + _('Reusable medium not found and could not be created.'), + Checkin.REASON_MEDIUM_INVALID, + ) + try: + medium = MEDIA_TYPES[media_type].handle_unknown(organizer, identifier, user, auth, force_create=True) + except IntegrityError: + raise CheckInError( + _('Reusable medium already exists.'), + Checkin.REASON_MEDIUM_EXISTS, + ) + else: + if not medium: + raise CheckInError( + _('Reusable medium could not be created.'), + Checkin.REASON_MEDIUM_INVALID, + ) + + else: + raise CheckInError( + _('Product does not support medium exchange.'), + Checkin.REASON_PRODUCT, + reason=_('Product does not support medium exchange.'), + ) + + if link_action == 'append': + medium.linked_orderpositions.add(link_orderposition) + medium.log_action( + 'pretix.reusable_medium.linked_orderposition.added', + user=user, + auth=auth, + data={ + 'linked_orderposition': link_orderposition, + } + ) + elif link_action == 'replace': + already_found = False + for op_pk in medium.linked_orderpositions.values_list('pk', flat=True): + if op_pk == link_orderposition.pk: + already_found = True + continue + else: + medium.log_action( + 'pretix.reusable_medium.linked_orderposition.removed', + data={ + 'linked_orderposition': op_pk, + } + ) + if not already_found: + medium.linked_orderpositions.set([link_orderposition]) + medium.log_action( + 'pretix.reusable_medium.linked_orderposition.added', + user=user, + auth=auth, + data={ + 'linked_orderposition': link_orderposition, + } + ) + + link_orderposition.order.log_action( + 'pretix.reusable_medium.exchanged', + data={ + 'position': link_orderposition.pk, + 'positionid': link_orderposition.positionid, + 'medium': medium.pk, + 'medium_identifier': medium.identifier, + 'medium_type': medium.media_type.identifier, + } + ) + medium.touch() + + return medium diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index cdd0cd2f53..dc73363301 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -3506,7 +3506,7 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs): from pretix.base.models import ReusableMedium for p in order.positions.all(): - if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW): + if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW, Item.MEDIA_POLICY_APPEND_OR_NEW): mt = MEDIA_TYPES[p.item.media_type] if mt.medium_created_by_server and not p.linked_media.exists(): rm = ReusableMedium.objects.create( diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index f3170a0b9f..ec6224e768 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -211,12 +211,25 @@ DEFAULTS = { 'form_class': forms.BooleanField, 'serializer_class': serializers.BooleanField, 'form_kwargs': dict( - label=_("Activate re-usable media"), - help_text=_("The re-usable media feature allows you to connect tickets and gift cards with physical media " - "such as wristbands or chip cards that may be re-used for different tickets or gift cards " + label=_("Activate reusable media"), + help_text=_("The reusable media feature allows you to connect tickets and gift cards with physical media " + "such as wristbands or chip cards that may be reused for different tickets or gift cards " "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 reusable media for check-in"), + help_text=_("If enabled, a ticket barcode will not be accepted anymore, if a reusable medium has been " + "created and linked to a ticket. Keeping this option turned off will treat the reusable " + "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/organizer.py b/src/pretix/control/forms/organizer.py index a1e39981b6..f4175ddcda 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -636,6 +636,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/logdisplay.py b/src/pretix/control/logdisplay.py index 84e3365ae8..1204fcc834 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -746,6 +746,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType): 'pretix.reusable_medium.linked_orderposition.added': _('A new ticket has been added to the medium.'), 'pretix.reusable_medium.linked_orderposition.removed': _('A ticket has been removed from the medium.'), 'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'), + 'pretix.reusable_medium.exchanged': _('The ticket #{positionid} was exchanged for reusable medium {medium_identifier}.'), 'pretix.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'), 'pretix.email.error': _('Sending of an email has failed.'), 'pretix.event.comment': _('The event\'s internal comment has been updated.'), diff --git a/src/pretix/control/templates/pretixcontrol/checkin/simulator.html b/src/pretix/control/templates/pretixcontrol/checkin/simulator.html index 2ebb94a307..48b5c5e240 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/simulator.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/simulator.html @@ -54,6 +54,8 @@ {% elif result.status == "incomplete" %} + {% elif result.status == "exchange" %} + {% elif result.status == "error" %} {% if result.reason == "already_redeemed" %} @@ -79,6 +81,14 @@ {% endfor %} + {% elif result.status == "exchange" %} +

{% trans "Media exchange required" %}

+

+ {% blocktrans trimmed with media_policy=media_policies|getitem:result.media_policy media_type=media_types|getitem:result.media_type %} + This ticket needs to be exchanged into a {{ media_type }} reusable medium. + {{ media_policy }}. + {% endblocktrans %} +

{% elif result.status == "error" %}

{{ reason_labels|getitem:result.reason }}

{% if result.reason_explanation %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/edit.html b/src/pretix/control/templates/pretixcontrol/organizers/edit.html index 2cfb241a7f..960c6fb4f5 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/edit.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/edit.html @@ -222,6 +222,7 @@
{% trans "Reusable media" %} {% bootstrap_field sform.reusable_media_active layout="control" %} + {% bootstrap_field sform.reusable_media_usage_enforced layout="control" %}
diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py index 51775210c1..446aad2733 100644 --- a/src/pretix/control/views/checkin.py +++ b/src/pretix/control/views/checkin.py @@ -50,7 +50,7 @@ from i18nfield.strings import LazyI18nString from pretix.api.views.checkin import _redeem_process from pretix.base.media import MEDIA_TYPES -from pretix.base.models import Checkin, LogEntry, Order, OrderPosition +from pretix.base.models import Checkin, Item, LogEntry, Order, OrderPosition from pretix.base.models.checkin import CheckinList from pretix.base.models.orders import PrintLog from pretix.base.permissions import AnyPermissionOf @@ -533,6 +533,8 @@ class CheckInListSimulator(EventPermissionRequiredMixin, FormView): checkinlist=self.list, result=self.result, reason_labels=dict(Checkin.REASONS), + media_policies=dict(Item.MEDIA_POLICIES), + media_types=dict(MEDIA_TYPES), ) def form_valid(self, form): diff --git a/src/pretix/static/jsi18n/en/djangojs.js b/src/pretix/static/jsi18n/en/djangojs.js index 5dee27a8f0..4d833a7135 100644 --- a/src/pretix/static/jsi18n/en/djangojs.js +++ b/src/pretix/static/jsi18n/en/djangojs.js @@ -1,5 +1,4 @@ - 'use strict'; { const globals = this; diff --git a/src/pretix/static/pretixcontrol/scss/main.scss b/src/pretix/static/pretixcontrol/scss/main.scss index 67b3f22b76..3941b3304e 100644 --- a/src/pretix/static/pretixcontrol/scss/main.scss +++ b/src/pretix/static/pretixcontrol/scss/main.scss @@ -864,6 +864,9 @@ tbody th { .checkin-sim-result-status-incomplete { background: $brand-primary; } +.checkin-sim-result-status-exchange { + background: $brand-primary; +} .checkin-sim-result-status-error { background: $brand-danger; } diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 78885344a2..0c5c387a94 100644 --- a/src/tests/api/test_checkinrpc.py +++ b/src/tests/api/test_checkinrpc.py @@ -34,7 +34,7 @@ from tests.const import SAMPLE_PNG from pretix.api.serializers.item import QuestionSerializer from pretix.base.models import ( - Checkin, InvoiceAddress, Order, OrderPosition, ReusableMedium, + Checkin, InvoiceAddress, Item, Order, OrderPosition, ReusableMedium, ) # Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering @@ -1253,3 +1253,489 @@ def test_annul_failures(device_client, team, organizer, clist, clist_event2, eve with scopes_disabled(): ci = p.all_checkins.get() assert ci.successful + + +@pytest.mark.django_db +def test_exchange_incomplete_body(token_client, organizer, clist, event, order): + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid" + }) + assert resp.status_code == 400 + assert resp.data == { + 'non_field_errors': ['If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.'] + } + + +@pytest.mark.django_db +def test_exchange_medium_for_medium(token_client, organizer, clist, event, order): + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="barcode", + identifier="abcdef", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "abcdef", { + "source_type": "barcode", + "exchange_medium_type": "barcode", + "exchange_medium_identifier": "hijkl", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'error' + + +@pytest.mark.django_db +def test_exchange_unknown_media_type(token_client, organizer, clist, event, order): + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "unknown", + "exchange_medium_identifier": "hijkl", + }) + assert resp.status_code == 400 + assert resp.data == {"exchange_medium_type": ["\"unknown\" is not a valid choice."]} + + +@pytest.mark.django_db +def test_exchange_disabled_media_type(token_client, organizer, clist, event, order): + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "hijkl", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'error' + assert resp.data['reason_explanation'] == 'Medium type is not enabled for organizer.' + + +@pytest.mark.django_db +def test_exchange_mismatch_media_type(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "barcode" + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'product' + assert resp.data['reason_explanation'] == 'Incorrect medium type for product.' + + +@pytest.mark.django_db +def test_exchange_no_item_policy(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'product' + assert resp.data['reason_explanation'] == 'Product does not support medium exchange.' + + +@pytest.mark.django_db +def test_exchange_reuse_or_new_new(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_REUSE_OR_NEW + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + rm = ReusableMedium.objects.get( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + assert rm.linked_orderpositions.get().secret == "z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + + +@pytest.mark.django_db +def test_exchange_reuse_or_new_reuse_replace(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_REUSE_OR_NEW + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + rm.refresh_from_db() + with scopes_disabled(): + assert rm.linked_orderpositions.get().secret == "z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + + +@pytest.mark.django_db +def test_exchange_reuse_or_new_reuse_append(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_APPEND_OR_NEW + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + rm.refresh_from_db() + with scopes_disabled(): + assert rm.linked_orderpositions.count() == 2 + assert rm.linked_orderpositions.filter(secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w").exists() + + +@pytest.mark.django_db +def test_exchange_reuse_exists_append(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_APPEND_OR_NEW + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + rm.refresh_from_db() + with scopes_disabled(): + assert rm.linked_orderpositions.count() == 2 + assert rm.linked_orderpositions.filter(secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w").exists() + + +@pytest.mark.django_db +def test_exchange_reuse_expired(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_REUSE + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + expires=now() - datetime.timedelta(hours=2), + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_invalid' + + +@pytest.mark.django_db +def test_exchange_reuse_not_exists(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_REUSE + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_invalid' + + +@pytest.mark.django_db +def test_exchange_new_exists(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, + ) + rm.linked_orderpositions.add(order.positions.last()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + "exchange_link_action": "append", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_exists' + + +@pytest.mark.django_db +def test_exchange_new_not_exists(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() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + "exchange_link_action": "replace", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + rm = ReusableMedium.objects.get( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + assert rm.linked_orderpositions.get().secret == "z3fsn8jyufm5kpk768q69gkbyr5f4h6w" + + +@pytest.mark.django_db +def test_exchange_required(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() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'exchange' + assert resp.data['media_policy'] == 'new' + assert resp.data['media_type'] == 'nfc_uid' + + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + # Force works + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "force": True, + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + +@pytest.mark.django_db +def test_exchanged_original_barcode_ok(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, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + +@pytest.mark.django_db +def test_exchanged_original_barcode_not_ok(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + organizer.settings.reusable_media_usage_enforced = 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, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'already_exchanged' + # Force works + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "force": True, + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + +@pytest.mark.django_db +def test_exchanged_scan_medium_ok(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + organizer.settings.reusable_media_usage_enforced = 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, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "12345678", { + "source_type": "nfc_uid", + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + +@pytest.mark.django_db +def test_exchanged_double_exchange(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + organizer.settings.reusable_media_usage_enforced = False + 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, + ) + rm.linked_orderpositions.add(order.positions.first()) + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "87654321", + "exchange_link_action": "replace", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'already_exchanged' + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "media_policy,media_type", + [ + (Item.MEDIA_POLICY_NEW, "nfc_mf0aes"), + (Item.MEDIA_POLICY_REUSE_OR_NEW, "nfc_mf0aes"), + (Item.MEDIA_POLICY_APPEND_OR_NEW, "nfc_mf0aes"), + (Item.MEDIA_POLICY_NEW, "barcode"), + (Item.MEDIA_POLICY_REUSE_OR_NEW, "barcode"), + (Item.MEDIA_POLICY_APPEND_OR_NEW, "barcode"), + ] +) +def test_exchange_unsupported_media_type_for_new(token_client, organizer, clist, event, order, item, media_policy, media_type): + organizer.settings.set(f'reusable_media_type_{media_type}', True) + # Shouldn't be configurable, but test that the logic is solid anyway + item.media_type = media_type + item.media_policy = media_policy + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": media_type, + "exchange_medium_identifier": "12345678", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_invalid' + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "media_policy", + [ + Item.MEDIA_POLICY_NEW, + Item.MEDIA_POLICY_REUSE_OR_NEW, + Item.MEDIA_POLICY_APPEND_OR_NEW, + ] +) +def test_exchange_rejected_media_identifier(token_client, organizer, clist, event, order, item, media_policy): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = media_policy + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "08RANDOM", + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'medium_invalid' + + +@pytest.mark.django_db +@pytest.mark.parametrize( + "media_policy", + [ + Item.MEDIA_POLICY_NEW, + Item.MEDIA_POLICY_REUSE_OR_NEW, + Item.MEDIA_POLICY_APPEND_OR_NEW, + ] +) +def test_exchange_create_gift_card(token_client, organizer, clist, event, order, item, media_policy): + organizer.settings.reusable_media_type_nfc_uid = True + organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard = True + organizer.settings.reusable_media_type_nfc_uid_autocreate_giftcard_currency = "EUR" + item.media_type = "nfc_uid" + item.media_policy = media_policy + item.save() + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "0412345", + }) + assert resp.status_code == 201 + with scopes_disabled(): + rm = ReusableMedium.objects.get(identifier="0412345") + assert rm.linked_giftcard.currency == "EUR" diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index 686405c5d9..e055231b17 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -3141,24 +3141,13 @@ def test_order_create_use_medium(token_client, organizer, event, item, quota, qu @pytest.mark.django_db def test_order_create_add_to_medium(token_client, organizer, event, item, quota, question, medium): item.media_type = medium.type - item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW + item.media_policy = Item.MEDIA_POLICY_APPEND_OR_NEW item.save() res = copy.deepcopy(ORDER_CREATE_PAYLOAD) res['positions'][0]['item'] = item.pk res['positions'][0]['use_reusable_medium'] = medium.pk - res['positions'][0]['add_to_reusable_medium'] = medium.pk res['positions'][0]['answers'][0]['question'] = question.pk - # do not use use_reusable_medium and add_to_reusable_medium - resp = token_client.post( - '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( - organizer.slug, event.slug - ), format='json', data=res - ) - assert resp.status_code == 400 - - del res['positions'][0]['use_reusable_medium'] - resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( organizer.slug, event.slug @@ -3179,8 +3168,9 @@ def test_order_create_add_to_medium(token_client, organizer, event, item, quota, medium.refresh_from_db() assert medium.linked_orderpositions.count() == 2 + item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW + item.save() res['positions'][0]['use_reusable_medium'] = medium.pk - del res['positions'][0]['add_to_reusable_medium'] resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format( organizer.slug, event.slug diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py index 04f51d4a65..2fb7559b04 100644 --- a/src/tests/base/test_checkin.py +++ b/src/tests/base/test_checkin.py @@ -1186,7 +1186,7 @@ def test_rules_reasoning_prefer_number_over_date(event, position, clist): @pytest.mark.django_db(transaction=True) def test_position_queries(django_assert_max_num_queries, position, clist): - with django_assert_max_num_queries(13) as captured: + with django_assert_max_num_queries(12) as captured: perform_checkin(position, clist, {}) if 'sqlite' not in settings.DATABASES['default']['ENGINE']: assert any('FOR UPDATE' in s['sql'] for s in captured) diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 8f39fc08e0..821a4fc5f3 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -4123,8 +4123,8 @@ def test_giftcard_multiple(event): for p in order.payments.all(): p.payment_provider.execute_payment(None, p) - assert order.payments.get(info__icontains=gc1.pk).amount == Decimal('12.00') - assert order.payments.get(info__icontains=gc2.pk).amount == Decimal('11.00') + assert order.payments.get(amount=Decimal("12.00")).info_data["gift_card"] == gc1.pk + assert order.payments.get(amount=Decimal("11.00")).info_data["gift_card"] == gc2.pk gc1 = GiftCard.objects.get(pk=gc1.pk) assert gc1.value == 0 gc2 = GiftCard.objects.get(pk=gc2.pk)