Compare commits

...

52 Commits

Author SHA1 Message Date
Maximilian Richt
eac917b629 Add copy and qr button to reusable medium detail view 2026-01-22 14:11:00 +01:00
Maximilian Richt
41300b012d Fix sorting of reusable media type in overview 2026-01-22 13:56:49 +01:00
Richard Schreiber
6e7e4b47f9 improve check 2025-12-17 13:48:13 +01:00
Richard Schreiber
b391fe0914 fix flake8 2025-12-17 09:22:49 +01:00
Richard Schreiber
5927161be8 fix multi-op media filter 2025-12-17 09:07:34 +01:00
Richard Schreiber
068280a315 fix indentation 2025-12-16 15:08:17 +01:00
Richard Schreiber
0ba3057d10 Add help-text 2025-12-16 15:04:09 +01:00
Richard Schreiber
e10af17940 add test 2025-12-16 15:02:40 +01:00
Richard Schreiber
300fb41005 fix linked_orderpositions filter in checkinrpc 2025-12-16 13:35:35 +01:00
Richard Schreiber
e6c53128d2 Add test for order-API add_to_reusable_medium 2025-12-16 12:54:13 +01:00
Richard Schreiber
75a34761a8 list ops comma-separated in export 2025-12-16 12:23:24 +01:00
Richard Schreiber
051f2fabb3 Clarify docs 2025-12-16 12:20:07 +01:00
Richard Schreiber
43ebb1bc38 fix flake8 2025-12-16 10:08:21 +01:00
Richard Schreiber
215e14d517 fix tests regarding claim_token 2025-12-16 10:05:20 +01:00
Richard Schreiber
83016ffd25 add add_to_reusable_medium to order-serializer 2025-12-16 09:23:33 +01:00
Richard Schreiber
fbdf915ecf fix flake8 2025-12-16 08:25:36 +01:00
Richard Schreiber
c02c77d2a3 fix missing claim_token in serializer 2025-12-16 08:17:37 +01:00
Richard Schreiber
c98106312c fix disallow of use of singular and plural linked_op in API 2025-12-16 08:15:34 +01:00
Richard Schreiber
b23b040152 Update reusablemedia.rst 2025-12-16 08:10:26 +01:00
Richard Schreiber
cae8af6042 Update reusablemedia.rst 2025-12-16 08:08:53 +01:00
Richard Schreiber
5b902683d4 Update reusablemedia.rst 2025-12-16 08:08:15 +01:00
Richard Schreiber
a404fe64f4 unifiy deprecated style 2025-12-16 08:06:04 +01:00
Richard Schreiber
2117f02be0 Update docs for claim_token 2025-12-16 08:03:06 +01:00
Richard Schreiber
26c19017be rename secret to claim_token 2025-12-16 08:00:13 +01:00
Richard Schreiber
f51ae3d22a add filter based on op.valid_from/until 2025-12-15 10:39:44 +01:00
Richard Schreiber
0cbe176f88 select_related order instead prefetch 2025-12-15 10:32:03 +01:00
Richard Schreiber
9e703b006a improve readability 2025-12-15 10:31:28 +01:00
Richard Schreiber
94bd101421 no need to prefetch linked_orderpositions 2025-12-15 10:31:12 +01:00
Richard Schreiber
f3c432ab93 clarify docs 2025-12-03 15:10:42 +01:00
Richard Schreiber
ee891d7f9d clarify docs updating multiple linked_orderpositions 2025-12-02 07:47:02 +01:00
Richard Schreiber
e238b512a8 update docs 2025-12-01 13:48:34 +01:00
Richard Schreiber
a69a056ab8 fix tests 2025-12-01 13:48:27 +01:00
Richard Schreiber
ad114651ad fix migration NOT NULL 2025-12-01 12:48:53 +01:00
Richard Schreiber
28e70f1303 add label to reusablemedium 2025-12-01 12:06:59 +01:00
Richard Schreiber
9cdeffa6fb fix code style 2025-12-01 09:53:24 +01:00
Richard Schreiber
2cb3d7b356 fix more tests 2025-12-01 09:41:37 +01:00
Richard Schreiber
c3c817b16c fix tests 2025-11-28 16:07:27 +01:00
Richard Schreiber
dd11149b4e fix create/update logging 2025-11-28 15:48:15 +01:00
Richard Schreiber
55b881ddc9 remove unneeded comment 2025-11-28 15:41:13 +01:00
Richard Schreiber
d9986e72cd adapt checkin API for multiple orderpositions 2025-11-28 15:33:08 +01:00
Richard Schreiber
2759639f1f fix media-issue signal 2025-11-28 15:29:46 +01:00
Richard Schreiber
77f788457b remove cached_property linked_orderposition - keep only in API 2025-11-28 15:29:18 +01:00
Richard Schreiber
4ae84e798a fix API orders matching media 2025-11-28 15:28:41 +01:00
Richard Schreiber
b51ac9982c fix API orders 2025-11-28 15:28:11 +01:00
Richard Schreiber
ccdc3837fb update control media forms 2025-11-28 15:27:34 +01:00
Richard Schreiber
d5b1143666 fix media-view filter 2025-11-28 15:26:50 +01:00
Richard Schreiber
730545f8c5 fix last media-API commit 2025-11-28 15:25:49 +01:00
Richard Schreiber
820c039e2b update media-API 2025-11-28 15:25:11 +01:00
Richard Schreiber
523c97603c add multi-op to export 2025-11-28 14:38:16 +01:00
Richard Schreiber
ce3f54b22d return last op as fallback for linked_orderposition 2025-11-28 14:37:58 +01:00
Richard Schreiber
7a4e5ae955 Update media views to list ops 2025-11-28 14:37:13 +01:00
Richard Schreiber
9d52cd9531 change linked orderpositions to many-to-many 2025-11-28 13:55:01 +01:00
22 changed files with 424 additions and 90 deletions

