my own review notes

This commit is contained in:
Raphael Michel
2026-06-08 18:27:52 +02:00
parent f6ab4195c4
commit e6f0ad552b
7 changed files with 134 additions and 81 deletions

View File

@@ -46,12 +46,15 @@ Checking a ticket in
this request twice with the same nonce, the second request will also succeed but will always 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 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. 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 :<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). order) when building texts (currently only the ``reason_explanation`` response field).
Defaults to ``false`` in which case the server will determine the language (currently Defaults to ``false`` in which case the server will determine the language (currently
the event default language, might change in the future with support for the the event default language, might change in the future with support for the
``Accept-Language`` header). ``Accept-Language`` header).
:>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: 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 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 :>json object position: Copy of the matching order position (if any was found). The contents are the same as the
@@ -67,6 +70,8 @@ Checking a ticket in
:>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found), :>json object list: Excerpt of information about the matching :ref:`check-in list <rest-checkinlists>` (if any was found),
including the attributes ``id``, ``name``, ``event``, ``subevent``, and ``include_pending``. 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 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**: **Example request**:
@@ -224,6 +229,9 @@ Checking a ticket in
* ``ambiguous`` - Multiple tickets match scan, rejected. * ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked. * ``revoked`` - Ticket code has been revoked.
* ``unapproved`` - Order has not yet been approved. * ``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.
* ``error`` - Internal error. * ``error`` - Internal error.
In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation`` In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``

View File

