Compare commits

...

59 Commits

Author SHA1 Message Date
Martin Gross
0fa2da5d30 Rebase against origin/master 2026-02-25 13:24:31 +01:00
Maximilian Richt
710cd2c97c Add copy and qr button to reusable medium detail view 2026-02-25 13:01:22 +01:00
Maximilian Richt
a979381ab0 Fix sorting of reusable media type in overview 2026-02-25 13:01:22 +01:00
Richard Schreiber
b9290a358e improve check 2026-02-25 13:01:22 +01:00
Richard Schreiber
71c01a3302 fix flake8 2026-02-25 13:01:22 +01:00
Richard Schreiber
b34c30cf6b fix multi-op media filter 2026-02-25 13:01:22 +01:00
Richard Schreiber
d1d249ae06 fix indentation 2026-02-25 13:01:22 +01:00
Richard Schreiber
6f01be0baf Add help-text 2026-02-25 13:01:22 +01:00
Richard Schreiber
690b357b17 add test 2026-02-25 13:01:22 +01:00
Richard Schreiber
1d48959a8b fix linked_orderpositions filter in checkinrpc 2026-02-25 13:01:22 +01:00
Richard Schreiber
1815dacfed Add test for order-API add_to_reusable_medium 2026-02-25 13:01:22 +01:00
Richard Schreiber
d7c1a8af6c list ops comma-separated in export 2026-02-25 13:01:22 +01:00
Richard Schreiber
0b9a5008c1 Clarify docs 2026-02-25 13:01:22 +01:00
Richard Schreiber
25f71422d5 fix flake8 2026-02-25 13:01:22 +01:00
Richard Schreiber
7a3d1f2b14 fix tests regarding claim_token 2026-02-25 13:01:22 +01:00
Richard Schreiber
fe9942eb4c add add_to_reusable_medium to order-serializer 2026-02-25 13:01:22 +01:00
Richard Schreiber
7d3183f67b fix flake8 2026-02-25 13:01:22 +01:00
Richard Schreiber
d13fe331f3 fix missing claim_token in serializer 2026-02-25 13:01:22 +01:00
Richard Schreiber
dcc050449d fix disallow of use of singular and plural linked_op in API 2026-02-25 13:01:22 +01:00
Richard Schreiber
a9183e846c Update reusablemedia.rst 2026-02-25 13:01:22 +01:00
Richard Schreiber
2a408ac3a6 Update reusablemedia.rst 2026-02-25 13:01:22 +01:00
Richard Schreiber
27a5d31098 Update reusablemedia.rst 2026-02-25 13:01:22 +01:00
Richard Schreiber
cac855e2b5 unifiy deprecated style 2026-02-25 13:01:22 +01:00
Richard Schreiber
f157a9bb10 Update docs for claim_token 2026-02-25 13:01:22 +01:00
Richard Schreiber
5f1c5db7fe rename secret to claim_token 2026-02-25 13:01:22 +01:00
Richard Schreiber
510fc14216 add filter based on op.valid_from/until 2026-02-25 13:01:22 +01:00
Richard Schreiber
0552441e4a select_related order instead prefetch 2026-02-25 13:01:22 +01:00
Richard Schreiber
76ab775876 improve readability 2026-02-25 13:01:22 +01:00
Richard Schreiber
2b13fae184 no need to prefetch linked_orderpositions 2026-02-25 13:01:22 +01:00
Richard Schreiber
f758a1710d clarify docs 2026-02-25 13:01:22 +01:00
Richard Schreiber
e6ef7f847d clarify docs updating multiple linked_orderpositions 2026-02-25 13:01:22 +01:00
Richard Schreiber
af48890152 update docs 2026-02-25 13:01:22 +01:00
Richard Schreiber
91c996c19a fix tests 2026-02-25 13:01:22 +01:00
Richard Schreiber
6872ca99da fix migration NOT NULL 2026-02-25 13:01:22 +01:00
Richard Schreiber
ab49d09c18 add label to reusablemedium 2026-02-25 13:01:22 +01:00
Richard Schreiber
dd247c47f8 fix code style 2026-02-25 13:01:22 +01:00
Richard Schreiber
e4a529c22f fix more tests 2026-02-25 13:01:22 +01:00
Richard Schreiber
b76f1dfd69 fix tests 2026-02-25 13:01:22 +01:00
Richard Schreiber
040d9815a5 fix create/update logging 2026-02-25 13:01:22 +01:00
Richard Schreiber
0bc61f673f remove unneeded comment 2026-02-25 13:01:22 +01:00
Richard Schreiber
23d630791e adapt checkin API for multiple orderpositions 2026-02-25 13:01:22 +01:00
Richard Schreiber
3a2f42f34d fix media-issue signal 2026-02-25 13:01:22 +01:00
Richard Schreiber
d23855a1c4 remove cached_property linked_orderposition - keep only in API 2026-02-25 13:01:22 +01:00
Richard Schreiber
d6ed21c776 fix API orders matching media 2026-02-25 13:01:22 +01:00
Richard Schreiber
3e5d368d6e fix API orders 2026-02-25 13:01:22 +01:00
Richard Schreiber
312b243add update control media forms 2026-02-25 13:01:22 +01:00
Richard Schreiber
cf880272f1 fix media-view filter 2026-02-25 13:01:22 +01:00
Richard Schreiber
9e01914b0f fix last media-API commit 2026-02-25 13:01:22 +01:00
Richard Schreiber
245fb48989 update media-API 2026-02-25 13:01:22 +01:00
Richard Schreiber
da48ec0f45 add multi-op to export 2026-02-25 13:01:22 +01:00
Richard Schreiber
5c0505c9c3 return last op as fallback for linked_orderposition 2026-02-25 13:01:22 +01:00
Richard Schreiber
f5da1e00fa Update media views to list ops 2026-02-25 13:01:22 +01:00
Richard Schreiber
8aa75cb256 change linked orderpositions to many-to-many 2026-02-25 13:01:21 +01:00
luelista
da43984ad2 Add datasync logging (Z#23225588) (#5928)
* Fix inconsistent log messages

* Add logging for successfully synced orders

(debugging orders that might get silently skipped)
2026-02-25 09:49:52 +01:00
Martin Gross
7cce1c9219 PPv2: Handle paypal-payments/oders in 'created' status (Z#23225625) (#5929) 2026-02-25 09:21:58 +01:00
Martin Gross
cb9c4466f9 Revert "PPv2: Do not put payments in pending-state if no capture has occured yet."
This reverts commit e5c8f19984.
2026-02-24 16:55:57 +01:00
Martin Gross
3398cda74b PPv2: properly check for pending-payments in pending-renderer 2026-02-24 16:16:22 +01:00
Martin Gross
e5c8f19984 PPv2: Do not put payments in pending-state if no capture has occured yet. 2026-02-24 16:07:16 +01:00
Raphael Michel
5027f6dd59 Bump version to 2026.3.0.dev0 2026-02-24 13:37:15 +01:00
26 changed files with 454 additions and 111 deletions

View File

@@ -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)

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)
* ``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``