View File

@@ -192,7 +192,7 @@ Cart position endpoints
* ``attendee_email`` (optional) * ``attendee_email`` (optional)
* ``subevent`` (optional) * ``subevent`` (optional)
* ``expires`` (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) * ``sales_channel`` (optional)
* ``voucher`` (optional, expect a voucher code) * ``voucher`` (optional, expect a voucher code)
* ``addons`` (optional, expect a list of nested objects of cart positions) * ``addons`` (optional, expect a list of nested objects of cart positions)

View File

@@ -1066,6 +1066,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) * ``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) * ``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) * ``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) * ``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`` * ``answers``

View File

@@ -21,12 +21,16 @@ id integer Internal ID of
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``. type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
organizer string Organizer slug of the organizer who "owns" this medium. organizer string Organizer slug of the organizer who "owns" this medium.
identifier string Unique identifier of the medium. The format depends on the ``type``. 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. active boolean Whether this medium may be used.
created datetime Date of creation created datetime Date of creation
updated datetime Date of last modification updated datetime Date of last modification
expires datetime Expiry date (or ``null``) expires datetime Expiry date (or ``null``)
customer string Identifier of a customer account this medium belongs to. 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 integer 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. linked_giftcard integer Internal ID of a gift card this medium is linked to.
info object Additional data, content depends on the ``type``. Consider info object Additional data, content depends on the ``type``. Consider
this internal to the system and don't use it for your own data. 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_uid``
- ``nfc_mf0aes`` - ``nfc_mf0aes``
.. versionchanged:: 2025.11
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 Endpoints
--------- ---------
@@ -77,6 +89,7 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -92,10 +105,13 @@ Endpoints
:query string customer: Only show media linked to the given customer. :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 created_since: Only show media created since a given date.
:query string updated_since: Only show media updated 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_orderposition: Only show media linked to the given ticket.
:query integer linked_giftcard: Only show media linked to the given gift card. :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"``, :query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderpositions"``,
or ``"customer"``, the respective field will be shown as a nested value instead of just an ID. ``"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 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 will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
matching easier. The parameter can be given multiple times. matching easier. The parameter can be given multiple times.
@@ -134,6 +150,7 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -191,6 +208,7 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -198,9 +216,9 @@ Endpoints
} }
:param organizer: The ``slug`` field of the organizer to look up a medium for :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 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 format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times. can be given multiple times.
:statuscode 201: no error :statuscode 201: no error
@@ -227,6 +245,7 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -251,6 +270,7 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderpositions": [],
"linked_orderposition": None, "linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
@@ -258,7 +278,7 @@ Endpoints
} }
:param organizer: The ``slug`` field of the organizer to create a medium for :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 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_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
@@ -287,7 +307,7 @@ Endpoints
Content-Length: 94 Content-Length: 94
{ {
"linked_orderposition": 13 "linked_orderpositions": [13, 29]
} }
**Example response**: **Example response**:
@@ -308,7 +328,8 @@ Endpoints
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderposition": 13, "linked_orderpositions": [13, 29],
"linked_orderposition": None,
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
"info": {} "info": {}
@@ -316,7 +337,7 @@ Endpoints
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the medium 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 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_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter

