Refactor link_action into media policy, gift card support

This commit is contained in:
Raphael Michel
2026-06-09 18:03:43 +02:00
parent 60dad936e3
commit 4b50df285c
11 changed files with 156 additions and 75 deletions

View File

@@ -48,7 +48,6 @@ Checking a ticket in
allows for a certain level of idempotency and enables you to re-try after a connection failure.
:<json string exchange_medium_type: To perform an exchange to a reusable medium, pass the type of the new reusable medium
:<json string exchange_medium_identifier: To perform an exchange to a reusable media, pass the identifier of the new medium
:<json string exchange_link_action: To perform an exchange to a reusable media, pass `"append"` or `"replace"` depending on whether any previous ticket links of the medium should be kept
:<json boolean use_order_locale: Specifies that pretix should use the customer's language (``locale`` field from the
order) when building texts (currently only the ``reason_explanation`` response field).
Defaults to ``false`` in which case the server will determine the language (currently

View File

@@ -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.

View File

@@ -90,20 +90,15 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
answers = serializers.JSONField(required=False, allow_null=True)
exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
exchange_medium_identifier = serializers.CharField(required=False)
exchange_link_action = serializers.ChoiceField(required=False, choices=[
('append', 'append'),
('replace', 'replace'),
])
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", "exchange_link_action"]
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, exchange_medium_identifier, or "
"exchange_link_action, you need to set all of them.")
raise ValidationError("If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.")
return attrs

View File

@@ -457,7 +457,7 @@ 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,
exchange_medium_type=None, exchange_medium_identifier=None, exchange_link_action=None):
exchange_medium_type=None, exchange_medium_identifier=None):
if not checkinlists:
raise ValidationError('No check-in list passed.')
@@ -805,7 +805,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
locale = op.order.event.settings.locale
with language(locale):
try:
if exchange_link_action and medium:
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.'),
@@ -833,14 +833,13 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
reusable_medium=medium,
)
if exchange_link_action: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate
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_action=exchange_link_action,
link_orderposition=op,
user=user,
auth=auth,
@@ -1061,7 +1060,6 @@ class CheckinRPCRedeemView(views.APIView):
legacy_url_support=False,
exchange_medium_type=s.validated_data.get('exchange_medium_type'),
exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
exchange_link_action=s.validated_data.get('exchange_link_action'),
)

View File

@@ -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 = 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

View File

@@ -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 reusable media, use regular one-off tickets")),
(MEDIA_POLICY_REUSE, _('Require an existing medium to be reused')),
(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()

View File

@@ -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

View File

@@ -75,21 +75,18 @@ def get_keysets_for_organizer(organizer):
return sets
def perform_media_exchange(organizer, media_type, identifier, link_action, link_orderposition, user, auth):
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_action: one of `"append"` or `"replace"`
:param link_orderposition: Position to link to the medium
:return: ReusableMedium
"""
medium = None
media_policy = link_orderposition.item.media_policy
if link_action not in ('append', 'replace'):
raise ValueError("Invalid link_action")
if media_type not in MEDIA_TYPES: # should be caught by serializer already
raise CheckInError(
@@ -119,7 +116,12 @@ def perform_media_exchange(organizer, media_type, identifier, link_action, link_
reason=_('Ticket is already exchanged for reusable medium.'),
)
if media_policy == Item.MEDIA_POLICY_REUSE:
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,
@@ -140,19 +142,28 @@ def perform_media_exchange(organizer, media_type, identifier, link_action, link_
reason=_('Reusable medium is inactive or expired.'),
)
elif media_policy == Item.MEDIA_POLICY_REUSE_OR_NEW:
medium, created = ReusableMedium.objects.get_or_create(
type=media_type,
identifier=identifier,
organizer=organizer,
)
if created:
medium.log_action(
'pretix.reusable_medium.created.auto',
user=user,
auth=auth,
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,
)
elif medium.is_expired or not medium.active:
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,
@@ -160,23 +171,24 @@ def perform_media_exchange(organizer, media_type, identifier, link_action, link_
)
elif media_policy == Item.MEDIA_POLICY_NEW:
try:
medium = ReusableMedium.objects.create(
type=media_type,
identifier=identifier,
organizer=organizer,
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:
medium.log_action(
'pretix.reusable_medium.created.auto',
user=user,
auth=auth,
)
if not medium:
raise CheckInError(
_('Reusable medium could not be created.'),
Checkin.REASON_MEDIUM_INVALID,
)
else:
raise CheckInError(

View File

@@ -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(

View File

@@ -1,5 +1,4 @@
'use strict';
{
const globals = this;

View File

@@ -1263,8 +1263,7 @@ def test_exchange_incomplete_body(token_client, organizer, clist, event, order):
})
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.']
'non_field_errors': ['If you set any of exchange_medium_type or exchange_medium_identifier, you need to set both of them.']
}
@@ -1281,7 +1280,6 @@ def test_exchange_medium_for_medium(token_client, organizer, clist, event, order
"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'
@@ -1294,7 +1292,6 @@ def test_exchange_unknown_media_type(token_client, organizer, clist, event, orde
"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."]}
@@ -1306,7 +1303,6 @@ def test_exchange_disabled_media_type(token_client, organizer, clist, event, ord
"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'
@@ -1323,7 +1319,6 @@ def test_exchange_mismatch_media_type(token_client, organizer, clist, event, ord
"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'
@@ -1340,7 +1335,6 @@ def test_exchange_no_item_policy(token_client, organizer, clist, event, order, i
"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'
@@ -1352,13 +1346,12 @@ def test_exchange_no_item_policy(token_client, organizer, clist, event, order, i
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.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",
"exchange_link_action": "replace",
})
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
@@ -1388,7 +1381,6 @@ def test_exchange_reuse_or_new_reuse_replace(token_client, organizer, clist, eve
"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'
@@ -1401,7 +1393,7 @@ def test_exchange_reuse_or_new_reuse_replace(token_client, organizer, clist, eve
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.media_policy = Item.MEDIA_POLICY_APPEND_OR_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
@@ -1414,7 +1406,6 @@ def test_exchange_reuse_or_new_reuse_append(token_client, organizer, clist, even
"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'
@@ -1428,7 +1419,7 @@ def test_exchange_reuse_or_new_reuse_append(token_client, organizer, clist, even
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.media_policy = Item.MEDIA_POLICY_APPEND_OR_NEW
item.save()
with scopes_disabled():
rm = ReusableMedium.objects.create(
@@ -1441,7 +1432,6 @@ def test_exchange_reuse_exists_append(token_client, organizer, clist, event, ord
"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'
@@ -1469,7 +1459,6 @@ def test_exchange_reuse_expired(token_client, organizer, clist, event, order, it
"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'
@@ -1486,7 +1475,6 @@ def test_exchange_reuse_not_exists(token_client, organizer, clist, event, order,
"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'
@@ -1672,3 +1660,82 @@ def test_exchanged_double_exchange(token_client, organizer, clist, event, order,
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"