@@ -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 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 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 <rest-checkin>` instead. URL-safe. We recommend to use our new :ref:`check-in API <rest-checkin>` 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 :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 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. * ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked. * ``revoked`` - Ticket code has been revoked.
* ``unapproved`` - Order has not yet been approved. * ``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`` 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``. with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.

View File

@@ -26,7 +26,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, CheckinList, Item from pretix.base.models import Checkin, CheckinList
class CheckinListSerializer(I18nAwareModelSerializer): class CheckinListSerializer(I18nAwareModelSerializer):
@@ -88,9 +88,9 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
nonce = serializers.CharField(required=False, allow_null=True) nonce = serializers.CharField(required=False, allow_null=True)
datetime = serializers.DateTimeField(required=False, allow_null=True) datetime = serializers.DateTimeField(required=False, allow_null=True)
answers = serializers.JSONField(required=False, allow_null=True) answers = serializers.JSONField(required=False, allow_null=True)
media_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES) exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES)
media_identifier = serializers.CharField(required=False) exchange_medium_identifier = serializers.CharField(required=False)
media_action = serializers.ChoiceField(required=False, choices=[ exchange_link_action = serializers.ChoiceField(required=False, choices=[
('append', 'append'), ('append', 'append'),
('replace', 'replace'), ('replace', 'replace'),
]) ])

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, 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, 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,
media_type=None, media_identifier=None, media_action=None): exchange_medium_type=None, exchange_medium_identifier=None, exchange_link_action=None):
if not checkinlists: if not checkinlists:
raise ValidationError('No check-in list passed.') raise ValidationError('No check-in list passed.')
@@ -526,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) # with respecting the force option), or it's a reusable medium (-> proceed with that)
if not op_candidates: if not op_candidates:
try: try:
media = ReusableMedium.objects.active().filter( medium = ReusableMedium.objects.active().filter(
Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk'))) Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
).get( ).get(
organizer_id=checkinlists[0].event.organizer_id, organizer_id=checkinlists[0].event.organizer_id,
@@ -634,7 +634,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400) }, status=400)
else: 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} 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): 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 # Medium exists but connected ticket is for the wrong event
@@ -665,7 +665,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
op_candidates = [] op_candidates = []
for op in linked_ops: for op in linked_ops:
if op.order.event_id in list_by_event: if op.order.event_id in list_by_event:
reusable_medium_used = media reusable_medium_used = medium
op_candidates.append(op) op_candidates.append(op)
if list_by_event[op.order.event_id].addon_match: if list_by_event[op.order.event_id].addon_match:
op_candidates += list(op.addons.all()) op_candidates += list(op.addons.all())
@@ -805,58 +805,56 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
locale = op.order.event.settings.locale locale = op.order.event.settings.locale
with language(locale): with language(locale):
try: try:
if all(k is not None for k in [media_type, media_identifier, media_action]) and not media: exchange_requested = any(k is not None for k in [exchange_medium_type, exchange_medium_identifier, exchange_link_action])
with transaction.atomic():
media = perform_media_exchange(
organizer=request.organizer,
media_type=media_type,
media_identifier=media_identifier,
media_action=media_action,
op=op,
)
source_type = media.media_type.identifier
perform_checkin( if exchange_requested:
op=op, if any(k is None for k in [exchange_medium_type, exchange_medium_identifier, exchange_link_action]):
clist=list_by_event[op.order.event_id], raise ValidationError("If you set any of exchange_medium_type, exchange_medium_identifier, or "
given_answers=given_answers, "èxchange_link_action, you need to set all of them.")
force=force, if medium:
ignore_unpaid=ignore_unpaid, # Cannot scan a medium and then request to exchange it
nonce=nonce, raise ReusableMedium.DuplicateEntry()
datetime=datetime,
questions_supported=questions_supported, checkin_args = dict(
canceled_supported=canceled_supported, op=op,
clist=list_by_event[op.order.event_id],
given_answers=given_answers,
force=force,
ignore_unpaid=ignore_unpaid,
nonce=nonce,
datetime=datetime,
questions_supported=questions_supported,
canceled_supported=canceled_supported,
user=user,
auth=auth,
type=checkin_type,
raw_barcode=raw_barcode_for_checkin,
raw_source_type=source_type,
from_revoked_secret=from_revoked_secret,
simulate=simulate,
gate=gate,
reusable_medium=medium,
)
if exchange_requested:
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, user=user,
auth=auth, auth=auth,
type=checkin_type, )
raw_barcode=raw_barcode_for_checkin, source_type = media.media_type.identifier
raw_source_type=source_type, checkin_args['medium'] = medium
from_revoked_secret=from_revoked_secret, perform_checkin(
simulate=simulate,
gate=gate,
reusable_media=media, reusable_media=media,
) )
else: else:
perform_checkin( perform_checkin(**checkin_args)
op=op,
clist=list_by_event[op.order.event_id],
given_answers=given_answers,
force=force,
ignore_unpaid=ignore_unpaid,
nonce=nonce,
datetime=datetime,
questions_supported=questions_supported,
canceled_supported=canceled_supported,
user=user,
auth=auth,
type=checkin_type,
raw_barcode=raw_barcode_for_checkin,
raw_source_type=source_type,
from_revoked_secret=from_revoked_secret,
simulate=simulate,
gate=gate,
reusable_media=media,
)
except RequiredQuestionsError as e: except RequiredQuestionsError as e:
return Response({ return Response({
'status': 'incomplete', 'status': 'incomplete',
@@ -879,11 +877,21 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
'reason_explanation': e.msg, 'reason_explanation': e.msg,
}, status=400) }, 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: except ReusableMedium.DuplicateEntry:
return Response({ return Response({
'status': 'error', 'status': 'error',
'reason': Checkin.REASON_AMBIGUOUS, 'reason': Checkin.REASON_MEDIUM_EXISTS,
'reason_explanation': 'Reusable medium identifier is ambigous', 'reason_explanation': 'Reusable medium identifier already exists',
'require_attention': op.require_checkin_attention, 'require_attention': op.require_checkin_attention,
'checkin_texts': op.checkin_texts, 'checkin_texts': op.checkin_texts,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data, 'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data,
@@ -1076,9 +1084,9 @@ class CheckinRPCRedeemView(views.APIView):
canceled_supported=True, canceled_supported=True,
request=self.request, # this is not clean, but we need it in the serializers for URL generation request=self.request, # this is not clean, but we need it in the serializers for URL generation
legacy_url_support=False, legacy_url_support=False,
media_type=s.validated_data.get('media_type'), exchange_medium_type=s.validated_data.get('exchange_medium_type'),
media_identifier=s.validated_data.get('media_identifier'), exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'),
media_action=s.validated_data.get('media_action'), exchange_link_action=s.validated_data.get('exchange_link_action'),
) )

View File

@@ -346,6 +346,8 @@ class Checkin(models.Model):
REASON_INCOMPLETE = 'incomplete' REASON_INCOMPLETE = 'incomplete'
REASON_ALREADY_REDEEMED = 'already_redeemed' REASON_ALREADY_REDEEMED = 'already_redeemed'
REASON_AMBIGUOUS = 'ambiguous' REASON_AMBIGUOUS = 'ambiguous'
REASON_MEDIUM_INVALID = 'medium_invalid'
REASON_MEDIUM_EXISTS = 'medium_exists'
REASON_ERROR = 'error' REASON_ERROR = 'error'
REASON_BLOCKED = 'blocked' REASON_BLOCKED = 'blocked'
REASON_UNAPPROVED = 'unapproved' REASON_UNAPPROVED = 'unapproved'
@@ -368,6 +370,8 @@ class Checkin(models.Model):
(REASON_INVALID_TIME, _('Ticket not valid at this time')), (REASON_INVALID_TIME, _('Ticket not valid at this time')),
(REASON_ANNULLED, _('Check-in annulled')), (REASON_ANNULLED, _('Check-in annulled')),
(REASON_ALREADY_EXCHANGED, _('Ticket already exchanged')), (REASON_ALREADY_EXCHANGED, _('Ticket already exchanged')),
(REASON_MEDIUM_INVALID, _('Reusable medium invalid')),
(REASON_MEDIUM_EXISTS, _('Reusable medium already exists')),
) )
successful = models.BooleanField( successful = models.BooleanField(

View File

@@ -948,7 +948,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False, raw_barcode=None, raw_source_type=None, from_revoked_secret=False, simulate=False,
gate=None, reusable_media=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 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. not valid at this time.
@@ -964,6 +964,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
:param datetime: The datetime of the checkin, defaults to now. :param datetime: The datetime of the checkin, defaults to now.
:param simulate: If true, the check-in is not saved. :param simulate: If true, the check-in is not saved.
:param gate: The gate the check-in was performed at. :param gate: The gate the check-in was performed at.
:param reusable_medium: The medium that is available for an exchange
""" """
# !!!!!!!!! # !!!!!!!!!
@@ -1112,8 +1113,9 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
required_media_policy = op.item.media_policy required_media_policy = op.item.media_policy
required_media_type = op.item.media_type required_media_type = op.item.media_type
require_a_medium = required_media_policy and required_media_type
linked_media = op.linked_media linked_media = op.linked_media
if not reusable_media and required_media_policy and required_media_type and not force: if require_a_medium and not reusable_medium and not force:
if not linked_media.exists(): if not linked_media.exists():
raise RequiredMediaExchangeError( raise RequiredMediaExchangeError(
_('Ticket needs to be exchanged to a suitable medium.'), _('Ticket needs to be exchanged to a suitable medium.'),

View File

@@ -72,35 +72,62 @@ def get_keysets_for_organizer(organizer):
return sets return sets
def perform_media_exchange(organizer, media_type, media_identifier, media_action, op): def perform_media_exchange(organizer, media_type, identifier, link_action, link_orderposition, user, auth):
"""
Create or retrieve a medium
: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 medium = None
media_policy = op.item.media_policy media_policy = link_orderposition.item.media_policy
if link_action not in ('append', 'replace'):
raise ValueError("Invalid link_action")
if media_policy in [Item.MEDIA_POLICY_REUSE, Item.MEDIA_POLICY_REUSE_OR_NEW]: if media_policy == Item.MEDIA_POLICY_REUSE:
try: # Will and should raise ReusableMedium.DoesNotExist if not found
medium = ReusableMedium.objects.get( medium = ReusableMedium.objects.get(
type=media_type, type=media_type,
identifier=media_identifier, identifier=identifier,
organizer=organizer, organizer=organizer,
)
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,
) )
except ReusableMedium.DoesNotExist:
pass
if not medium and media_policy in [Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW]: elif media_policy == Item.MEDIA_POLICY_NEW:
try: try:
medium = ReusableMedium.objects.create( medium = ReusableMedium.objects.create(
type=media_type, type=media_type,
identifier=media_identifier, identifier=identifier,
organizer=organizer, organizer=organizer,
) )
except IntegrityError: except IntegrityError:
raise ReusableMedium.DuplicateEntry() raise ReusableMedium.DuplicateEntry()
else:
medium.log_action(
'pretix.reusable_medium.created.auto',
user=user,
auth=auth,
)
if medium: if link_action == 'append':
if media_action == 'append': medium.linked_orderpositions.add(link_orderposition)
medium.linked_orderpositions.add(*[op]) elif link_action == 'replace':
elif media_action == 'replace': medium.linked_orderpositions.set([link_orderposition])
medium.linked_orderpositions.set([op])
medium.save()
return medium return medium