View File

@@ -65,9 +65,11 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'): expand_nested = self.context['request'].query_params.getlist('expand')
if 'linked_giftcard' in expand_nested:
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context) 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) self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
else: else:
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField( self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
@@ -76,16 +78,27 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
queryset=self.context['organizer'].issued_gift_cards.all() queryset=self.context['organizer'].issued_gift_cards.all()
) )
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'): # keep linked_orderposition (singular) for backwards compatibility, will be overwritten in self.validate
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True) 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: else:
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField( self.fields['linked_orderpositions'] = serializers.PrimaryKeyRelatedField(
many=True,
required=False, required=False,
allow_null=True, allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']), 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:
self.fields['customer'] = CustomerSerializer(read_only=True) self.fields['customer'] = CustomerSerializer(read_only=True)
else: else:
self.fields['customer'] = serializers.SlugRelatedField( self.fields['customer'] = serializers.SlugRelatedField(
@@ -97,6 +110,20 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
def validate(self, data): def validate(self, data):
data = super().validate(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.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 []
if 'type' in data and 'identifier' in data: if 'type' in data and 'identifier' in data:
qs = self.context['organizer'].reusable_media.filter( qs = self.context['organizer'].reusable_media.filter(
identifier=data['identifier'], type=data['type'] identifier=data['identifier'], type=data['type']
@@ -109,6 +136,14 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
) )
return data return data
def to_representation(self, obj):
r = super(ReusableMediaSerializer, self).to_representation(obj)
ops = r.get('linked_orderpositions')
if len(ops) < 2:
# add linked_orderposition (singular) for backwards compatibility
r['linked_orderposition'] = ops[0] if ops else None
return r
class Meta: class Meta:
model = ReusableMedium model = ReusableMedium
fields = ( fields = (
@@ -118,10 +153,12 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
'updated', 'updated',
'type', 'type',
'identifier', 'identifier',
'claim_token',
'label',
'active', 'active',
'expires', 'expires',
'customer', 'customer',
'linked_orderposition', 'linked_orderpositions',
'linked_giftcard', 'linked_giftcard',
'info', 'info',
'notes', 'notes',

View File

@@ -1019,13 +1019,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
requested_valid_from = serializers.DateTimeField(required=False, allow_null=True) requested_valid_from = serializers.DateTimeField(required=False, allow_null=True)
use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(), use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
required=False, allow_null=True) required=False, allow_null=True)
add_to_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
required=False, allow_null=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled', 'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until', '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): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -1037,6 +1039,8 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
with scopes_disabled(): with scopes_disabled():
if 'use_reusable_medium' in self.fields: if 'use_reusable_medium' in self.fields:
self.fields['use_reusable_medium'].queryset = ReusableMedium.objects.all() 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): def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -1052,6 +1056,9 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
) )
return m return m
def validate_add_to_reusable_medium(self, m):
return self.validate_use_reusable_medium(m)
def validate_item(self, item): def validate_item(self, item):
if item.event != self.context['event']: if item.event != self.context['event']:
raise ValidationError( raise ValidationError(
@@ -1125,6 +1132,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError( raise ValidationError(
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']} {'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 return data
@@ -1540,7 +1554,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos_data['attendee_name_parts'] = { pos_data['attendee_name_parts'] = {
'_legacy': attendee_name '_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: if simulate:
pos.order = order._wrapped pos.order = order._wrapped
else: else:
@@ -1614,6 +1628,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
for pos_data in positions_data: for pos_data in positions_data:
answers_data = pos_data.pop('answers', []) answers_data = pos_data.pop('answers', [])
use_reusable_medium = pos_data.pop('use_reusable_medium', None) 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 = pos_data['__instance']
pos._calculate_tax(invoice_address=ia) pos._calculate_tax(invoice_address=ia)
@@ -1655,8 +1670,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answ.options.add(*options) answ.options.add(*options)
if use_reusable_medium: if use_reusable_medium:
use_reusable_medium.linked_orderposition = pos use_reusable_medium.linked_orderpositions.set([pos])
use_reusable_medium.save(update_fields=['linked_orderposition'])
use_reusable_medium.log_action( use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.changed', 'pretix.reusable_medium.linked_orderposition.changed',
data={ data={
@@ -1664,6 +1678,15 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
'linked_orderposition': pos.pk, 'linked_orderposition': pos.pk,
} }
) )
if 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,
}
)
if not simulate: if not simulate:
for cp in delete_cps: for cp in delete_cps:

View File

@@ -503,11 +503,13 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# with respecting the force option), or it's a reusable medium (-> proceed with that) # with respecting the force option), or it's a reusable medium (-> proceed with that)
if not op_candidates: if not op_candidates:
try: try:
media = ReusableMedium.objects.select_related('linked_orderposition').active().get( media = ReusableMedium.objects.active().annotate(
has_linked_orderpositions=Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
).get(
organizer_id=checkinlists[0].event.organizer_id, organizer_id=checkinlists[0].event.organizer_id,
type=source_type, type=source_type,
identifier=raw_barcode, identifier=raw_barcode,
linked_orderposition__isnull=False, has_linked_orderpositions=True,
) )
raw_barcode_for_checkin = raw_barcode raw_barcode_for_checkin = raw_barcode
except ReusableMedium.DoesNotExist: except ReusableMedium.DoesNotExist:
@@ -610,7 +612,8 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data, 'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400) }, status=400)
else: else:
if media.linked_orderposition.order.event_id not in list_by_event: linked_event_ids = media.linked_orderpositions.values_list("order__event_id", flat=True).order_by().distinct()
if not any(event_id in list_by_event for event_id in linked_event_ids):
# Medium exists but connected ticket is for the wrong event # Medium exists but connected ticket is for the wrong event
if not simulate: if not simulate:
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={ checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
@@ -636,21 +639,34 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
'checkin_texts': [], 'checkin_texts': [],
'list': MiniCheckinListSerializer(checkinlists[0]).data, 'list': MiniCheckinListSerializer(checkinlists[0]).data,
}, status=404) }, status=404)
op_candidates = [media.linked_orderposition] op_candidates = []
if list_by_event[media.linked_orderposition.order.event_id].addon_match: for op in media.linked_orderpositions.all().select_related("order"):
op_candidates += list(media.linked_orderposition.addons.all()) 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 # 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 # 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. # which add-on has the right product.
if len(op_candidates) > 1: if len(op_candidates) > 1:
op_candidates_matching_product = [ # only check addons if at most one non-addon-op is in op_candidates
op for op in op_candidates # otherwise it is likely a medium linked to multiple orderpositions, which we need to filter based on validity
if ( if len([op for op in op_candidates if not op.addon_to]) <= 1:
(list_by_event[op.order.event_id].addon_match or op.secret == raw_barcode or legacy_url_support) and op_candidates_matching_product = [
(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()}) 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()})
)
]
else:
op_candidates_matching_product = [
op for op in op_candidates
if (
(not op.valid_from or op.valid_from < now()) and
(not op.valid_until or op.valid_until > now())
)
]
if len(op_candidates_matching_product) == 0: 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 # None of the found add-ons has the correct product, too bad! We could just error out here, but