View File

@@ -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

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "2026.2.0"
__version__ = "2026.3.0.dev0"

View File

@@ -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',

View File

@@ -1029,13 +1029,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)
@@ -1047,6 +1049,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():
@@ -1062,6 +1066,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(
@@ -1135,6 +1142,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
@@ -1550,7 +1564,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:
@@ -1624,6 +1638,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)
@@ -1665,8 +1680,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={
@@ -1674,6 +1688,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:

View File

@@ -520,11 +520,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:
@@ -627,7 +629,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={
@@ -653,21 +656,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

View File

@@ -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})

View File

@@ -193,7 +193,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
@@ -1030,7 +1030,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)

View File

@@ -216,7 +216,10 @@ class OutboundSyncProvider:
try:
mapped_objects = self.sync_order(sq.order)
if not all(all(not res or res.sync_info.get("action", "") == "nothing_to_do" for res in res_list) for res_list in mapped_objects.values()):
actions_taken = [res and res.sync_info.get("action", "") for res_list in mapped_objects.values() for res in res_list]
should_write_logentry = any(action not in (None, "nothing_to_do") for action in actions_taken)
logger.info('Synced order %s to %s, actions: %r, log: %r', sq.order.code, sq.sync_provider, actions_taken, should_write_logentry)
if should_write_logentry:
sq.order.log_action("pretix.event.order.data_sync.success", {
"provider": self.identifier,
"objects": {
@@ -237,7 +240,7 @@ class OutboundSyncProvider:
sq.set_sync_error("exceeded", e.messages, e.full_message)
else:
logger.info(
f"Could not sync order {sq.order.code} to {type(self).__name__} "
f"Could not sync order {sq.order.code} to {sq.sync_provider} "
f"(transient error, attempt #{sq.failed_attempts}, next {sq.not_before})",
exc_info=True,
)

View File

@@ -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'

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

@@ -86,7 +86,7 @@ class OrderSyncQueue(models.Model):
def set_sync_error(self, failure_mode, messages, full_message):
logger.exception(
f"Could not sync order {self.order.code} to {type(self).__name__} ({failure_mode})"
f"Could not sync order {self.order.code} to {self.sync_provider} ({failure_mode})"
)
self.order.log_action(f"pretix.event.order.data_sync.failed.{failure_mode}", {
"provider": self.sync_provider,

View File

@@ -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,

View File

@@ -3495,8 +3495,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={

View File

@@ -1744,7 +1744,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)
)

View File

@@ -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
@@ -845,12 +851,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,
@@ -860,8 +866,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={
@@ -869,8 +875,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(
@@ -924,12 +930,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,

View File

@@ -741,6 +741,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.'),

View File

@@ -54,8 +54,8 @@
<a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a>
</th>
<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' 'email' %}"><i class="fa fa-caret-up"></i></a></th>
<a href="?{% url_replace request 'ordering' '-type' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'type' %}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Connections" context "reusable_media" %}</th>
<th></th>
</tr>
@@ -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>

View File

@@ -28,7 +28,19 @@
<dt>{% trans "Media type" context "reusable_media" %}</dt>
<dd>{{ medium.get_type_display }}</dd>
<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>
<dd>
{% if not medium.active %}
@@ -49,13 +61,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>

View File

@@ -3338,8 +3338,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)
@@ -3387,10 +3389,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
@@ -3416,10 +3422,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)

View File

@@ -802,31 +802,37 @@ class PaypalMethod(BasePaymentProvider):
all_captures_completed = False
else:
any_captures = True
if not (any_captures and all_captures_completed):
# Payment has at least one capture, but it is not yet completed
if any_captures and not all_captures_completed:
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as '
'soon as the payment completed.'))
payment.info = json.dumps(pp_captured_order.dict())
payment.state = OrderPayment.PAYMENT_STATE_PENDING
payment.save()
return
# Payment has at least one capture and all captures are completed
elif any_captures and all_captures_completed:
if pp_captured_order.status != 'COMPLETED':
payment.fail(info=pp_captured_order.dict())
logger.error('Invalid state: %s' % repr(pp_captured_order.dict()))
raise PaymentException(
_('We were unable to process your payment. See below for details on how to proceed.')
)
if pp_captured_order.status != 'COMPLETED':
payment.fail(info=pp_captured_order.dict())
logger.error('Invalid state: %s' % repr(pp_captured_order.dict()))
raise PaymentException(
_('We were unable to process your payment. See below for details on how to proceed.')
)
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
logger.warning('PayPal success event even though order is already marked as paid')
return
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
logger.warning('PayPal success event even though order is already marked as paid')
try:
payment.info = json.dumps(pp_captured_order.dict())
payment.save(update_fields=['info'])
payment.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
# Payment has not any captures yet - so it's probably in created status
else:
return
try:
payment.info = json.dumps(pp_captured_order.dict())
payment.save(update_fields=['info'])
payment.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
finally:
if 'payment_paypal_oid' in request.session:
del request.session['payment_paypal_oid']
@@ -836,7 +842,7 @@ class PaypalMethod(BasePaymentProvider):
try:
if (
payment.info
and payment.info_data['purchase_units'][0]['payments']['captures'][0]['status'] == 'pending'
and payment.info_data['purchase_units'][0]['payments']['captures'][0]['status'] == 'PENDING'
):
retry = False
except (KeyError, IndexError):

View File

@@ -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'

View File

@@ -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"

View File

@@ -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()