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 <maxi@richt.name>

* 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 <mail@raphaelmichel.de>

* 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 <maxi@richt.name>
Co-authored-by: Maximilian Richt <richt@pretix.eu>
Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: Raphael Michel <michel@pretix.eu>
This commit is contained in:
Martin Gross
2026-06-11 16:25:13 +02:00
committed by GitHub
co-authored by robbi5 Raphael Michel Maximilian Richt Raphael Michel Raphael Michel
parent 784577d86f
commit 775fdd1ccb
31 changed files with 875 additions and 95 deletions
+487 -1
View File
@@ -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"
+3 -13
View File
@@ -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