View File

@@ -53,10 +53,12 @@ with scopes_disabled():
customer = django_filters.CharFilter(field_name='customer__identifier') customer = django_filters.CharFilter(field_name='customer__identifier')
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte') updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='created', 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: class Meta:
model = ReusableMedium model = ReusableMedium
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard'] fields = ['identifier', 'type', 'active', 'customer', 'linked_orderpositions', 'linked_giftcard']
class ReusableMediaViewSet(viewsets.ModelViewSet): class ReusableMediaViewSet(viewsets.ModelViewSet):
@@ -75,7 +77,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
).order_by().values('card').annotate(s=Sum('value')).values('s') ).order_by().values('card').annotate(s=Sum('value')).values('s')
return self.request.organizer.reusable_media.prefetch_related( return self.request.organizer.reusable_media.prefetch_related(
Prefetch( Prefetch(
'linked_orderposition', 'linked_orderpositions',
queryset=OrderPosition.objects.select_related( queryset=OrderPosition.objects.select_related(
'order', 'order__event', 'order__event__organizer', 'seat', 'order', 'order__event', 'order__event__organizer', 'seat',
).prefetch_related( ).prefetch_related(
@@ -155,7 +157,6 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
type=s.validated_data["type"], type=s.validated_data["type"],
identifier=s.validated_data["identifier"], identifier=s.validated_data["identifier"],
) )
m.linked_orderposition = None # not relevant for cross-organizer
m.customer = None # not relevant for cross-organizer m.customer = None # not relevant for cross-organizer
s = self.get_serializer(m) s = self.get_serializer(m)
return Response({"result": s.data}) return Response({"result": s.data})

View File

@@ -194,7 +194,7 @@ with scopes_disabled():
) )
).values('id') ).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 = ( mainq = (
code code
@@ -1036,7 +1036,7 @@ with scopes_disabled():
search = django_filters.CharFilter(method='search_qs') search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value): 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( return queryset.filter(
Q(secret__istartswith=value) Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value) | Q(attendee_name_cached__icontains=value)

View File

@@ -20,12 +20,13 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from django.db.models import Prefetch
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy
from ..exporter import ListExporter, OrganizerLevelExportMixin from ..exporter import ListExporter, OrganizerLevelExportMixin
from ..models import ReusableMedium from ..models import OrderPosition, ReusableMedium
from ..signals import register_multievent_data_exporters from ..signals import register_multievent_data_exporters
@@ -40,7 +41,9 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
media = ReusableMedium.objects.filter( media = ReusableMedium.objects.filter(
organizer=self.organizer, organizer=self.organizer,
).select_related( ).select_related(
'customer', 'linked_orderposition', 'linked_giftcard', 'customer', 'linked_giftcard',
).prefetch_related(
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order"))
).order_by('created') ).order_by('created')
headers = [ headers = [
@@ -58,17 +61,16 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
yield self.ProgressSetTotal(total=media.count()) yield self.ProgressSetTotal(total=media.count())
for medium in media.iterator(chunk_size=1000): for medium in media.iterator(chunk_size=1000):
row = [ yield [
medium.type, medium.type,
medium.identifier, medium.identifier,
_('Yes') if medium.active else _('No'), _('Yes') if medium.active else _('No'),
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '', date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
medium.customer.identifier if medium.customer_id 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.linked_giftcard.secret if medium.linked_giftcard_id else '',
medium.notes, medium.notes,
] ]
yield row
def get_filename(self): def get_filename(self):
return f'{self.organizer.slug}_media' return f'{self.organizer.slug}_media'

View File

@@ -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", "0296_invoice_invoice_from_state"),
]
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;",
),
]

View File

@@ -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", "0297_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"
),
),
]

