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 @@ # . # +from django.db.models import Prefetch from django.dispatch import receiver from django.utils.formats import date_format from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy from ..exporter import ListExporter, OrganizerLevelExportMixin -from ..models import ReusableMedium +from ..models import OrderPosition, ReusableMedium from ..signals import register_multievent_data_exporters @@ -44,7 +45,9 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter): media = ReusableMedium.objects.filter( organizer=self.organizer, ).select_related( - 'customer', 'linked_orderposition', 'linked_giftcard', + 'customer', 'linked_giftcard', + ).prefetch_related( + Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order")) ).order_by('created') headers = [ @@ -62,17 +65,16 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter): yield self.ProgressSetTotal(total=media.count()) for medium in media.iterator(chunk_size=1000): - row = [ + yield [ medium.type, medium.identifier, _('Yes') if medium.active else _('No'), date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '', medium.customer.identifier if medium.customer_id else '', - f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '', + ', '.join([f"{op.order.code}-{op.positionid}" for op in medium.linked_orderpositions.all()]), medium.linked_giftcard.secret if medium.linked_giftcard_id else '', medium.notes, ] - yield row def get_filename(self): return f'{self.organizer.slug}_media' diff --git a/src/pretix/base/migrations/0300_add_reusablemedium_label.py b/src/pretix/base/migrations/0300_add_reusablemedium_label.py new file mode 100644 index 0000000000..0352c50b81 --- /dev/null +++ b/src/pretix/base/migrations/0300_add_reusablemedium_label.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.26 on 2025-11-24 11:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0299_itemprogramtime_location"), + ] + + operations = [ + migrations.AddField( + model_name="reusablemedium", + name="claim_token", + field=models.CharField(max_length=200, null=True), + ), + migrations.AddField( + model_name="reusablemedium", + name="label", + field=models.CharField(max_length=200, null=True), + ), + # use temporary related_name "linked_mediums" for ManyToManyField, so we can migrate existing data + migrations.AddField( + model_name="reusablemedium", + name="linked_orderpositions", + field=models.ManyToManyField( + related_name="linked_mediums", to="pretixbase.orderposition" + ), + ), + migrations.RunSQL( + sql="INSERT INTO pretixbase_reusablemedium_linked_orderpositions (reusablemedium_id, orderposition_id) SELECT id, linked_orderposition_id FROM pretixbase_reusablemedium WHERE linked_orderposition_id IS NOT NULL;", + reverse_sql="DELETE FROM pretixbase_reusablemedium_linked_orderpositions;", + ), + ] diff --git a/src/pretix/base/migrations/0301_reusablemedium_remove_orderposition.py b/src/pretix/base/migrations/0301_reusablemedium_remove_orderposition.py new file mode 100644 index 0000000000..8ba0a64b8a --- /dev/null +++ b/src/pretix/base/migrations/0301_reusablemedium_remove_orderposition.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.26 on 2025-11-24 11:32 + +from django.db import migrations, models + + +def reverse(apps, schema_editor): + ReusableMedium = apps.get_model('pretixbase', 'ReusableMedium') + + qs = ReusableMedium.linked_orderpositions.through.objects + objs = [] + # get last added orderposition from linked_orderpositions + for rm_id, op_id in qs.filter(id__in=qs.values("reusablemedium_id").annotate(max_id=models.Max('id')).values('max_id')).values_list("reusablemedium_id", "orderposition_id"): + obj = ReusableMedium( + id=rm_id, + linked_orderposition_id=op_id, + ) + objs.append(obj) + + ReusableMedium.objects.bulk_update(objs, ['linked_orderposition_id']) + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0300_add_reusablemedium_label"), + ] + + operations = [ + # according to the docs, UPDATE FROM should run similarly on sqlite and postgres, but I could not get it to work + # so roll back the data migration with code before deleting data from through-table in 0297 + migrations.RunPython(migrations.RunPython.noop, reverse), + migrations.RemoveField( + model_name="reusablemedium", + name="linked_orderposition", + ), + # change related_name for new ManyToManyField to previously used linked_media + migrations.AlterField( + model_name="reusablemedium", + name="linked_orderpositions", + field=models.ManyToManyField( + related_name="linked_media", to="pretixbase.orderposition" + ), + ), + ] diff --git a/src/pretix/base/models/media.py b/src/pretix/base/models/media.py index d14dbdcc10..eabefae59f 100644 --- a/src/pretix/base/models/media.py +++ b/src/pretix/base/models/media.py @@ -72,6 +72,16 @@ class ReusableMedium(LoggedModel): max_length=200, verbose_name=pgettext_lazy('reusable_medium', 'Identifier'), ) + claim_token = models.CharField( + max_length=200, + verbose_name=pgettext_lazy('reusable_medium', 'Claim token'), + null=True, blank=True + ) + label = models.CharField( + max_length=200, + verbose_name=pgettext_lazy('reusable_medium', 'Label'), + null=True, blank=True + ) active = models.BooleanField( verbose_name=_('Active'), @@ -89,12 +99,14 @@ class ReusableMedium(LoggedModel): on_delete=models.SET_NULL, verbose_name=_('Customer account'), ) - linked_orderposition = models.ForeignKey( + linked_orderpositions = models.ManyToManyField( OrderPosition, - null=True, blank=True, related_name='linked_media', - on_delete=models.SET_NULL, - verbose_name=_('Linked ticket'), + verbose_name=_('Linked tickets'), + help_text=_( + 'If you link to more than one ticket, make sure there is no overlap in validity. ' + 'If multiple tickets are valid at once, this will lead to failed check-ins.' + ) ) linked_giftcard = models.ForeignKey( GiftCard, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 8fe2d3f1a5..cdd0cd2f53 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -3515,8 +3515,8 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs): identifier=mt.generate_identifier(sender.organizer), active=True, customer=order.customer, - linked_orderposition=p, ) + rm.linked_orderpositions.add(p) rm.log_action( 'pretix.reusable_medium.created', data={ diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index fda7545a03..b4ce016491 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -1871,7 +1871,7 @@ class ReusableMediaFilterForm(FilterForm): Q(identifier__icontains=query) | Q(customer__identifier__icontains=query) | Q(customer__external_identifier__istartswith=query) - | Q(linked_orderposition__order__code__icontains=query) + | Q(linked_orderpositions__order__code__icontains=query) | Q(linked_giftcard__secret__icontains=query) ) diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index d0c48fc3d4..a1e39981b6 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -86,7 +86,7 @@ from pretix.control.forms import ExtFileField, SplitDateTimeField from pretix.control.forms.event import ( SafeEventMultipleChoiceField, multimail_validate, ) -from pretix.control.forms.widgets import Select2 +from pretix.control.forms.widgets import Select2, Select2Multiple from pretix.multidomain.models import KnownDomain from pretix.multidomain.urlreverse import build_absolute_uri @@ -249,6 +249,15 @@ class SafeOrderPositionChoiceField(forms.ModelChoiceField): return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})' +class SafeOrderPositionMultipleChoiceField(forms.ModelMultipleChoiceField): + def __init__(self, queryset, **kwargs): + queryset = queryset.model.all.none() + super().__init__(queryset, **kwargs) + + def label_from_instance(self, op): + return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})' + + class EventMetaPropertyForm(I18nModelForm): class Meta: model = EventMetaProperty @@ -963,12 +972,12 @@ class ReusableMediumUpdateForm(forms.ModelForm): class Meta: model = ReusableMedium - fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes'] + fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderpositions', 'notes'] field_classes = { 'expires': SplitDateTimeField, 'customer': SafeModelChoiceField, 'linked_giftcard': SafeModelChoiceField, - 'linked_orderposition': SafeOrderPositionChoiceField, + 'linked_orderpositions': SafeOrderPositionMultipleChoiceField, } widgets = { 'expires': SplitDateTimePickerWidget, @@ -978,8 +987,8 @@ class ReusableMediumUpdateForm(forms.ModelForm): super().__init__(*args, **kwargs) organizer = self.instance.organizer - self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all() - self.fields['linked_orderposition'].widget = Select2( + self.fields['linked_orderpositions'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all() + self.fields['linked_orderpositions'].widget = Select2Multiple( attrs={ 'data-model-select2': 'generic', 'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={ @@ -987,8 +996,8 @@ class ReusableMediumUpdateForm(forms.ModelForm): }), } ) - self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices - self.fields['linked_orderposition'].required = False + self.fields['linked_orderpositions'].widget.choices = self.fields['linked_orderpositions'].choices + self.fields['linked_orderpositions'].required = False self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all() self.fields['linked_giftcard'].widget = Select2( @@ -1042,12 +1051,12 @@ class ReusableMediumCreateForm(ReusableMediumUpdateForm): class Meta: model = ReusableMedium - fields = ['active', 'type', 'identifier', 'expires', 'linked_orderposition', 'linked_giftcard', 'customer', 'notes'] + fields = ['active', 'type', 'identifier', 'expires', 'linked_orderpositions', 'linked_giftcard', 'customer', 'notes'] field_classes = { 'expires': SplitDateTimeField, 'customer': SafeModelChoiceField, 'linked_giftcard': SafeModelChoiceField, - 'linked_orderposition': SafeOrderPositionChoiceField, + 'linked_orderpositions': SafeOrderPositionMultipleChoiceField, } widgets = { 'expires': SplitDateTimePickerWidget, diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 12fb8455da..84e3365ae8 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -743,6 +743,8 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType): 'pretix.reusable_medium.created': _('The reusable medium has been created.'), 'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'), 'pretix.reusable_medium.changed': _('The reusable medium has been changed.'), + 'pretix.reusable_medium.linked_orderposition.added': _('A new ticket has been added to the medium.'), + 'pretix.reusable_medium.linked_orderposition.removed': _('A ticket has been removed from the medium.'), 'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'), 'pretix.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'), 'pretix.email.error': _('Sending of an email has failed.'), diff --git a/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html b/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html index 9b96fc8af7..6b2f9e2c73 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/reusable_media.html @@ -58,8 +58,8 @@ {% trans "Media type" context "reusable_media" %} - - + + {% trans "Connections" context "reusable_media" %} @@ -90,13 +90,13 @@ {% endif %} {% endif %} - {% if m.linked_orderposition %} + {% for op in m.linked_orderpositions.all %} - - {{ m.linked_orderposition.order.code }}-{{ m.linked_orderposition.positionid }} + + {{ op.order.code }}-{{ op.positionid }} - {% endif %} + {% endfor %} {% if m.linked_giftcard %} diff --git a/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html b/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html index f53ebbf0c2..4cac9db0ac 100644 --- a/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html +++ b/src/pretix/control/templates/pretixcontrol/organizers/reusable_medium.html @@ -26,7 +26,19 @@
{% trans "Media type" context "reusable_media" %}
{{ medium.get_type_display }}
{% trans "Identifier" context "reusable_media" %}
-
{{ medium.identifier }}
+
+ {{ medium.identifier }} + + {% if medium.type == "barcode" %} + + {% endif %} +
{% trans "Status" %}
{% if not medium.active %} @@ -41,34 +53,34 @@
{% if medium.customer %} - - {% if "organizer.customers:read" in request.orgapermset %} - + + {% if "organizer.customers:read" in request.orgapermset %} + + {{ medium.customer }} + + {% else %} {{ medium.customer }} - - {% else %} - {{ medium.customer }} - {% endif %} - + {% endif %} + {% endif %} - {% if medium.linked_orderposition %} + {% for op in medium.linked_orderpositions.all %} - - - {{ medium.linked_orderposition.order.code }}-{{ medium.linked_orderposition.positionid }} - - {% endif %} + + + {{ op.order.code }}-{{ op.positionid }} + + {% endfor %} {% if medium.linked_giftcard %} - - {% if "organizer.giftcards:read" in request.orgapermset %} - - {{ medium.linked_giftcard.secret }} - - {% else %} - {{ medium.linked_giftcard.secret|slice:":3" }}… - {% endif %} - + + {% if "organizer.giftcards:read" in request.orgapermset %} + + {{ medium.linked_giftcard.secret }} + + {% else %} + {{ medium.linked_giftcard.secret|slice:":3" }}… + {% endif %} + {% endif %}
{% if medium.notes %} diff --git a/src/pretix/control/views/organizer.py b/src/pretix/control/views/organizer.py index e4854b24cb..c7c64fc90e 100644 --- a/src/pretix/control/views/organizer.py +++ b/src/pretix/control/views/organizer.py @@ -3384,8 +3384,10 @@ class ReusableMediaListView(OrganizerDetailViewMixin, OrganizerPermissionRequire def get_queryset(self): qs = self.request.organizer.reusable_media.select_related( - 'customer', 'linked_orderposition', 'linked_orderposition__order', 'linked_orderposition__order__event', - 'linked_giftcard' + 'customer', + 'linked_giftcard', + ).prefetch_related( + Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order", "order__event")) ) if self.filter_form.is_valid(): qs = self.filter_form.filter_qs(qs) @@ -3433,10 +3435,14 @@ class ReusableMediumCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequ @transaction.atomic def form_valid(self, form): r = super().form_valid(form) - form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data={ + + data = { k: getattr(form.instance, k) for k in form.changed_data - }) + } + if "linked_orderpositions" in data: + data["linked_orderpositions"] = data["linked_orderpositions"].values_list("pk", flat=True) + form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data=data) messages.success(self.request, _('Your changes have been saved.')) return r @@ -3461,13 +3467,40 @@ class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequ @transaction.atomic def form_valid(self, form): + prev_linked_ops_pks = list(getattr(self.object, "linked_orderpositions").values_list("pk", flat=True)) + result = super().form_valid(form) if form.has_changed(): - self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data={ + data = { k: getattr(self.object, k) for k in form.changed_data - }) + } + if "linked_orderpositions" in data: + # handle changes to linked_orderpositions separately + linked_ops_pks = data["linked_orderpositions"].values_list("pk", flat=True) + del data["linked_orderpositions"] + for op_pk in prev_linked_ops_pks: + if op_pk not in linked_ops_pks: + self.object.log_action( + 'pretix.reusable_medium.linked_orderposition.removed', + user=self.request.user, + data={ + 'linked_orderposition': op_pk, + } + ) + for op_pk in linked_ops_pks: + if op_pk not in prev_linked_ops_pks: + self.object.log_action( + 'pretix.reusable_medium.linked_orderposition.added', + user=self.request.user, + data={ + 'linked_orderposition': op_pk, + } + ) + if data: + # log change-action only for changes other than linked_orderpositions + self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data=data) messages.success(self.request, _('Your changes have been saved.')) - return super().form_valid(form) + return result def get_success_url(self): return reverse('control:organizer.reusable_medium', kwargs={ diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 44e278a93d..78885344a2 100644 --- a/src/tests/api/test_checkinrpc.py +++ b/src/tests/api/test_checkinrpc.py @@ -286,12 +286,12 @@ def test_by_secret_special_chars(token_client, organizer, clist, event, order): @pytest.mark.django_db def test_by_medium(token_client, organizer, clist, event, order): with scopes_disabled(): - ReusableMedium.objects.create( + rm = ReusableMedium.objects.create( type="barcode", identifier="abcdef", organizer=organizer, - linked_orderposition=order.positions.first(), ) + rm.linked_orderpositions.add(order.positions.first()) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) assert resp.status_code == 201 assert resp.data['status'] == 'ok' @@ -301,6 +301,71 @@ def test_by_medium(token_client, organizer, clist, event, order): assert ci.raw_source_type == "barcode" +@pytest.mark.django_db +def test_by_medium_multiple_orderpositions(token_client, organizer, clist_all, event, order): + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="barcode", + identifier="abcdef", + organizer=organizer, + ) + op_item_first = order.positions.first() + rm.linked_orderpositions.add(op_item_first) + op_item_other = order.positions.all()[1] + rm.linked_orderpositions.add(op_item_other) + + # multiple tickets are valid => no check-in + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'ambiguous' + + with scopes_disabled(): + op_item_other.valid_from = datetime.datetime(2020, 1, 1, 12, 0, 0, tzinfo=event.timezone) + op_item_other.valid_until = datetime.datetime(2020, 1, 1, 15, 0, 0, tzinfo=event.timezone) + op_item_other.save() + + with freeze_time("2020-01-01 13:45:00"): + # multiple tickets are valid => no check-in + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'ambiguous' + + with freeze_time("2020-01-01 10:45:00"): + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + with freeze_time("2020-01-01 15:45:00"): + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'already_redeemed' + + with scopes_disabled(): + op_item_first.valid_from = datetime.datetime(2020, 1, 1, 10, 0, 0, tzinfo=event.timezone) + op_item_first.valid_until = datetime.datetime(2020, 1, 1, 12, 0, 0, tzinfo=event.timezone) + op_item_first.save() + + with freeze_time("2020-01-01 15:45:00"): + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'invalid_time' + + with scopes_disabled(): + op_item_first.canceled = True + op_item_first.save() + op_item_other.canceled = True + op_item_other.save() + + resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'canceled' + + @pytest.mark.django_db def test_by_medium_not_connected(token_client, organizer, clist, event, order): with scopes_disabled(): @@ -318,12 +383,12 @@ def test_by_medium_not_connected(token_client, organizer, clist, event, order): @pytest.mark.django_db def test_by_medium_wrong_event(token_client, organizer, clist, event, order2): with scopes_disabled(): - ReusableMedium.objects.create( + rm = ReusableMedium.objects.create( type="barcode", identifier="abcdef", organizer=organizer, - linked_orderposition=order2.positions.first(), ) + rm.linked_orderpositions.add(order2.positions.first()) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) assert resp.status_code == 404 assert resp.data['status'] == 'error' @@ -337,12 +402,12 @@ def test_by_medium_wrong_event(token_client, organizer, clist, event, order2): @pytest.mark.django_db def test_by_medium_wrong_type(token_client, organizer, clist, event, order): with scopes_disabled(): - ReusableMedium.objects.create( + rm = ReusableMedium.objects.create( type="nfc_uid", identifier="abcdef", organizer=organizer, - linked_orderposition=order.positions.first(), ) + rm.linked_orderpositions.add(order.positions.first()) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) assert resp.status_code == 404 assert resp.data['status'] == 'error' @@ -355,13 +420,13 @@ def test_by_medium_wrong_type(token_client, organizer, clist, event, order): @pytest.mark.django_db def test_by_medium_inactive(token_client, organizer, clist, event, order): with scopes_disabled(): - ReusableMedium.objects.create( + rm = ReusableMedium.objects.create( type="barcode", identifier="abcdef", organizer=organizer, active=False, - linked_orderposition=order.positions.first(), ) + rm.linked_orderpositions.add(order.positions.first()) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) assert resp.status_code == 404 assert resp.data['status'] == 'error' diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index fe0377946c..686405c5d9 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -3121,9 +3121,78 @@ def test_order_create_use_medium(token_client, organizer, event, item, quota, qu with scopes_disabled(): o = Order.objects.get(code=resp.data['code']) medium.refresh_from_db() - assert o.positions.first() == medium.linked_orderposition + assert o.positions.first() == medium.linked_orderpositions.first() assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier + 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 == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + medium.refresh_from_db() + assert medium.linked_orderpositions.count() == 1 + assert o.positions.first() == medium.linked_orderpositions.first() + assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier + + +@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.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 + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + medium.refresh_from_db() + assert medium.linked_orderpositions.count() == 1 + + 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 == 201 + with scopes_disabled(): + medium.refresh_from_db() + assert medium.linked_orderpositions.count() == 2 + + 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 + ), format='json', data=res + ) + assert resp.status_code == 201 + with scopes_disabled(): + o = Order.objects.get(code=resp.data['code']) + medium.refresh_from_db() + assert medium.linked_orderpositions.count() == 1 + assert o.positions.first() == medium.linked_orderpositions.first() + @pytest.mark.django_db def test_order_create_use_medium_other_organizer(token_client, organizer, event, item, quota, question, medium2): @@ -3168,7 +3237,7 @@ def test_order_create_create_medium(token_client, organizer, event, item, quota, i = resp.data['positions'][0]['pdf_data']['medium_identifier'] assert i m = organizer.reusable_media.get(identifier=i) - assert m.linked_orderposition == o.positions.first() + assert m.linked_orderpositions.first() == o.positions.first() assert m.type == "barcode" diff --git a/src/tests/api/test_reusable_media.py b/src/tests/api/test_reusable_media.py index 18ee325a53..2646d2a2dd 100644 --- a/src/tests/api/test_reusable_media.py +++ b/src/tests/api/test_reusable_media.py @@ -89,10 +89,13 @@ TEST_MEDIUM_RES = { "organizer": "dummy", "identifier": "ABCDEFGH", "type": "barcode", + "claim_token": None, + "label": None, "active": True, "expires": None, "customer": None, "linked_orderposition": None, + "linked_orderpositions": [], "linked_giftcard": None, "notes": None, "info": {}, @@ -170,7 +173,7 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, personalized=True) op = o.positions.create(item=ticket, price=Decimal("14")) - medium.linked_orderposition = op + medium.linked_orderpositions.add(op) medium.linked_giftcard = giftcard medium.customer = customer medium.save() @@ -273,7 +276,7 @@ def test_medium_detail_event_permission_missing(token_client, organizer, event, ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, personalized=True) op = o.positions.create(item=ticket, price=Decimal("14")) - medium.linked_orderposition = op + medium.linked_orderpositions.add(op) medium.linked_giftcard = giftcard medium.customer = customer medium.save() @@ -352,6 +355,110 @@ def test_medium_create(token_client, organizer, giftcard): assert m.updated > now() - timedelta(minutes=10) +@pytest.mark.django_db +def test_medium_create_linked_orderposition(token_client, organizer, event, org2_event, medium): + with scopes_disabled(): + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + total=14, locale='en' + ) + ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, + personalized=True) + op = o.positions.create(item=ticket, price=Decimal("14")) + op2 = o.positions.create(item=ticket, price=Decimal("14")) + + org2_o = Order.objects.create( + code='FOO', event=org2_event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + sales_channel=org2_event.organizer.sales_channels.get(identifier="web"), + total=14, locale='en' + ) + org2_ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, + personalized=True) + org2_op = org2_o.positions.create(item=org2_ticket, price=Decimal("14")) + + payload = dict(TEST_MEDIUM_CREATE_PAYLOAD) + + # wrong orderposition for organizer + payload['linked_orderposition'] = org2_op.pk + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + + # unkown orderposition + payload['linked_orderposition'] = "unknown" + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + + # create with linked_orderposition + payload['linked_orderposition'] = op.pk + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 201 + with scopes_disabled(): + m = ReusableMedium.objects.get(pk=resp.data['id']) + assert list(m.linked_orderpositions.values_list('pk', flat=True)) == [op.pk] + + # double-check API-response for fallback-values + resp = token_client.get( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, resp.data['id']) + ) + assert resp.status_code == 200 + assert resp.data['linked_orderposition'] == op.pk + assert resp.data['linked_orderpositions'] == [op.pk] + + # create with linked_orderposition and linked_orderpositions (not allowed) + payload['identifier'] = "FOOBAZ" + payload['linked_orderpositions'] = [op.pk, org2_op.pk] + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + + # multiple linked_orderpositions, but from different organizers + del payload['linked_orderposition'] + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 400 + + # multiple linked_orderpositions from same organizer + payload['linked_orderpositions'] = [op.pk, op2.pk] + resp = token_client.post( + '/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug), + payload, + format='json' + ) + assert resp.status_code == 201 + with scopes_disabled(): + m = ReusableMedium.objects.get(pk=resp.data['id']) + assert list(m.linked_orderpositions.values_list('pk', flat=True)) == [op.pk, op2.pk] + + # double-check API-response for fallback-values + resp = token_client.get( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, resp.data['id']) + ) + assert resp.status_code == 200 + assert resp.data['linked_orderposition'] is None + assert resp.data['linked_orderpositions'] == [op.pk, op2.pk] + + @pytest.mark.django_db def test_medium_foreignkeyval(token_client, organizer, giftcard2): payload = dict(TEST_MEDIUM_CREATE_PAYLOAD) @@ -398,6 +505,68 @@ def test_medium_patch(token_client, organizer, event, medium, giftcard, customer assert medium.info == {'test': 2} assert medium.identifier == "ABCDEFGH" + # test patch with linked_orderpositions + with scopes_disabled(): + o = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + total=14, locale='en' + ) + ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, + personalized=True) + op = o.positions.create(item=ticket, price=Decimal("14")) + op2 = o.positions.create(item=ticket, price=Decimal("14")) + + resp = token_client.patch( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + { + 'linked_orderposition': op.pk, + }, + format='json' + ) + assert resp.status_code == 200 + medium.refresh_from_db() + with scopes_disabled(): + assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op.pk] + assert medium.all_logentries().count() == 2 + + resp = token_client.patch( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + { + 'linked_orderpositions': [op.pk, op2.pk], + }, + format='json' + ) + assert resp.status_code == 200 + medium.refresh_from_db() + with scopes_disabled(): + assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op.pk, op2.pk] + assert medium.all_logentries().count() == 3 + + resp = token_client.patch( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + { + 'linked_orderpositions': [op2.pk], + }, + format='json' + ) + assert resp.status_code == 200 + medium.refresh_from_db() + with scopes_disabled(): + assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op2.pk] + assert medium.all_logentries().count() == 4 + + resp = token_client.patch( + '/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk), + { + 'linked_orderposition': op.pk, + 'linked_orderpositions': [op.pk, op2.pk], + }, + format='json' + ) + assert resp.status_code == 400 + @pytest.mark.django_db def test_medium_no_deletion(token_client, organizer, event, medium): @@ -538,7 +707,7 @@ def test_medium_lookup_cross_organizer(token_client, organizer, organizer2, org2 ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True, personalized=True) op = o.positions.create(item=ticket, price=Decimal("14")) - medium2.linked_orderposition = op + medium2.linked_orderpositions.add(op) medium2.linked_giftcard = giftcard2 medium2.save()