diff --git a/doc/api/resources/carts.rst b/doc/api/resources/carts.rst
index 74afdc4565..b725cc5a75 100644
--- a/doc/api/resources/carts.rst
+++ b/doc/api/resources/carts.rst
@@ -192,7 +192,7 @@ Cart position endpoints
* ``attendee_email`` (optional)
* ``subevent`` (optional)
* ``expires`` (optional)
- * ``includes_tax`` (optional, **deprecated**, do not use, will be removed)
+ * ``includes_tax`` (optional, **DEPRECATED**, do not use, will be removed)
* ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code)
* ``addons`` (optional, expect a list of nested objects of cart positions)
diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst
index fe98d93e98..d345541506 100644
--- a/doc/api/resources/orders.rst
+++ b/doc/api/resources/orders.rst
@@ -1070,6 +1070,7 @@ Creating orders
* ``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)
* ``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/doc/api/resources/reusablemedia.rst b/doc/api/resources/reusablemedia.rst
index e0618a5b41..666a4f2c40 100644
--- a/doc/api/resources/reusablemedia.rst
+++ b/doc/api/resources/reusablemedia.rst
@@ -21,12 +21,16 @@ id integer Internal ID of
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
organizer string Organizer slug of the organizer who "owns" this medium.
identifier string Unique identifier of the medium. The format depends on the ``type``.
+claim_token string Secret token to claim ownership of the medium (or ``null``)
+label string Label to identify the medium, usually something human readable (or ``null``)
active boolean Whether this medium may be used.
created datetime Date of creation
updated datetime Date of last modification
expires datetime Expiry date (or ``null``)
customer string Identifier of a customer account this medium belongs to.
-linked_orderposition integer Internal ID of a ticket this medium is linked to.
+linked_orderpositions list of integers Internal IDs of tickets this medium is linked to.
+linked_orderposition integer **DEPRECATED.** ID of the ticket the medium is linked to, if it is linked to
+ only one ticket. ``null``, if the medium is linked to none or multiple tickets.
linked_giftcard integer Internal ID of a gift card this medium is linked to.
info object Additional data, content depends on the ``type``. Consider
this internal to the system and don't use it for your own data.
@@ -39,6 +43,14 @@ Existing media types are:
- ``nfc_uid``
- ``nfc_mf0aes``
+
+.. versionchanged:: 2026.5
+
+ The ``claim_token``, ``label``, ``linked_orderpositions`` attributes have been added, the ``linked_orderposition`` attribute has been
+ deprecated. Note: To maintain backwards compatibility ``linked_orderposition`` contains the internal ID of the linked order position
+ if the medium has exactly one order position in ``linked_orderpositions``.
+
+
Endpoints
---------
@@ -77,6 +89,7 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
+ "linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -92,10 +105,13 @@ Endpoints
:query string customer: Only show media linked to the given customer.
:query string created_since: Only show media created since a given date.
:query string updated_since: Only show media updated since a given date.
+ :query integer linked_orderpositions: Only show media linked to the given tickets. Note: you can pass multiple ticket IDs by passing
+ ``linked_orderpositions`` multiple times. Any medium matching any linked orderposition will be returned.
:query integer linked_orderposition: Only show media linked to the given ticket.
:query integer linked_giftcard: Only show media linked to the given gift card.
- :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``,
- or ``"customer"``, the respective field will be shown as a nested value instead of just an ID.
+ :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderpositions"``,
+ ``"linked_orderposition"`` (**DEPRECATED**), or ``"customer"``, the respective field will be shown
+ as a nested value instead of just an ID.
The nested objects are identical to the respective resources, except that order positions
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
matching easier. The parameter can be given multiple times.
@@ -134,6 +150,7 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
+ "linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -191,6 +208,7 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
+ "linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -198,9 +216,9 @@ Endpoints
}
:param organizer: The ``slug`` field of the organizer to look up a medium for
- :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
+ :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
- the respective resources, except that the ``linked_orderposition`` will have an attribute of the
+ the respective resources, except that the ``linked_orderpositions`` each will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:statuscode 201: no error
@@ -227,6 +245,7 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
+ "linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -251,6 +270,7 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
+ "linked_orderpositions": [],
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
@@ -258,7 +278,7 @@ Endpoints
}
:param organizer: The ``slug`` field of the organizer to create a medium for
- :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
+ :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
@@ -287,7 +307,7 @@ Endpoints
Content-Length: 94
{
- "linked_orderposition": 13
+ "linked_orderpositions": [13, 29]
}
**Example response**:
@@ -308,7 +328,8 @@ Endpoints
"active": True,
"expires": None,
"customer": None,
- "linked_orderposition": 13,
+ "linked_orderpositions": [13, 29],
+ "linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
@@ -316,7 +337,7 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the medium to modify
- :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
+ :query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
diff --git a/src/pretix/api/serializers/media.py b/src/pretix/api/serializers/media.py
index d759f14684..cce6e86183 100644
--- a/src/pretix/api/serializers/media.py
+++ b/src/pretix/api/serializers/media.py
@@ -66,13 +66,14 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ expand_nested = self.context['request'].query_params.getlist('expand')
- if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
+ if 'linked_giftcard' in expand_nested:
if not self.context["can_read_giftcards"]:
raise PermissionDenied("No permission to access gift card details.")
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
- if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
+ if 'linked_giftcard.owner_ticket' in expand_nested:
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
else:
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
@@ -81,17 +82,27 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
queryset=self.context['organizer'].issued_gift_cards.all()
)
- if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
- # Permission Check performed in to_representation
- self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
+ # keep linked_orderposition (singular) for backwards compatibility, will be overwritten in self.validate
+ self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
+ required=False,
+ allow_null=True,
+ queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
+ )
+
+ if 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
+ self.fields['linked_orderpositions'] = NestedOrderPositionSerializer(
+ many=True,
+ read_only=True
+ )
else:
- self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
+ self.fields['linked_orderpositions'] = serializers.PrimaryKeyRelatedField(
+ many=True,
required=False,
allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
)
- if 'customer' in self.context['request'].query_params.getlist('expand'):
+ if 'customer' in expand_nested:
if not self.context["can_read_customers"]:
raise PermissionDenied("No permission to access customer details.")
@@ -106,6 +117,21 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def validate(self, data):
data = super().validate(data)
+ if 'linked_orderposition' in data:
+ linked_orderposition = data['linked_orderposition']
+ # backwards-compatibility
+ if 'linked_orderpositions' in data:
+ raise ValidationError({
+ 'linked_orderposition': 'You cannot use linked_orderposition and linked_orderpositions at the same time.'
+ })
+ if self.instance and self.instance.linked_orderpositions.count() > 1:
+ raise ValidationError({
+ 'linked_orderposition': 'There are more than one linked_orderposition. You need to use linked_orderpositions.'
+ })
+
+ data['linked_orderpositions'] = [linked_orderposition] if linked_orderposition else []
+ del data['linked_orderposition']
+
if 'type' in data and 'identifier' in data:
qs = self.context['organizer'].reusable_media.filter(
identifier=data['identifier'], type=data['type']
@@ -121,14 +147,28 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
+
+ ops = r.get('linked_orderpositions', [])
# late permission evaluations for checks that depend on the actual linked events
expand_nested = self.context['request'].query_params.getlist('expand')
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
- if 'linked_orderposition' in expand_nested:
- if instance.linked_orderposition is not None:
- event = instance.linked_orderposition.order.event
+ if ops and 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
+ ops_noperm = []
+ for lop in instance.linked_orderpositions.all():
+ event = lop.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
- r['linked_orderposition'] = {'id': instance.linked_orderposition.id}
+ ops_noperm.append(lop.id)
+ if ops_noperm:
+ ops = [
+ {'id': op['id']} if op['id'] in ops_noperm
+ else op
+ for op in ops
+ ]
+ r['linked_orderpositions'] = ops
+
+ # add linked_orderposition (singular) for backwards compatibility
+ if len(ops) < 2:
+ r['linked_orderposition'] = ops[0] if ops else None
if 'linked_giftcard.owner_ticket' in expand_nested:
gc = instance.linked_giftcard
@@ -148,10 +188,12 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
'updated',
'type',
'identifier',
+ 'claim_token',
+ 'label',
'active',
'expires',
'customer',
- 'linked_orderposition',
+ 'linked_orderpositions',
'linked_giftcard',
'info',
'notes',
diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py
index 9fa6b5ce92..0cfcb3a46f 100644
--- a/src/pretix/api/serializers/order.py
+++ b/src/pretix/api/serializers/order.py
@@ -1043,13 +1043,15 @@ 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', 'discount')
+ 'requested_valid_from', 'use_reusable_medium', 'add_to_reusable_medium', 'discount')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -1061,6 +1063,8 @@ 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():
@@ -1076,6 +1080,9 @@ 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(
@@ -1149,6 +1156,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'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
@@ -1588,7 +1602,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 != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
+ pos = OrderPosition(**{k: v for k, v in pos_data.items() if k not in ('answers', '_quotas', 'use_reusable_medium', 'add_to_reusable_medium')})
if simulate:
pos.order = order._wrapped
else:
@@ -1662,6 +1676,7 @@ 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)
@@ -1703,10 +1718,25 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answ.options.add(*options)
if use_reusable_medium:
- use_reusable_medium.linked_orderposition = pos
- use_reusable_medium.save(update_fields=['linked_orderposition'])
+ 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])
use_reusable_medium.log_action(
- 'pretix.reusable_medium.linked_orderposition.changed',
+ 'pretix.reusable_medium.linked_orderposition.added',
+ data={
+ 'by_order': order.code,
+ '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,
diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py
index c6567a4d5c..20d9ff8512 100644
--- a/src/pretix/api/views/checkin.py
+++ b/src/pretix/api/views/checkin.py
@@ -491,6 +491,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
)
raw_barcode_for_checkin = None
from_revoked_secret = False
+ reusable_medium_used = None
if simulate:
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
@@ -521,11 +522,12 @@ 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.select_related('linked_orderposition').active().get(
+ media = ReusableMedium.objects.active().filter(
+ Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
+ ).get(
organizer_id=checkinlists[0].event.organizer_id,
type=source_type,
identifier=raw_barcode,
- linked_orderposition__isnull=False,
)
raw_barcode_for_checkin = raw_barcode
except ReusableMedium.DoesNotExist:
@@ -628,7 +630,9 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400)
else:
- if media.linked_orderposition.order.event_id not in list_by_event:
+ linked_ops = media.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
if not simulate:
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
@@ -654,28 +658,91 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'checkin_texts': [],
'list': MiniCheckinListSerializer(checkinlists[0]).data,
}, status=404)
- op_candidates = [media.linked_orderposition]
- if list_by_event[media.linked_orderposition.order.event_id].addon_match:
- op_candidates += list(media.linked_orderposition.addons.all())
+ op_candidates = []
+ for op in linked_ops:
+ if op.order.event_id in list_by_event:
+ reusable_medium_used = media
+ op_candidates.append(op)
+ if list_by_event[op.order.event_id].addon_match:
+ op_candidates += list(op.addons.all())
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
- # key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
- # which add-on has the right product.
+ # key on the same list, we're probably dealing with multiple linked_orderpositions or the ``addon_match`` case
+ # here and need to figure out which op has the right product. This basically is a valid-for-checkin-test on every op.
if len(op_candidates) > 1:
- op_candidates_matching_product = [
- op for op in op_candidates
- if (
- (list_by_event[op.order.event_id].addon_match or op.secret == raw_barcode or legacy_url_support) and
- (list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()})
- )
- ]
- if len(op_candidates_matching_product) == 0:
- # None of the found add-ons has the correct product, too bad! We could just error out here, but
+ if not reusable_medium_used:
+ # 3a. First, we clean up that we made an imprecise query above. If a scan is made for multiple check-in lists,
+ # we have queried ``addon_to__secret=raw_barcode``, even if some of the lists in question do not allow addon
+ # matching. So we accept all candidates that match one of these cases:
+ # - Exactly the ticket secret we scanned (because that's always a possible result)
+ # - Exactly the ticket pk we scanned (on legacy endpoints)
+ # - An add-on on a list that allows add-on matching
+ # This is not necessary when a reusable media was used, since in that case we already obeyed list.addon_match
+ # correctly above.
+ op_candidates_filtered = [
+ op for op in op_candidates
+ if (
+ op.secret == raw_barcode or
+ list_by_event[op.order.event_id].addon_match or
+ (str(op.pk) == raw_barcode and legacy_url_support and not untrusted_input)
+ )
+ ]
+ else:
+ op_candidates_filtered = op_candidates
+
+ if len(op_candidates_filtered) > 1:
+ # 3b. If we still have multiple candidates, we filter by product based on the check-in list configuration.
+ # This is relevant for the addon_match scenario where the scanned ticket has multiple add-ons, but only
+ # one is contained in the check-in list used to scan. It makes sense to filter this first, since it is a
+ # "static" check, i.e. scanning the same QR code on the same check-in list will always do the same, no matter
+ # when I scan it, and it is "intentional" filtering in the sense that the admin configured this behaviour
+ # into the check-in list.
+ op_candidates_filtered = [
+ op for op in op_candidates_filtered
+ if list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()}
+ ]
+
+ if len(op_candidates_filtered) > 1:
+ # 3c. If we still have multiple candidates, we filter by validity date. This was introduced for the case where
+ # a reusable media refers to two tickets, one currently valid and one expired or in the future. Howeer,
+ # it could in theory also happen with two add-ons being on the same check-in list but without overlapping
+ # validity. It makes sense to filter this "after" the previous checks since it is not "intentional" filtering
+ # configured by the admin but "accidental" filtering that depends on the time of execution.
+ op_candidates_filtered = [
+ op for op in op_candidates_filtered
+ if (
+ (not op.valid_from or op.valid_from <= datetime) and
+ (not op.valid_until or op.valid_until > datetime)
+ )
+ ]
+
+ if len(op_candidates_filtered) == 0:
+ # None of the ops is valid today or has the correct product, too bad! We could just error out here, but
# instead we just continue with *any* product and have it rejected by the check in perform_checkin.
- # This has the advantage of a better error message.
- op_candidates = [op_candidates[0]]
- elif len(op_candidates_matching_product) > 1:
+ # To improve the error message, we select the op that will "work next" or - if none matches - "worked last".
+ op_candidate = None
+ for op in op_candidates:
+ if (
+ op.valid_from and op.valid_from > datetime and
+ (not op_candidate or op.valid_from < op_candidate.valid_from)
+ ):
+ op_candidate = op
+
+ if not op_candidate:
+ # no candidate in the future, get closest in the past
+ for op in op_candidates:
+ if (
+ op.valid_until and op.valid_until < datetime and
+ (not op_candidate or op.valid_until > op_candidate.valid_until)
+ ):
+ op_candidate = op
+
+ if not op_candidate:
+ op_candidate = op_candidates[0]
+
+ op_candidates = [op_candidate]
+ elif len(op_candidates_filtered) > 1:
# It's still ambiguous, we'll error out.
# We choose the first match (regardless of product) for the logging since it's most likely to be the
# base product according to our order_by above.
@@ -709,7 +776,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
}, status=400)
else:
- op_candidates = op_candidates_matching_product
+ op_candidates = op_candidates_filtered
op = op_candidates[0]
common_checkin_args['list'] = list_by_event[op.order.event_id]
diff --git a/src/pretix/api/views/media.py b/src/pretix/api/views/media.py
index 7b3be781f6..7b1509128d 100644
--- a/src/pretix/api/views/media.py
+++ b/src/pretix/api/views/media.py
@@ -53,10 +53,12 @@ with scopes_disabled():
customer = django_filters.CharFilter(field_name='customer__identifier')
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
+ # backwards-compatible
+ linked_orderposition = django_filters.NumberFilter(field_name='linked_orderpositions__id')
class Meta:
model = ReusableMedium
- fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
+ fields = ['identifier', 'type', 'active', 'customer', 'linked_orderpositions', 'linked_giftcard']
class ReusableMediaViewSet(viewsets.ModelViewSet):
@@ -75,7 +77,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
).order_by().values('card').annotate(s=Sum('value')).values('s')
return self.request.organizer.reusable_media.prefetch_related(
Prefetch(
- 'linked_orderposition',
+ 'linked_orderpositions',
queryset=OrderPosition.objects.select_related(
'order', 'order__event', 'order__event__organizer', 'seat',
).prefetch_related(
@@ -117,14 +119,38 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
@transaction.atomic()
def perform_update(self, serializer):
- ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
+ rm = ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
+ prev_linked_ops_pks = list(rm.linked_orderpositions.values_list("pk", flat=True))
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
- inst.log_action(
- 'pretix.reusable_medium.changed',
- user=self.request.user,
- auth=self.request.auth,
- data=self.request.data,
- )
+ linked_ops_pks = inst.linked_orderpositions.values_list("pk", flat=True)
+ for op_pk in prev_linked_ops_pks:
+ if op_pk not in linked_ops_pks:
+ inst.log_action(
+ 'pretix.reusable_medium.linked_orderposition.removed',
+ user=self.request.user,
+ auth=self.request.auth,
+ data={
+ 'linked_orderposition': op_pk,
+ }
+ )
+ for op_pk in linked_ops_pks:
+ if op_pk not in prev_linked_ops_pks:
+ inst.log_action(
+ 'pretix.reusable_medium.linked_orderposition.added',
+ user=self.request.user,
+ auth=self.request.auth,
+ data={
+ 'linked_orderposition': op_pk,
+ }
+ )
+ data = {k: v for k, v in self.request.data.items() if k not in ('linked_orderposition', 'linked_orderpositions')}
+ if data:
+ inst.log_action(
+ 'pretix.reusable_medium.changed',
+ user=self.request.user,
+ auth=self.request.auth,
+ data=data,
+ )
return inst
def perform_destroy(self, instance):
@@ -157,7 +183,6 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
type=s.validated_data["type"],
identifier=s.validated_data["identifier"],
)
- m.linked_orderposition = None # not relevant for cross-organizer
m.customer = None # not relevant for cross-organizer
s = self.get_serializer(m)
return Response({"result": s.data})
diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py
index 850ff06615..31a81ed0bb 100644
--- a/src/pretix/api/views/order.py
+++ b/src/pretix/api/views/order.py
@@ -194,7 +194,7 @@ with scopes_disabled():
)
).values('id')
- matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderposition__order_id', flat=True)
+ matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderpositions__order_id', flat=True)
mainq = (
code
@@ -1034,7 +1034,7 @@ with scopes_disabled():
search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value):
- matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderposition', flat=True)
+ matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderpositions', flat=True)
return queryset.filter(
Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value)
diff --git a/src/pretix/base/exporters/reusablemedia.py b/src/pretix/base/exporters/reusablemedia.py
index fbc6005908..10f951379b 100644
--- a/src/pretix/base/exporters/reusablemedia.py
+++ b/src/pretix/base/exporters/reusablemedia.py
@@ -20,12 +20,13 @@
#
{{ medium.identifier }}{{ medium.identifier }}
+
+ {% if medium.type == "barcode" %}
+
+ {% endif %}
+