mirror of
https://github.com/pretix/pretix.git
synced 2025-12-19 16:22:26 +00:00
Compare commits
50 Commits
widget-dia
...
reusableme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e7e4b47f9 | ||
|
|
b391fe0914 | ||
|
|
5927161be8 | ||
|
|
068280a315 | ||
|
|
0ba3057d10 | ||
|
|
e10af17940 | ||
|
|
300fb41005 | ||
|
|
e6c53128d2 | ||
|
|
75a34761a8 | ||
|
|
051f2fabb3 | ||
|
|
43ebb1bc38 | ||
|
|
215e14d517 | ||
|
|
83016ffd25 | ||
|
|
fbdf915ecf | ||
|
|
c02c77d2a3 | ||
|
|
c98106312c | ||
|
|
b23b040152 | ||
|
|
cae8af6042 | ||
|
|
5b902683d4 | ||
|
|
a404fe64f4 | ||
|
|
2117f02be0 | ||
|
|
26c19017be | ||
|
|
f51ae3d22a | ||
|
|
0cbe176f88 | ||
|
|
9e703b006a | ||
|
|
94bd101421 | ||
|
|
f3c432ab93 | ||
|
|
ee891d7f9d | ||
|
|
e238b512a8 | ||
|
|
a69a056ab8 | ||
|
|
ad114651ad | ||
|
|
28e70f1303 | ||
|
|
9cdeffa6fb | ||
|
|
2cb3d7b356 | ||
|
|
c3c817b16c | ||
|
|
dd11149b4e | ||
|
|
55b881ddc9 | ||
|
|
d9986e72cd | ||
|
|
2759639f1f | ||
|
|
77f788457b | ||
|
|
4ae84e798a | ||
|
|
b51ac9982c | ||
|
|
ccdc3837fb | ||
|
|
d5b1143666 | ||
|
|
730545f8c5 | ||
|
|
820c039e2b | ||
|
|
523c97603c | ||
|
|
ce3f54b22d | ||
|
|
7a4e5ae955 | ||
|
|
9d52cd9531 |
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
* ``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``
|
||||
|
||||
|
||||
@@ -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 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.
|
||||
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:: 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
|
||||
---------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -65,9 +65,11 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
def __init__(self, *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)
|
||||
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(
|
||||
@@ -76,16 +78,27 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
queryset=self.context['organizer'].issued_gift_cards.all()
|
||||
)
|
||||
|
||||
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
|
||||
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:
|
||||
self.fields['customer'] = CustomerSerializer(read_only=True)
|
||||
else:
|
||||
self.fields['customer'] = serializers.SlugRelatedField(
|
||||
@@ -97,6 +110,20 @@ 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.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:
|
||||
qs = self.context['organizer'].reusable_media.filter(
|
||||
identifier=data['identifier'], type=data['type']
|
||||
@@ -109,6 +136,14 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
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:
|
||||
model = ReusableMedium
|
||||
fields = (
|
||||
@@ -118,10 +153,12 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
'updated',
|
||||
'type',
|
||||
'identifier',
|
||||
'claim_token',
|
||||
'label',
|
||||
'active',
|
||||
'expires',
|
||||
'customer',
|
||||
'linked_orderposition',
|
||||
'linked_orderpositions',
|
||||
'linked_giftcard',
|
||||
'info',
|
||||
'notes',
|
||||
|
||||
@@ -1019,13 +1019,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)
|
||||
@@ -1037,6 +1039,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():
|
||||
@@ -1052,6 +1056,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(
|
||||
@@ -1125,6 +1132,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
|
||||
|
||||
|
||||
@@ -1540,7 +1554,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:
|
||||
@@ -1614,6 +1628,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)
|
||||
|
||||
@@ -1655,8 +1670,7 @@ 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'])
|
||||
use_reusable_medium.linked_orderpositions.set([pos])
|
||||
use_reusable_medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.changed',
|
||||
data={
|
||||
@@ -1664,6 +1678,15 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
'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:
|
||||
for cp in delete_cps:
|
||||
|
||||
@@ -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)
|
||||
if not op_candidates:
|
||||
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,
|
||||
type=source_type,
|
||||
identifier=raw_barcode,
|
||||
linked_orderposition__isnull=False,
|
||||
has_linked_orderpositions=True,
|
||||
)
|
||||
raw_barcode_for_checkin = raw_barcode
|
||||
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,
|
||||
}, status=400)
|
||||
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
|
||||
if not simulate:
|
||||
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': [],
|
||||
'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 media.linked_orderpositions.all().select_related("order"):
|
||||
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.
|
||||
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()})
|
||||
)
|
||||
]
|
||||
# only check addons if at most one non-addon-op is in op_candidates
|
||||
# otherwise it is likely a medium linked to multiple orderpositions, which we need to filter based on validity
|
||||
if len([op for op in op_candidates if not op.addon_to]) <= 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()})
|
||||
)
|
||||
]
|
||||
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:
|
||||
# None of the found add-ons has the correct product, too bad! We could just error out here, but
|
||||
|
||||
@@ -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(
|
||||
@@ -155,7 +157,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})
|
||||
|
||||
@@ -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
|
||||
@@ -1036,7 +1036,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)
|
||||
|
||||
@@ -20,12 +20,13 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -40,7 +41,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 = [
|
||||
@@ -58,17 +61,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'
|
||||
|
||||
35
src/pretix/base/migrations/0297_add_reusablemedium_label.py
Normal file
35
src/pretix/base/migrations/0297_add_reusablemedium_label.py
Normal 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;",
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -3471,8 +3471,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={
|
||||
|
||||
@@ -1616,7 +1616,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)
|
||||
)
|
||||
|
||||
|
||||
@@ -83,7 +83,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
|
||||
|
||||
@@ -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 "")})'
|
||||
|
||||
|
||||
class SafeOrderPositionMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
def __init__(self, queryset, **kwargs):
|
||||
queryset = queryset.model.all.none()
|
||||
super().__init__(queryset, **kwargs)
|
||||
|
||||
|
||||
class EventMetaPropertyForm(I18nModelForm):
|
||||
class Meta:
|
||||
model = EventMetaProperty
|
||||
@@ -828,12 +834,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,
|
||||
@@ -843,8 +849,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={
|
||||
@@ -852,8 +858,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(
|
||||
@@ -907,12 +913,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,
|
||||
|
||||
@@ -733,6 +733,7 @@ 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.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.'),
|
||||
|
||||
@@ -82,13 +82,13 @@
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if m.linked_orderposition %}
|
||||
{% for op in m.linked_orderpositions.all %}
|
||||
<span class="helper-display-block">
|
||||
<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 %}">
|
||||
{{ m.linked_orderposition.order.code }}</a>-{{ m.linked_orderposition.positionid }}
|
||||
<a href="{% url "control:event.order" event=op.order.event.slug organizer=request.organizer.slug code=op.order.code %}">
|
||||
{{ op.order.code }}</a>-{{ op.positionid }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if m.linked_giftcard %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-credit-card fa-fw"></span>
|
||||
|
||||
@@ -49,13 +49,13 @@
|
||||
</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% if medium.linked_orderposition %}
|
||||
{% for op in medium.linked_orderpositions.all %}
|
||||
<span class="helper-display-block">
|
||||
<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 %}">
|
||||
{{ medium.linked_orderposition.order.code }}</a>-{{ medium.linked_orderposition.positionid }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span class="fa fa-ticket fa-fw"></span>
|
||||
<a href="{% url "control:event.order" event=op.order.event.slug organizer=request.organizer.slug code=op.order.code %}">
|
||||
{{ op.order.code }}</a>-{{ op.positionid }}
|
||||
</span>
|
||||
{% endfor %}
|
||||
{% if medium.linked_giftcard %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-credit-card fa-fw"></span>
|
||||
|
||||
@@ -3300,8 +3300,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)
|
||||
@@ -3349,10 +3351,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
|
||||
|
||||
@@ -3378,10 +3384,13 @@ class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
|
||||
@transaction.atomic
|
||||
def form_valid(self, 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:
|
||||
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.'))
|
||||
return super().form_valid(form)
|
||||
|
||||
|
||||
@@ -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,48 @@ 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, 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
|
||||
def test_by_medium_not_connected(token_client, organizer, clist, event, order):
|
||||
with scopes_disabled():
|
||||
@@ -318,12 +360,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 +379,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 +397,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'
|
||||
|
||||
@@ -3086,9 +3086,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):
|
||||
@@ -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']
|
||||
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"
|
||||
|
||||
|
||||
|
||||
@@ -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": {},
|
||||
@@ -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,
|
||||
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()
|
||||
@@ -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,
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user