View File

@@ -72,6 +72,16 @@ class ReusableMedium(LoggedModel):
max_length=200, max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'), 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( active = models.BooleanField(
verbose_name=_('Active'), verbose_name=_('Active'),
@@ -89,12 +99,14 @@ class ReusableMedium(LoggedModel):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
verbose_name=_('Customer account'), verbose_name=_('Customer account'),
) )
linked_orderposition = models.ForeignKey( linked_orderpositions = models.ManyToManyField(
OrderPosition, OrderPosition,
null=True, blank=True,
related_name='linked_media', related_name='linked_media',
on_delete=models.SET_NULL, verbose_name=_('Linked tickets'),
verbose_name=_('Linked ticket'), 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( linked_giftcard = models.ForeignKey(
GiftCard, GiftCard,

View File

@@ -3471,8 +3471,8 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
identifier=mt.generate_identifier(sender.organizer), identifier=mt.generate_identifier(sender.organizer),
active=True, active=True,
customer=order.customer, customer=order.customer,
linked_orderposition=p,
) )
rm.linked_orderpositions.add(p)
rm.log_action( rm.log_action(
'pretix.reusable_medium.created', 'pretix.reusable_medium.created',
data={ data={

View File

@@ -1616,7 +1616,7 @@ class ReusableMediaFilterForm(FilterForm):
Q(identifier__icontains=query) Q(identifier__icontains=query)
| Q(customer__identifier__icontains=query) | Q(customer__identifier__icontains=query)
| Q(customer__external_identifier__istartswith=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) | Q(linked_giftcard__secret__icontains=query)
) )

View File

@@ -83,7 +83,7 @@ from pretix.control.forms import ExtFileField, SplitDateTimeField
from pretix.control.forms.event import ( from pretix.control.forms.event import (
SafeEventMultipleChoiceField, multimail_validate, 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.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
@@ -246,6 +246,12 @@ class SafeOrderPositionChoiceField(forms.ModelChoiceField):
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})' 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)
class EventMetaPropertyForm(I18nModelForm): class EventMetaPropertyForm(I18nModelForm):
class Meta: class Meta:
model = EventMetaProperty model = EventMetaProperty
@@ -828,12 +834,12 @@ class ReusableMediumUpdateForm(forms.ModelForm):
class Meta: class Meta:
model = ReusableMedium model = ReusableMedium
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes'] fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderpositions', 'notes']
field_classes = { field_classes = {
'expires': SplitDateTimeField, 'expires': SplitDateTimeField,
'customer': SafeModelChoiceField, 'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField, 'linked_giftcard': SafeModelChoiceField,
'linked_orderposition': SafeOrderPositionChoiceField, 'linked_orderpositions': SafeOrderPositionMultipleChoiceField,
} }
widgets = { widgets = {
'expires': SplitDateTimePickerWidget, 'expires': SplitDateTimePickerWidget,
@@ -843,8 +849,8 @@ class ReusableMediumUpdateForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
organizer = self.instance.organizer organizer = self.instance.organizer
self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all() self.fields['linked_orderpositions'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
self.fields['linked_orderposition'].widget = Select2( self.fields['linked_orderpositions'].widget = Select2Multiple(
attrs={ attrs={
'data-model-select2': 'generic', 'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={ 'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
@@ -852,8 +858,8 @@ class ReusableMediumUpdateForm(forms.ModelForm):
}), }),
} }
) )
self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices self.fields['linked_orderpositions'].widget.choices = self.fields['linked_orderpositions'].choices
self.fields['linked_orderposition'].required = False self.fields['linked_orderpositions'].required = False
self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all() self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all()
self.fields['linked_giftcard'].widget = Select2( self.fields['linked_giftcard'].widget = Select2(
@@ -907,12 +913,12 @@ class ReusableMediumCreateForm(ReusableMediumUpdateForm):
class Meta: class Meta:
model = ReusableMedium 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 = { field_classes = {
'expires': SplitDateTimeField, 'expires': SplitDateTimeField,
'customer': SafeModelChoiceField, 'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField, 'linked_giftcard': SafeModelChoiceField,
'linked_orderposition': SafeOrderPositionChoiceField, 'linked_orderpositions': SafeOrderPositionMultipleChoiceField,
} }
widgets = { widgets = {
'expires': SplitDateTimePickerWidget, 'expires': SplitDateTimePickerWidget,

View File

@@ -733,6 +733,7 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.reusable_medium.created': _('The reusable medium has been created.'), '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.created.auto': _('The reusable medium has been created automatically.'),
'pretix.reusable_medium.changed': _('The reusable medium has been changed.'), '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.changed': _('The medium has been connected to a new ticket.'), '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.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'),
'pretix.email.error': _('Sending of an email has failed.'), 'pretix.email.error': _('Sending of an email has failed.'),

View File

@@ -54,8 +54,8 @@
<a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a> <a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a>
</th> </th>
<th>{% trans "Media type" context "reusable_media" %} <th>{% trans "Media type" context "reusable_media" %}
<a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a> <a href="?{% url_replace request 'ordering' '-type' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a></th> <a href="?{% url_replace request 'ordering' 'type' %}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Connections" context "reusable_media" %}</th> <th>{% trans "Connections" context "reusable_media" %}</th>
<th></th> <th></th>
</tr> </tr>
@@ -82,13 +82,13 @@
</a> </a>
</span> </span>
{% endif %} {% endif %}
{% if m.linked_orderposition %} {% for op in m.linked_orderpositions.all %}
<span class="helper-display-block"> <span class="helper-display-block">
<span class="fa fa-ticket fa-fw"></span> <span class="fa fa-ticket fa-fw"></span>
<a href="{% url "control:event.order" event=m.linked_orderposition.order.event.slug organizer=request.organizer.slug code=m.linked_orderposition.order.code %}"> <a href="{% url "control:event.order" event=op.order.event.slug organizer=request.organizer.slug code=op.order.code %}">
{{ m.linked_orderposition.order.code }}</a>-{{ m.linked_orderposition.positionid }} {{ op.order.code }}</a>-{{ op.positionid }}
</span> </span>
{% endif %} {% endfor %}
{% if m.linked_giftcard %} {% if m.linked_giftcard %}
<span class="helper-display-block"> <span class="helper-display-block">
<span class="fa fa-credit-card fa-fw"></span> <span class="fa fa-credit-card fa-fw"></span>

View File

@@ -28,7 +28,19 @@
<dt>{% trans "Media type" context "reusable_media" %}</dt> <dt>{% trans "Media type" context "reusable_media" %}</dt>
<dd>{{ medium.get_type_display }}</dd> <dd>{{ medium.get_type_display }}</dd>
<dt>{% trans "Identifier" context "reusable_media" %}</dt> <dt>{% trans "Identifier" context "reusable_media" %}</dt>
<dd><code>{{ medium.identifier }}</code></dd> <dd>
<code id="medium_identifier">{{ medium.identifier }}</code>
<button type="button" class="btn btn-default btn-xs btn-clipboard js-only" data-clipboard-target="#medium_identifier">
<i class="fa fa-clipboard" aria-hidden="true"></i>
<span class="sr-only">{% trans "Copy to clipboard" %}</span>
</button>
{% if medium.type == "barcode" %}
<button type="button" class="btn btn-default btn-xs js-only" data-toggle="qrcode" data-qrcode="{{ medium.identifier }}">
<i class="fa fa-qrcode" aria-hidden="true"></i>
<span class="sr-only">{% trans "Create QR code" %}</span>
</button>
{% endif %}
</dd>
<dt>{% trans "Status" %}</dt> <dt>{% trans "Status" %}</dt>
<dd> <dd>
{% if not medium.active %} {% if not medium.active %}
@@ -49,13 +61,13 @@
</a> </a>
</span> </span>
{% endif %} {% endif %}
{% if medium.linked_orderposition %} {% for op in medium.linked_orderpositions.all %}
<span class="helper-display-block"> <span class="helper-display-block">
<span class="fa fa-ticket fa-fw"></span> <span class="fa fa-ticket fa-fw"></span>
<a href="{% url "control:event.order" event=medium.linked_orderposition.order.event.slug organizer=request.organizer.slug code=medium.linked_orderposition.order.code %}"> <a href="{% url "control:event.order" event=op.order.event.slug organizer=request.organizer.slug code=op.order.code %}">
{{ medium.linked_orderposition.order.code }}</a>-{{ medium.linked_orderposition.positionid }} {{ op.order.code }}</a>-{{ op.positionid }}
</span> </span>
{% endif %} {% endfor %}
{% if medium.linked_giftcard %} {% if medium.linked_giftcard %}
<span class="helper-display-block"> <span class="helper-display-block">
<span class="fa fa-credit-card fa-fw"></span> <span class="fa fa-credit-card fa-fw"></span>

View File

@@ -3300,8 +3300,10 @@ class ReusableMediaListView(OrganizerDetailViewMixin, OrganizerPermissionRequire
def get_queryset(self): def get_queryset(self):
qs = self.request.organizer.reusable_media.select_related( qs = self.request.organizer.reusable_media.select_related(
'customer', 'linked_orderposition', 'linked_orderposition__order', 'linked_orderposition__order__event', 'customer',
'linked_giftcard' 'linked_giftcard',
).prefetch_related(
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order", "order__event"))
) )
if self.filter_form.is_valid(): if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs) qs = self.filter_form.filter_qs(qs)
@@ -3349,10 +3351,14 @@ class ReusableMediumCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
r = super().form_valid(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) k: getattr(form.instance, k)
for k in form.changed_data 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.')) messages.success(self.request, _('Your changes have been saved.'))
return r return r
@@ -3378,10 +3384,13 @@ class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
@transaction.atomic @transaction.atomic
def form_valid(self, form): def form_valid(self, form):
if form.has_changed(): if form.has_changed():
self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data={ data = {
k: getattr(self.object, k) k: getattr(self.object, k)
for k in form.changed_data for k in form.changed_data
}) }
if "linked_orderpositions" in data:
data["linked_orderpositions"] = data["linked_orderpositions"].values_list("pk", flat=True)
self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data=data)
messages.success(self.request, _('Your changes have been saved.')) messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form) return super().form_valid(form)

View File

@@ -286,12 +286,12 @@ def test_by_secret_special_chars(token_client, organizer, clist, event, order):
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium(token_client, organizer, clist, event, order): def test_by_medium(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
ReusableMedium.objects.create( rm = ReusableMedium.objects.create(
type="barcode", type="barcode",
identifier="abcdef", identifier="abcdef",
organizer=organizer, organizer=organizer,
linked_orderposition=order.positions.first(),
) )
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 201 assert resp.status_code == 201
assert resp.data['status'] == 'ok' assert resp.data['status'] == 'ok'
@@ -301,6 +301,48 @@ def test_by_medium(token_client, organizer, clist, event, order):
assert ci.raw_source_type == "barcode" assert ci.raw_source_type == "barcode"
@pytest.mark.django_db
def test_by_medium_multiple_orderpositions(token_client, organizer, clist, event, order):
with scopes_disabled():
rm = ReusableMedium.objects.create(
type="barcode",
identifier="abcdef",
organizer=organizer,
)
rm.linked_orderpositions.add(order.positions.first())
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, "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, "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, "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, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 400
assert resp.data['status'] == 'error'
assert resp.data['reason'] == 'already_redeemed'
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium_not_connected(token_client, organizer, clist, event, order): def test_by_medium_not_connected(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
@@ -318,12 +360,12 @@ def test_by_medium_not_connected(token_client, organizer, clist, event, order):
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium_wrong_event(token_client, organizer, clist, event, order2): def test_by_medium_wrong_event(token_client, organizer, clist, event, order2):
with scopes_disabled(): with scopes_disabled():
ReusableMedium.objects.create( rm = ReusableMedium.objects.create(
type="barcode", type="barcode",
identifier="abcdef", identifier="abcdef",
organizer=organizer, organizer=organizer,
linked_orderposition=order2.positions.first(),
) )
rm.linked_orderpositions.add(order2.positions.first())
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.data['status'] == 'error' assert resp.data['status'] == 'error'
@@ -337,12 +379,12 @@ def test_by_medium_wrong_event(token_client, organizer, clist, event, order2):
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium_wrong_type(token_client, organizer, clist, event, order): def test_by_medium_wrong_type(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
ReusableMedium.objects.create( rm = ReusableMedium.objects.create(
type="nfc_uid", type="nfc_uid",
identifier="abcdef", identifier="abcdef",
organizer=organizer, organizer=organizer,
linked_orderposition=order.positions.first(),
) )
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.data['status'] == 'error' assert resp.data['status'] == 'error'
@@ -355,13 +397,13 @@ def test_by_medium_wrong_type(token_client, organizer, clist, event, order):
@pytest.mark.django_db @pytest.mark.django_db
def test_by_medium_inactive(token_client, organizer, clist, event, order): def test_by_medium_inactive(token_client, organizer, clist, event, order):
with scopes_disabled(): with scopes_disabled():
ReusableMedium.objects.create( rm = ReusableMedium.objects.create(
type="barcode", type="barcode",
identifier="abcdef", identifier="abcdef",
organizer=organizer, organizer=organizer,
active=False, active=False,
linked_orderposition=order.positions.first(),
) )
rm.linked_orderpositions.add(order.positions.first())
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"}) resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
assert resp.status_code == 404 assert resp.status_code == 404
assert resp.data['status'] == 'error' assert resp.data['status'] == 'error'

View File

@@ -3086,9 +3086,78 @@ def test_order_create_use_medium(token_client, organizer, event, item, quota, qu
with scopes_disabled(): with scopes_disabled():
o = Order.objects.get(code=resp.data['code']) o = Order.objects.get(code=resp.data['code'])
medium.refresh_from_db() 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 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 @pytest.mark.django_db
def test_order_create_use_medium_other_organizer(token_client, organizer, event, item, quota, question, medium2): def test_order_create_use_medium_other_organizer(token_client, organizer, event, item, quota, question, medium2):
@@ -3133,7 +3202,7 @@ def test_order_create_create_medium(token_client, organizer, event, item, quota,
i = resp.data['positions'][0]['pdf_data']['medium_identifier'] i = resp.data['positions'][0]['pdf_data']['medium_identifier']
assert i assert i
m = organizer.reusable_media.get(identifier=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" assert m.type == "barcode"

View File

@@ -89,10 +89,13 @@ TEST_MEDIUM_RES = {
"organizer": "dummy", "organizer": "dummy",
"identifier": "ABCDEFGH", "identifier": "ABCDEFGH",
"type": "barcode", "type": "barcode",
"claim_token": None,
"label": None,
"active": True, "active": True,
"expires": None, "expires": None,
"customer": None, "customer": None,
"linked_orderposition": None, "linked_orderposition": None,
"linked_orderpositions": [],
"linked_giftcard": None, "linked_giftcard": None,
"notes": None, "notes": None,
"info": {}, "info": {},
@@ -138,7 +141,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, ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True) personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14")) op = o.positions.create(item=ticket, price=Decimal("14"))
medium.linked_orderposition = op medium.linked_orderpositions.add(op)
medium.linked_giftcard = giftcard medium.linked_giftcard = giftcard
medium.customer = customer medium.customer = customer
medium.save() medium.save()
@@ -419,7 +422,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, ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
personalized=True) personalized=True)
op = o.positions.create(item=ticket, price=Decimal("14")) op = o.positions.create(item=ticket, price=Decimal("14"))
medium2.linked_orderposition = op medium2.linked_orderpositions.add(op)
medium2.linked_giftcard = giftcard2 medium2.linked_giftcard = giftcard2
medium2.save() medium2.save()