diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index 0c67cfe344..93378d2987 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -99,6 +99,13 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer): 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", "exchange_link_action"] + 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, exchange_medium_identifier, or " + "exchange_link_action, you need to set all of them.") + return attrs + class MiniCheckinListSerializer(I18nAwareModelSerializer): event = serializers.SlugRelatedField(slug_field='slug', read_only=True) diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index e59cf63d57..eee0741366 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -466,7 +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) - media = None + medium = None context = { 'request': request, @@ -805,15 +805,12 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, locale = op.order.event.settings.locale with language(locale): try: - exchange_requested = any(k is not None for k in [exchange_medium_type, exchange_medium_identifier, exchange_link_action]) - - if exchange_requested: - if any(k is None for k in [exchange_medium_type, exchange_medium_identifier, exchange_link_action]): - raise ValidationError("If you set any of exchange_medium_type, exchange_medium_identifier, or " - "èxchange_link_action, you need to set all of them.") - if medium: - # Cannot scan a medium and then request to exchange it - raise ReusableMedium.DuplicateEntry() + if exchange_link_action 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, @@ -836,7 +833,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, reusable_medium=medium, ) - if exchange_requested: + if exchange_link_action: # 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( @@ -848,11 +845,9 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, user=user, auth=auth, ) - source_type = media.media_type.identifier - checkin_args['medium'] = medium - perform_checkin( - reusable_media=media, - ) + source_type = medium.media_type.identifier + checkin_args['reusable_medium'] = medium + perform_checkin(**checkin_args) else: perform_checkin(**checkin_args) except RequiredQuestionsError as e: @@ -877,26 +872,6 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, 'reason_explanation': e.msg, }, status=400) - except ReusableMedium.DoesNotExist: - return Response({ - 'status': 'error', - 'reason': Checkin.REASON_MEDIUM_INVALID, - 'reason_explanation': 'Reusable medium identifier not found', - 'require_attention': op.require_checkin_attention, - 'checkin_texts': op.checkin_texts, - 'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data, - 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, - }, status=400) - except ReusableMedium.DuplicateEntry: - return Response({ - 'status': 'error', - 'reason': Checkin.REASON_MEDIUM_EXISTS, - 'reason_explanation': 'Reusable medium identifier already exists', - 'require_attention': op.require_checkin_attention, - 'checkin_texts': op.checkin_texts, - 'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data, - '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={ diff --git a/src/pretix/base/models/media.py b/src/pretix/base/models/media.py index 88698f855e..eabefae59f 100644 --- a/src/pretix/base/models/media.py +++ b/src/pretix/base/models/media.py @@ -138,9 +138,6 @@ class ReusableMedium(LoggedModel): ] ordering = "identifier", "type", "organizer" - class DuplicateEntry(Exception): - pass - class MediumKeySet(models.Model): organizer = models.ForeignKey('Organizer', on_delete=models.CASCADE, related_name='medium_key_sets') diff --git a/src/pretix/base/services/media.py b/src/pretix/base/services/media.py index 79037c332c..903cdd4479 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, Item +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): @@ -88,14 +91,47 @@ def perform_media_exchange(organizer, media_type, identifier, link_action, link_ if link_action not in ('append', 'replace'): raise ValueError("Invalid link_action") - if media_policy == Item.MEDIA_POLICY_REUSE: - # Will and should raise ReusableMedium.DoesNotExist if not found - medium = ReusableMedium.objects.get( - type=media_type, - identifier=identifier, - organizer=organizer, + 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 == Item.MEDIA_POLICY_REUSE: + 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, + ) + elif media_policy == Item.MEDIA_POLICY_REUSE_OR_NEW: medium, created = ReusableMedium.objects.get_or_create( type=media_type, @@ -117,7 +153,10 @@ def perform_media_exchange(organizer, media_type, identifier, link_action, link_ organizer=organizer, ) except IntegrityError: - raise ReusableMedium.DuplicateEntry() + raise CheckInError( + _('Reusable medium already exists.'), + Checkin.REASON_MEDIUM_EXISTS, + ) else: medium.log_action( 'pretix.reusable_medium.created.auto', @@ -125,6 +164,13 @@ def perform_media_exchange(organizer, media_type, identifier, link_action, link_ auth=auth, ) + 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) elif link_action == 'replace': diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 78885344a2..ad7cfc4627 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,397 @@ 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, exchange_medium_identifier, or ' + 'exchange_link_action, you need to set all 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", + "exchange_link_action": "replace", + }) + 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", + "exchange_link_action": "replace", + }) + 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", + "exchange_link_action": "replace", + }) + 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", + "exchange_link_action": "replace", + }) + 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", + "exchange_link_action": "replace", + }) + 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_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_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", + "exchange_link_action": "replace", + }) + 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_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", + "exchange_link_action": "append", + }) + 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_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", + "exchange_link_action": "append", + }) + 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_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", + "exchange_link_action": "replace", + }) + 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'