mirror of
https://github.com/pretix/pretix.git
synced 2026-05-18 17:24:03 +00:00
Compare commits
94 Commits
pajowu/sho
...
reusableme
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0256d92fb1 | ||
|
|
ca4e6ab07a | ||
|
|
49e60700b0 | ||
|
|
42e5732e8d | ||
|
|
16e16f71e4 | ||
|
|
e8e175936c | ||
|
|
294d32656c | ||
|
|
e018bf1d8a | ||
|
|
b318a17159 | ||
|
|
8c84b6c634 | ||
|
|
c19400260b | ||
|
|
8609704ac6 | ||
|
|
2c1dd1ec69 | ||
|
|
5e13542ab9 | ||
|
|
aadf113beb | ||
|
|
09b2dd358f | ||
|
|
f465480686 | ||
|
|
3ffdb3444a | ||
|
|
31e45f45be | ||
|
|
5d242722fc | ||
|
|
8a60c208b6 | ||
|
|
1cb9957dfe | ||
|
|
4ce66505a0 | ||
|
|
7da713359b | ||
|
|
d211f5e724 | ||
|
|
2e450f6503 | ||
|
|
96ac10b128 | ||
|
|
5585a35d3a | ||
|
|
0e09abcadf | ||
|
|
4ed7f00634 | ||
|
|
2a4ee9cdf6 | ||
|
|
728669713e | ||
|
|
85834f108b | ||
|
|
fa346f61c1 | ||
|
|
009d61d860 | ||
|
|
af0b2b777e | ||
|
|
5482e68ef9 | ||
|
|
b59ee66d33 | ||
|
|
fa4543789e | ||
|
|
6e45e46b6b | ||
|
|
b9035e8c6d | ||
|
|
90a05c87f8 | ||
|
|
8c54a8417d | ||
|
|
015b4efb11 | ||
|
|
9f2b83e5fc | ||
|
|
81b7226773 | ||
|
|
823f2708b8 | ||
|
|
cf80e70e37 | ||
|
|
b7a66645eb | ||
|
|
73e3dc5258 | ||
|
|
beb219396a | ||
|
|
55697b1d70 | ||
|
|
a4c1b8e3bb | ||
|
|
18ab21132b | ||
|
|
45cf171042 | ||
|
|
e5b4f629bd | ||
|
|
18dcaa8489 | ||
|
|
65a8e2ab59 | ||
|
|
a3d89fb894 | ||
|
|
a157fe7a28 | ||
|
|
08cb753233 | ||
|
|
ea0f11f4bf | ||
|
|
c70dd65440 | ||
|
|
d0abe8876b | ||
|
|
de7b296e68 | ||
|
|
e215bec971 | ||
|
|
a8ca39b792 | ||
|
|
8bc00f50e3 | ||
|
|
eab7870785 | ||
|
|
ac0963b40c | ||
|
|
d8051e3aa0 | ||
|
|
3257f87a95 | ||
|
|
25d57de9d4 | ||
|
|
59d5a68a3b | ||
|
|
c93b2ad169 | ||
|
|
7f70f4dbea | ||
|
|
968dd13e6a | ||
|
|
112b147b9e | ||
|
|
af3be3d6c7 | ||
|
|
392d75ea80 | ||
|
|
4c7911245e | ||
|
|
d00000be37 | ||
|
|
85f6cc91df | ||
|
|
c70bb5bfe2 | ||
|
|
f41dd79fe3 | ||
|
|
b21b69b2b8 | ||
|
|
80ed6e76cd | ||
|
|
bb211be436 | ||
|
|
3b70ef8c84 | ||
|
|
9d57380c9a | ||
|
|
8b468c31a5 | ||
|
|
9aec608601 | ||
|
|
e542bb606d | ||
|
|
fe1b4ec9d0 |
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -123,7 +123,7 @@ jobs:
|
||||
working-directory: ./src
|
||||
run: make all compress
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install
|
||||
run: playwright install
|
||||
- name: Run E2E tests
|
||||
working-directory: ./src
|
||||
run: PRETIX_CONFIG_FILE=tests/ci_postgres.cfg py.test tests/e2e/ -v --maxfail=10
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1070,6 +1070,7 @@ Creating orders
|
||||
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
|
||||
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
|
||||
* ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID)
|
||||
* ``add_to_reusable_medium`` (optional, causes the new ticket to be added to the given reusable medium, identified by its ID)
|
||||
* ``discount`` (optional, only possible if ``price`` is set; attention: if this is set to not-``null`` on any position, automatic calculation of discounts will not run)
|
||||
* ``answers``
|
||||
|
||||
|
||||
@@ -21,12 +21,16 @@ id integer Internal ID of
|
||||
type string Type of medium, e.g. ``"barcode"``, ``"nfc_uid"`` or ``"nfc_mf0aes"``.
|
||||
organizer string Organizer slug of the organizer who "owns" this medium.
|
||||
identifier string Unique identifier of the medium. The format depends on the ``type``.
|
||||
claim_token string Secret token to claim ownership of the medium (or ``null``)
|
||||
label string Label to identify the medium, usually something human readable (or ``null``)
|
||||
active boolean Whether this medium may be used.
|
||||
created datetime Date of creation
|
||||
updated datetime Date of last modification
|
||||
expires datetime Expiry date (or ``null``)
|
||||
customer string Identifier of a customer account this medium belongs to.
|
||||
linked_orderposition integer Internal ID of a ticket this medium is linked to.
|
||||
linked_orderpositions list of integers Internal IDs of tickets this medium is linked to.
|
||||
linked_orderposition integer **DEPRECATED.** ID of the ticket the medium is linked to, if it is linked to
|
||||
only one ticket. ``null``, if the medium is linked to none or multiple tickets.
|
||||
linked_giftcard integer Internal ID of a gift card this medium is linked to.
|
||||
info object Additional data, content depends on the ``type``. Consider
|
||||
this internal to the system and don't use it for your own data.
|
||||
@@ -39,6 +43,14 @@ Existing media types are:
|
||||
- ``nfc_uid``
|
||||
- ``nfc_mf0aes``
|
||||
|
||||
|
||||
.. versionchanged:: 2026.5
|
||||
|
||||
The ``claim_token``, ``label``, ``linked_orderpositions`` attributes have been added, the ``linked_orderposition`` attribute has been
|
||||
deprecated. Note: To maintain backwards compatibility ``linked_orderposition`` contains the internal ID of the linked order position
|
||||
if the medium has exactly one order position in ``linked_orderpositions``.
|
||||
|
||||
|
||||
Endpoints
|
||||
---------
|
||||
|
||||
@@ -77,6 +89,7 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -92,10 +105,13 @@ Endpoints
|
||||
:query string customer: Only show media linked to the given customer.
|
||||
:query string created_since: Only show media created since a given date.
|
||||
:query string updated_since: Only show media updated since a given date.
|
||||
:query integer linked_orderpositions: Only show media linked to the given tickets. Note: you can pass multiple ticket IDs by passing
|
||||
``linked_orderpositions`` multiple times. Any medium matching any linked orderposition will be returned.
|
||||
:query integer linked_orderposition: Only show media linked to the given ticket.
|
||||
:query integer linked_giftcard: Only show media linked to the given gift card.
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderposition"``,
|
||||
or ``"customer"``, the respective field will be shown as a nested value instead of just an ID.
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_giftcard.owner_ticket"``, ``"linked_orderpositions"``,
|
||||
``"linked_orderposition"`` (**DEPRECATED**), or ``"customer"``, the respective field will be shown
|
||||
as a nested value instead of just an ID.
|
||||
The nested objects are identical to the respective resources, except that order positions
|
||||
will have an attribute of the format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make
|
||||
matching easier. The parameter can be given multiple times.
|
||||
@@ -134,6 +150,7 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -191,6 +208,7 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -198,9 +216,9 @@ Endpoints
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to look up a medium for
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
|
||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||
the respective resources, except that the ``linked_orderpositions`` each will have an attribute of the
|
||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||
can be given multiple times.
|
||||
:statuscode 201: no error
|
||||
@@ -227,6 +245,7 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -251,6 +270,7 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
@@ -258,7 +278,7 @@ Endpoints
|
||||
}
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to create a medium for
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
|
||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||
@@ -287,7 +307,7 @@ Endpoints
|
||||
Content-Length: 94
|
||||
|
||||
{
|
||||
"linked_orderposition": 13
|
||||
"linked_orderpositions": [13, 29]
|
||||
}
|
||||
|
||||
**Example response**:
|
||||
@@ -308,7 +328,8 @@ Endpoints
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderposition": 13,
|
||||
"linked_orderpositions": [13, 29],
|
||||
"linked_orderposition": None,
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
"info": {}
|
||||
@@ -316,7 +337,7 @@ Endpoints
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to modify
|
||||
:param id: The ``id`` field of the medium to modify
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
|
||||
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderpositions"``, or ``"customer"``, the respective
|
||||
field will be shown as a nested value instead of just an ID. The nested objects are identical to
|
||||
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
|
||||
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
|
||||
|
||||
@@ -126,6 +126,7 @@ dev = [
|
||||
"pytest-xdist==3.8.*",
|
||||
"pytest-playwright",
|
||||
"pytest==9.0.*",
|
||||
"playwright",
|
||||
"responses",
|
||||
]
|
||||
|
||||
|
||||
@@ -66,13 +66,14 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
expand_nested = self.context['request'].query_params.getlist('expand')
|
||||
|
||||
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
|
||||
if 'linked_giftcard' in expand_nested:
|
||||
if not self.context["can_read_giftcards"]:
|
||||
raise PermissionDenied("No permission to access gift card details.")
|
||||
|
||||
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True, context=self.context)
|
||||
if 'linked_giftcard.owner_ticket' in self.context['request'].query_params.getlist('expand'):
|
||||
if 'linked_giftcard.owner_ticket' in expand_nested:
|
||||
self.fields['linked_giftcard'].fields['owner_ticket'] = NestedOrderPositionSerializer(read_only=True, context=self.context)
|
||||
else:
|
||||
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
|
||||
@@ -81,17 +82,27 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
queryset=self.context['organizer'].issued_gift_cards.all()
|
||||
)
|
||||
|
||||
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
|
||||
# Permission Check performed in to_representation
|
||||
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
|
||||
# keep linked_orderposition (singular) for backwards compatibility, will be overwritten in self.validate
|
||||
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
|
||||
)
|
||||
|
||||
if 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
|
||||
self.fields['linked_orderpositions'] = NestedOrderPositionSerializer(
|
||||
many=True,
|
||||
read_only=True
|
||||
)
|
||||
else:
|
||||
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
|
||||
self.fields['linked_orderpositions'] = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
|
||||
)
|
||||
|
||||
if 'customer' in self.context['request'].query_params.getlist('expand'):
|
||||
if 'customer' in expand_nested:
|
||||
if not self.context["can_read_customers"]:
|
||||
raise PermissionDenied("No permission to access customer details.")
|
||||
|
||||
@@ -106,6 +117,21 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
|
||||
def validate(self, data):
|
||||
data = super().validate(data)
|
||||
if 'linked_orderposition' in data:
|
||||
linked_orderposition = data['linked_orderposition']
|
||||
# backwards-compatibility
|
||||
if 'linked_orderpositions' in data:
|
||||
raise ValidationError({
|
||||
'linked_orderposition': 'You cannot use linked_orderposition and linked_orderpositions at the same time.'
|
||||
})
|
||||
if self.instance and self.instance.linked_orderpositions.count() > 1:
|
||||
raise ValidationError({
|
||||
'linked_orderposition': 'There are more than one linked_orderposition. You need to use linked_orderpositions.'
|
||||
})
|
||||
|
||||
data['linked_orderpositions'] = [linked_orderposition] if linked_orderposition else []
|
||||
del data['linked_orderposition']
|
||||
|
||||
if 'type' in data and 'identifier' in data:
|
||||
qs = self.context['organizer'].reusable_media.filter(
|
||||
identifier=data['identifier'], type=data['type']
|
||||
@@ -121,14 +147,28 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
def to_representation(self, instance):
|
||||
r = super().to_representation(instance)
|
||||
request = self.context.get('request')
|
||||
|
||||
ops = r.get('linked_orderpositions', [])
|
||||
# late permission evaluations for checks that depend on the actual linked events
|
||||
expand_nested = self.context['request'].query_params.getlist('expand')
|
||||
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
|
||||
if 'linked_orderposition' in expand_nested:
|
||||
if instance.linked_orderposition is not None:
|
||||
event = instance.linked_orderposition.order.event
|
||||
if ops and 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
|
||||
ops_noperm = []
|
||||
for lop in instance.linked_orderpositions.all():
|
||||
event = lop.order.event
|
||||
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
|
||||
r['linked_orderposition'] = {'id': instance.linked_orderposition.id}
|
||||
ops_noperm.append(lop.id)
|
||||
if ops_noperm:
|
||||
ops = [
|
||||
{'id': op['id']} if op['id'] in ops_noperm
|
||||
else op
|
||||
for op in ops
|
||||
]
|
||||
r['linked_orderpositions'] = ops
|
||||
|
||||
# add linked_orderposition (singular) for backwards compatibility
|
||||
if len(ops) < 2:
|
||||
r['linked_orderposition'] = ops[0] if ops else None
|
||||
|
||||
if 'linked_giftcard.owner_ticket' in expand_nested:
|
||||
gc = instance.linked_giftcard
|
||||
@@ -148,10 +188,12 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
|
||||
'updated',
|
||||
'type',
|
||||
'identifier',
|
||||
'claim_token',
|
||||
'label',
|
||||
'active',
|
||||
'expires',
|
||||
'customer',
|
||||
'linked_orderposition',
|
||||
'linked_orderpositions',
|
||||
'linked_giftcard',
|
||||
'info',
|
||||
'notes',
|
||||
|
||||
@@ -1043,13 +1043,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
requested_valid_from = serializers.DateTimeField(required=False, allow_null=True)
|
||||
use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
|
||||
required=False, allow_null=True)
|
||||
add_to_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
|
||||
required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = OrderPosition
|
||||
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
|
||||
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
|
||||
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until',
|
||||
'requested_valid_from', 'use_reusable_medium', 'discount')
|
||||
'requested_valid_from', 'use_reusable_medium', 'add_to_reusable_medium', 'discount')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -1061,6 +1063,8 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
with scopes_disabled():
|
||||
if 'use_reusable_medium' in self.fields:
|
||||
self.fields['use_reusable_medium'].queryset = ReusableMedium.objects.all()
|
||||
if 'add_to_reusable_medium' in self.fields:
|
||||
self.fields['add_to_reusable_medium'].queryset = ReusableMedium.objects.all()
|
||||
|
||||
def validate_secret(self, secret):
|
||||
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
|
||||
@@ -1076,6 +1080,9 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
)
|
||||
return m
|
||||
|
||||
def validate_add_to_reusable_medium(self, m):
|
||||
return self.validate_use_reusable_medium(m)
|
||||
|
||||
def validate_item(self, item):
|
||||
if item.event != self.context['event']:
|
||||
raise ValidationError(
|
||||
@@ -1149,6 +1156,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
|
||||
raise ValidationError(
|
||||
{'discount': ['You can only specify a discount if you do the price computation, but price is not set.']}
|
||||
)
|
||||
|
||||
if 'use_reusable_medium' in data and 'add_to_reusable_medium' in data:
|
||||
raise ValidationError({
|
||||
'use_reusable_medium': ['You can only specify either use_reusable_medium or add_to_reusable_medium.'],
|
||||
'add_to_reusable_medium': ['You can only specify either use_reusable_medium or add_to_reusable_medium.'],
|
||||
})
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -1588,7 +1602,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
pos_data['attendee_name_parts'] = {
|
||||
'_legacy': attendee_name
|
||||
}
|
||||
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
|
||||
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k not in ('answers', '_quotas', 'use_reusable_medium', 'add_to_reusable_medium')})
|
||||
if simulate:
|
||||
pos.order = order._wrapped
|
||||
else:
|
||||
@@ -1662,6 +1676,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
for pos_data in positions_data:
|
||||
answers_data = pos_data.pop('answers', [])
|
||||
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
|
||||
add_to_reusable_medium = pos_data.pop('add_to_reusable_medium', None)
|
||||
pos = pos_data['__instance']
|
||||
pos._calculate_tax(invoice_address=ia)
|
||||
|
||||
@@ -1703,10 +1718,25 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
|
||||
answ.options.add(*options)
|
||||
|
||||
if use_reusable_medium:
|
||||
use_reusable_medium.linked_orderposition = pos
|
||||
use_reusable_medium.save(update_fields=['linked_orderposition'])
|
||||
for op_pk in use_reusable_medium.linked_orderpositions.values_list('pk', flat=True):
|
||||
use_reusable_medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.removed',
|
||||
data={
|
||||
'linked_orderposition': op_pk,
|
||||
}
|
||||
)
|
||||
use_reusable_medium.linked_orderpositions.set([pos])
|
||||
use_reusable_medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.changed',
|
||||
'pretix.reusable_medium.linked_orderposition.added',
|
||||
data={
|
||||
'by_order': order.code,
|
||||
'linked_orderposition': pos.pk,
|
||||
}
|
||||
)
|
||||
elif add_to_reusable_medium:
|
||||
add_to_reusable_medium.linked_orderpositions.add(pos)
|
||||
add_to_reusable_medium.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.added',
|
||||
data={
|
||||
'by_order': order.code,
|
||||
'linked_orderposition': pos.pk,
|
||||
|
||||
@@ -491,6 +491,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
)
|
||||
raw_barcode_for_checkin = None
|
||||
from_revoked_secret = False
|
||||
reusable_medium_used = None
|
||||
if simulate:
|
||||
common_checkin_args['__fake_arg_to_prevent_this_from_being_saved'] = True
|
||||
|
||||
@@ -521,11 +522,12 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
# with respecting the force option), or it's a reusable medium (-> proceed with that)
|
||||
if not op_candidates:
|
||||
try:
|
||||
media = ReusableMedium.objects.select_related('linked_orderposition').active().get(
|
||||
media = ReusableMedium.objects.active().filter(
|
||||
Exists(ReusableMedium.linked_orderpositions.through.objects.filter(reusablemedium_id=OuterRef('pk')))
|
||||
).get(
|
||||
organizer_id=checkinlists[0].event.organizer_id,
|
||||
type=source_type,
|
||||
identifier=raw_barcode,
|
||||
linked_orderposition__isnull=False,
|
||||
)
|
||||
raw_barcode_for_checkin = raw_barcode
|
||||
except ReusableMedium.DoesNotExist:
|
||||
@@ -628,7 +630,9 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
|
||||
}, status=400)
|
||||
else:
|
||||
if media.linked_orderposition.order.event_id not in list_by_event:
|
||||
linked_ops = media.linked_orderpositions.all().select_related("order").prefetch_related("addons")
|
||||
linked_event_ids = {op.order.event_id for op in linked_ops}
|
||||
if not any(event_id in list_by_event for event_id in linked_event_ids):
|
||||
# Medium exists but connected ticket is for the wrong event
|
||||
if not simulate:
|
||||
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
|
||||
@@ -654,28 +658,91 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'checkin_texts': [],
|
||||
'list': MiniCheckinListSerializer(checkinlists[0]).data,
|
||||
}, status=404)
|
||||
op_candidates = [media.linked_orderposition]
|
||||
if list_by_event[media.linked_orderposition.order.event_id].addon_match:
|
||||
op_candidates += list(media.linked_orderposition.addons.all())
|
||||
op_candidates = []
|
||||
for op in linked_ops:
|
||||
if op.order.event_id in list_by_event:
|
||||
reusable_medium_used = media
|
||||
op_candidates.append(op)
|
||||
if list_by_event[op.order.event_id].addon_match:
|
||||
op_candidates += list(op.addons.all())
|
||||
|
||||
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
|
||||
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
|
||||
# which add-on has the right product.
|
||||
# key on the same list, we're probably dealing with multiple linked_orderpositions or the ``addon_match`` case
|
||||
# here and need to figure out which op has the right product. This basically is a valid-for-checkin-test on every op.
|
||||
if len(op_candidates) > 1:
|
||||
op_candidates_matching_product = [
|
||||
op for op in op_candidates
|
||||
if (
|
||||
(list_by_event[op.order.event_id].addon_match or op.secret == raw_barcode or legacy_url_support) and
|
||||
(list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()})
|
||||
)
|
||||
]
|
||||
|
||||
if len(op_candidates_matching_product) == 0:
|
||||
# None of the found add-ons has the correct product, too bad! We could just error out here, but
|
||||
if not reusable_medium_used:
|
||||
# 3a. First, we clean up that we made an imprecise query above. If a scan is made for multiple check-in lists,
|
||||
# we have queried ``addon_to__secret=raw_barcode``, even if some of the lists in question do not allow addon
|
||||
# matching. So we accept all candidates that match one of these cases:
|
||||
# - Exactly the ticket secret we scanned (because that's always a possible result)
|
||||
# - Exactly the ticket pk we scanned (on legacy endpoints)
|
||||
# - An add-on on a list that allows add-on matching
|
||||
# This is not necessary when a reusable media was used, since in that case we already obeyed list.addon_match
|
||||
# correctly above.
|
||||
op_candidates_filtered = [
|
||||
op for op in op_candidates
|
||||
if (
|
||||
op.secret == raw_barcode or
|
||||
list_by_event[op.order.event_id].addon_match or
|
||||
(str(op.pk) == raw_barcode and legacy_url_support and not untrusted_input)
|
||||
)
|
||||
]
|
||||
else:
|
||||
op_candidates_filtered = op_candidates
|
||||
|
||||
if len(op_candidates_filtered) > 1:
|
||||
# 3b. If we still have multiple candidates, we filter by product based on the check-in list configuration.
|
||||
# This is relevant for the addon_match scenario where the scanned ticket has multiple add-ons, but only
|
||||
# one is contained in the check-in list used to scan. It makes sense to filter this first, since it is a
|
||||
# "static" check, i.e. scanning the same QR code on the same check-in list will always do the same, no matter
|
||||
# when I scan it, and it is "intentional" filtering in the sense that the admin configured this behaviour
|
||||
# into the check-in list.
|
||||
op_candidates_filtered = [
|
||||
op for op in op_candidates_filtered
|
||||
if list_by_event[op.order.event_id].all_products or op.item_id in {i.pk for i in list_by_event[op.order.event_id].limit_products.all()}
|
||||
]
|
||||
|
||||
if len(op_candidates_filtered) > 1:
|
||||
# 3c. If we still have multiple candidates, we filter by validity date. This was introduced for the case where
|
||||
# a reusable media refers to two tickets, one currently valid and one expired or in the future. Howeer,
|
||||
# it could in theory also happen with two add-ons being on the same check-in list but without overlapping
|
||||
# validity. It makes sense to filter this "after" the previous checks since it is not "intentional" filtering
|
||||
# configured by the admin but "accidental" filtering that depends on the time of execution.
|
||||
op_candidates_filtered = [
|
||||
op for op in op_candidates_filtered
|
||||
if (
|
||||
(not op.valid_from or op.valid_from <= datetime) and
|
||||
(not op.valid_until or op.valid_until > datetime)
|
||||
)
|
||||
]
|
||||
|
||||
if len(op_candidates_filtered) == 0:
|
||||
# None of the ops is valid today or has the correct product, too bad! We could just error out here, but
|
||||
# instead we just continue with *any* product and have it rejected by the check in perform_checkin.
|
||||
# This has the advantage of a better error message.
|
||||
op_candidates = [op_candidates[0]]
|
||||
elif len(op_candidates_matching_product) > 1:
|
||||
# To improve the error message, we select the op that will "work next" or - if none matches - "worked last".
|
||||
op_candidate = None
|
||||
for op in op_candidates:
|
||||
if (
|
||||
op.valid_from and op.valid_from > datetime and
|
||||
(not op_candidate or op.valid_from < op_candidate.valid_from)
|
||||
):
|
||||
op_candidate = op
|
||||
|
||||
if not op_candidate:
|
||||
# no candidate in the future, get closest in the past
|
||||
for op in op_candidates:
|
||||
if (
|
||||
op.valid_until and op.valid_until < datetime and
|
||||
(not op_candidate or op.valid_until > op_candidate.valid_until)
|
||||
):
|
||||
op_candidate = op
|
||||
|
||||
if not op_candidate:
|
||||
op_candidate = op_candidates[0]
|
||||
|
||||
op_candidates = [op_candidate]
|
||||
elif len(op_candidates_filtered) > 1:
|
||||
# It's still ambiguous, we'll error out.
|
||||
# We choose the first match (regardless of product) for the logging since it's most likely to be the
|
||||
# base product according to our order_by above.
|
||||
@@ -709,7 +776,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data,
|
||||
}, status=400)
|
||||
else:
|
||||
op_candidates = op_candidates_matching_product
|
||||
op_candidates = op_candidates_filtered
|
||||
|
||||
op = op_candidates[0]
|
||||
common_checkin_args['list'] = list_by_event[op.order.event_id]
|
||||
|
||||
@@ -53,10 +53,12 @@ with scopes_disabled():
|
||||
customer = django_filters.CharFilter(field_name='customer__identifier')
|
||||
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
|
||||
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
|
||||
# backwards-compatible
|
||||
linked_orderposition = django_filters.NumberFilter(field_name='linked_orderpositions__id')
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
|
||||
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderpositions', 'linked_giftcard']
|
||||
|
||||
|
||||
class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
@@ -75,7 +77,7 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
).order_by().values('card').annotate(s=Sum('value')).values('s')
|
||||
return self.request.organizer.reusable_media.prefetch_related(
|
||||
Prefetch(
|
||||
'linked_orderposition',
|
||||
'linked_orderpositions',
|
||||
queryset=OrderPosition.objects.select_related(
|
||||
'order', 'order__event', 'order__event__organizer', 'seat',
|
||||
).prefetch_related(
|
||||
@@ -117,14 +119,38 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
|
||||
rm = ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
|
||||
prev_linked_ops_pks = list(rm.linked_orderpositions.values_list("pk", flat=True))
|
||||
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
|
||||
inst.log_action(
|
||||
'pretix.reusable_medium.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
linked_ops_pks = inst.linked_orderpositions.values_list("pk", flat=True)
|
||||
for op_pk in prev_linked_ops_pks:
|
||||
if op_pk not in linked_ops_pks:
|
||||
inst.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.removed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'linked_orderposition': op_pk,
|
||||
}
|
||||
)
|
||||
for op_pk in linked_ops_pks:
|
||||
if op_pk not in prev_linked_ops_pks:
|
||||
inst.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.added',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data={
|
||||
'linked_orderposition': op_pk,
|
||||
}
|
||||
)
|
||||
data = {k: v for k, v in self.request.data.items() if k not in ('linked_orderposition', 'linked_orderpositions')}
|
||||
if data:
|
||||
inst.log_action(
|
||||
'pretix.reusable_medium.changed',
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=data,
|
||||
)
|
||||
return inst
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
@@ -157,7 +183,6 @@ class ReusableMediaViewSet(viewsets.ModelViewSet):
|
||||
type=s.validated_data["type"],
|
||||
identifier=s.validated_data["identifier"],
|
||||
)
|
||||
m.linked_orderposition = None # not relevant for cross-organizer
|
||||
m.customer = None # not relevant for cross-organizer
|
||||
s = self.get_serializer(m)
|
||||
return Response({"result": s.data})
|
||||
|
||||
@@ -194,7 +194,7 @@ with scopes_disabled():
|
||||
)
|
||||
).values('id')
|
||||
|
||||
matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderposition__order_id', flat=True)
|
||||
matching_media = ReusableMedium.objects.filter(identifier=u).values_list('linked_orderpositions__order_id', flat=True)
|
||||
|
||||
mainq = (
|
||||
code
|
||||
@@ -1034,7 +1034,7 @@ with scopes_disabled():
|
||||
search = django_filters.CharFilter(method='search_qs')
|
||||
|
||||
def search_qs(self, queryset, name, value):
|
||||
matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderposition', flat=True)
|
||||
matching_media = ReusableMedium.objects.filter(identifier=value).values_list('linked_orderpositions', flat=True)
|
||||
return queryset.filter(
|
||||
Q(secret__istartswith=value)
|
||||
| Q(attendee_name_cached__icontains=value)
|
||||
|
||||
@@ -160,7 +160,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
|
||||
def _get_all_payment_methods(self, qs):
|
||||
pps = dict(get_all_payment_providers())
|
||||
return sorted([(pp, pps[pp]) for pp in set(
|
||||
return sorted([(pp, pps.get(pp, pp)) for pp in set(
|
||||
OrderPayment.objects.exclude(provider='free').filter(order__event__in=self.events).values_list(
|
||||
'provider', flat=True
|
||||
).distinct()
|
||||
@@ -330,6 +330,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
taxsum=Sum('tax_value'), grosssum=Sum('value')
|
||||
)
|
||||
}
|
||||
payment_methods = None
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_sum_cache = {
|
||||
(o['order__id'], o['provider']): o['grosssum'] for o in
|
||||
@@ -347,6 +348,7 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
grosssum=Sum('amount')
|
||||
)
|
||||
}
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
sum_cache = {
|
||||
(o['order__id'], o['tax_rate']): o for o in
|
||||
OrderPosition.objects.values('tax_rate', 'order__id').order_by().annotate(
|
||||
@@ -434,7 +436,6 @@ class OrderListExporter(MultiSheetListExporter):
|
||||
)
|
||||
|
||||
if form_data.get('include_payment_amounts'):
|
||||
payment_methods = self._get_all_payment_methods(qs)
|
||||
for id, vn in payment_methods:
|
||||
row.append(
|
||||
payment_sum_cache.get((order.id, id), Decimal('0.00')) -
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -44,7 +45,9 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
media = ReusableMedium.objects.filter(
|
||||
organizer=self.organizer,
|
||||
).select_related(
|
||||
'customer', 'linked_orderposition', 'linked_giftcard',
|
||||
'customer', 'linked_giftcard',
|
||||
).prefetch_related(
|
||||
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order"))
|
||||
).order_by('created')
|
||||
|
||||
headers = [
|
||||
@@ -62,17 +65,16 @@ class ReusableMediaExporter(OrganizerLevelExportMixin, ListExporter):
|
||||
yield self.ProgressSetTotal(total=media.count())
|
||||
|
||||
for medium in media.iterator(chunk_size=1000):
|
||||
row = [
|
||||
yield [
|
||||
medium.type,
|
||||
medium.identifier,
|
||||
_('Yes') if medium.active else _('No'),
|
||||
date_format(medium.expires, 'SHORT_DATETIME_FORMAT') if medium.expires else '',
|
||||
medium.customer.identifier if medium.customer_id else '',
|
||||
f"{medium.linked_orderposition.order.code}-{medium.linked_orderposition.positionid}" if medium.linked_orderposition_id else '',
|
||||
', '.join([f"{op.order.code}-{op.positionid}" for op in medium.linked_orderpositions.all()]),
|
||||
medium.linked_giftcard.secret if medium.linked_giftcard_id else '',
|
||||
medium.notes,
|
||||
]
|
||||
yield row
|
||||
|
||||
def get_filename(self):
|
||||
return f'{self.organizer.slug}_media'
|
||||
|
||||
35
src/pretix/base/migrations/0300_add_reusablemedium_label.py
Normal file
35
src/pretix/base/migrations/0300_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", "0299_itemprogramtime_location"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="reusablemedium",
|
||||
name="claim_token",
|
||||
field=models.CharField(max_length=200, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="reusablemedium",
|
||||
name="label",
|
||||
field=models.CharField(max_length=200, null=True),
|
||||
),
|
||||
# use temporary related_name "linked_mediums" for ManyToManyField, so we can migrate existing data
|
||||
migrations.AddField(
|
||||
model_name="reusablemedium",
|
||||
name="linked_orderpositions",
|
||||
field=models.ManyToManyField(
|
||||
related_name="linked_mediums", to="pretixbase.orderposition"
|
||||
),
|
||||
),
|
||||
migrations.RunSQL(
|
||||
sql="INSERT INTO pretixbase_reusablemedium_linked_orderpositions (reusablemedium_id, orderposition_id) SELECT id, linked_orderposition_id FROM pretixbase_reusablemedium WHERE linked_orderposition_id IS NOT NULL;",
|
||||
reverse_sql="DELETE FROM pretixbase_reusablemedium_linked_orderpositions;",
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 4.2.26 on 2025-11-24 11:32
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
ReusableMedium = apps.get_model('pretixbase', 'ReusableMedium')
|
||||
|
||||
qs = ReusableMedium.linked_orderpositions.through.objects
|
||||
objs = []
|
||||
# get last added orderposition from linked_orderpositions
|
||||
for rm_id, op_id in qs.filter(id__in=qs.values("reusablemedium_id").annotate(max_id=models.Max('id')).values('max_id')).values_list("reusablemedium_id", "orderposition_id"):
|
||||
obj = ReusableMedium(
|
||||
id=rm_id,
|
||||
linked_orderposition_id=op_id,
|
||||
)
|
||||
objs.append(obj)
|
||||
|
||||
ReusableMedium.objects.bulk_update(objs, ['linked_orderposition_id'])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0300_add_reusablemedium_label"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# according to the docs, UPDATE FROM should run similarly on sqlite and postgres, but I could not get it to work
|
||||
# so roll back the data migration with code before deleting data from through-table in 0297
|
||||
migrations.RunPython(migrations.RunPython.noop, reverse),
|
||||
migrations.RemoveField(
|
||||
model_name="reusablemedium",
|
||||
name="linked_orderposition",
|
||||
),
|
||||
# change related_name for new ManyToManyField to previously used linked_media
|
||||
migrations.AlterField(
|
||||
model_name="reusablemedium",
|
||||
name="linked_orderpositions",
|
||||
field=models.ManyToManyField(
|
||||
related_name="linked_media", to="pretixbase.orderposition"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -442,7 +442,7 @@ class AttendeeState(ImportColumn):
|
||||
|
||||
@property
|
||||
def verbose_name(self):
|
||||
return _('Attendee address') + ': ' + _('State')
|
||||
return _('Attendee address') + ': ' + pgettext('address', 'State')
|
||||
|
||||
def clean(self, value, previous_values):
|
||||
if value:
|
||||
|
||||
@@ -125,7 +125,7 @@ class LoggingMixin:
|
||||
elif isinstance(self, Event):
|
||||
event = self
|
||||
organizer_id = self.organizer_id
|
||||
elif hasattr(self, 'event'):
|
||||
elif hasattr(self, 'event') and self.event:
|
||||
event = self.event
|
||||
organizer_id = self.event.organizer_id
|
||||
elif hasattr(self, 'organizer_id'):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3515,8 +3515,8 @@ def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
|
||||
identifier=mt.generate_identifier(sender.organizer),
|
||||
active=True,
|
||||
customer=order.customer,
|
||||
linked_orderposition=p,
|
||||
)
|
||||
rm.linked_orderpositions.add(p)
|
||||
rm.log_action(
|
||||
'pretix.reusable_medium.created',
|
||||
data={
|
||||
|
||||
@@ -1871,7 +1871,7 @@ class ReusableMediaFilterForm(FilterForm):
|
||||
Q(identifier__icontains=query)
|
||||
| Q(customer__identifier__icontains=query)
|
||||
| Q(customer__external_identifier__istartswith=query)
|
||||
| Q(linked_orderposition__order__code__icontains=query)
|
||||
| Q(linked_orderpositions__order__code__icontains=query)
|
||||
| Q(linked_giftcard__secret__icontains=query)
|
||||
)
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ from pretix.control.forms import ExtFileField, SplitDateTimeField
|
||||
from pretix.control.forms.event import (
|
||||
SafeEventMultipleChoiceField, multimail_validate,
|
||||
)
|
||||
from pretix.control.forms.widgets import Select2
|
||||
from pretix.control.forms.widgets import Select2, Select2Multiple
|
||||
from pretix.multidomain.models import KnownDomain
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri
|
||||
|
||||
@@ -249,6 +249,15 @@ class SafeOrderPositionChoiceField(forms.ModelChoiceField):
|
||||
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
|
||||
|
||||
|
||||
class SafeOrderPositionMultipleChoiceField(forms.ModelMultipleChoiceField):
|
||||
def __init__(self, queryset, **kwargs):
|
||||
queryset = queryset.model.all.none()
|
||||
super().__init__(queryset, **kwargs)
|
||||
|
||||
def label_from_instance(self, op):
|
||||
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
|
||||
|
||||
|
||||
class EventMetaPropertyForm(I18nModelForm):
|
||||
class Meta:
|
||||
model = EventMetaProperty
|
||||
@@ -963,12 +972,12 @@ class ReusableMediumUpdateForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes']
|
||||
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderpositions', 'notes']
|
||||
field_classes = {
|
||||
'expires': SplitDateTimeField,
|
||||
'customer': SafeModelChoiceField,
|
||||
'linked_giftcard': SafeModelChoiceField,
|
||||
'linked_orderposition': SafeOrderPositionChoiceField,
|
||||
'linked_orderpositions': SafeOrderPositionMultipleChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'expires': SplitDateTimePickerWidget,
|
||||
@@ -978,8 +987,8 @@ class ReusableMediumUpdateForm(forms.ModelForm):
|
||||
super().__init__(*args, **kwargs)
|
||||
organizer = self.instance.organizer
|
||||
|
||||
self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
|
||||
self.fields['linked_orderposition'].widget = Select2(
|
||||
self.fields['linked_orderpositions'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
|
||||
self.fields['linked_orderpositions'].widget = Select2Multiple(
|
||||
attrs={
|
||||
'data-model-select2': 'generic',
|
||||
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
|
||||
@@ -987,8 +996,8 @@ class ReusableMediumUpdateForm(forms.ModelForm):
|
||||
}),
|
||||
}
|
||||
)
|
||||
self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices
|
||||
self.fields['linked_orderposition'].required = False
|
||||
self.fields['linked_orderpositions'].widget.choices = self.fields['linked_orderpositions'].choices
|
||||
self.fields['linked_orderpositions'].required = False
|
||||
|
||||
self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all()
|
||||
self.fields['linked_giftcard'].widget = Select2(
|
||||
@@ -1042,12 +1051,12 @@ class ReusableMediumCreateForm(ReusableMediumUpdateForm):
|
||||
|
||||
class Meta:
|
||||
model = ReusableMedium
|
||||
fields = ['active', 'type', 'identifier', 'expires', 'linked_orderposition', 'linked_giftcard', 'customer', 'notes']
|
||||
fields = ['active', 'type', 'identifier', 'expires', 'linked_orderpositions', 'linked_giftcard', 'customer', 'notes']
|
||||
field_classes = {
|
||||
'expires': SplitDateTimeField,
|
||||
'customer': SafeModelChoiceField,
|
||||
'linked_giftcard': SafeModelChoiceField,
|
||||
'linked_orderposition': SafeOrderPositionChoiceField,
|
||||
'linked_orderpositions': SafeOrderPositionMultipleChoiceField,
|
||||
}
|
||||
widgets = {
|
||||
'expires': SplitDateTimePickerWidget,
|
||||
|
||||
@@ -34,11 +34,11 @@
|
||||
# License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
import bleach
|
||||
import dateutil.parser
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils.formats import date_format
|
||||
@@ -248,7 +248,7 @@ class OrderValidFromChanged(OrderChangeLogEntryType):
|
||||
def display_prefixed(self, event: Event, logentry: LogEntry, data):
|
||||
return _('The validity start date for position #{posid} has been changed to {value}.').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
|
||||
value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get(
|
||||
'new_value') else '–'
|
||||
)
|
||||
|
||||
@@ -260,7 +260,7 @@ class OrderValidUntilChanged(OrderChangeLogEntryType):
|
||||
def display_prefixed(self, event: Event, logentry: LogEntry, data):
|
||||
return _('The validity end date for position #{posid} has been changed to {value}.').format(
|
||||
posid=data.get('positionid', '?'),
|
||||
value=date_format(dateutil.parser.parse(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else '–'
|
||||
value=date_format(datetime.fromisoformat(data.get('new_value')), 'SHORT_DATETIME_FORMAT') if data.get('new_value') else '–'
|
||||
)
|
||||
|
||||
|
||||
@@ -364,7 +364,7 @@ class CheckinErrorLogEntryType(OrderLogEntryType):
|
||||
data['posid'] = logentry.parsed_data.get('positionid', '?')
|
||||
|
||||
if 'datetime' in data:
|
||||
dt = dateutil.parser.parse(data.get('datetime'))
|
||||
dt = datetime.fromisoformat(data.get('datetime'))
|
||||
if abs((logentry.datetime - dt).total_seconds()) > 5 or data.get('forced'):
|
||||
if event:
|
||||
data['datetime'] = date_format(dt.astimezone(event.timezone), "SHORT_DATETIME_FORMAT")
|
||||
@@ -430,7 +430,7 @@ class OrderPrintLogEntryType(OrderLogEntryType):
|
||||
return _('Position #{posid} has been printed at {datetime} with type "{type}".').format(
|
||||
posid=data.get('positionid'),
|
||||
datetime=date_format(
|
||||
dateutil.parser.parse(data["datetime"]).astimezone(logentry.event.timezone),
|
||||
datetime.fromisoformat(data["datetime"]).astimezone(logentry.event.timezone),
|
||||
"SHORT_DATETIME_FORMAT"
|
||||
) if logentry.event else data["datetime"],
|
||||
type=dict(PrintLog.PRINT_TYPES)[data["type"]],
|
||||
@@ -743,6 +743,8 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
|
||||
'pretix.reusable_medium.created': _('The reusable medium has been created.'),
|
||||
'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'),
|
||||
'pretix.reusable_medium.changed': _('The reusable medium has been changed.'),
|
||||
'pretix.reusable_medium.linked_orderposition.added': _('A new ticket has been added to the medium.'),
|
||||
'pretix.reusable_medium.linked_orderposition.removed': _('A ticket has been removed from the medium.'),
|
||||
'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'),
|
||||
'pretix.reusable_medium.linked_giftcard.changed': _('The medium has been connected to a new gift card.'),
|
||||
'pretix.email.error': _('Sending of an email has failed.'),
|
||||
@@ -985,7 +987,7 @@ class LegacyCheckinLogEntryType(OrderLogEntryType):
|
||||
|
||||
def display(self, logentry, data):
|
||||
# deprecated
|
||||
dt = dateutil.parser.parse(data.get('datetime'))
|
||||
dt = datetime.fromisoformat(data.get('datetime'))
|
||||
tz = logentry.event.timezone
|
||||
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
|
||||
if 'list' in data:
|
||||
|
||||
@@ -89,7 +89,9 @@
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ items|json_script:"items" }}
|
||||
{% if items %}
|
||||
{{ items|json_script:"items" }}
|
||||
{% endif %}
|
||||
|
||||
{% compress js %}
|
||||
<script type="text/javascript" src="{% static "d3/d3.v6.js" %}"></script>
|
||||
|
||||
@@ -134,39 +134,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if invoice_qualified and order.invoice_dirty %}
|
||||
<div class="alert alert-warning">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
This order was changed after the last invoice was generated. A new invoice was not generated yet, because invoices are configured to be generated on payment or if required by the payment method.
|
||||
A new invoice will be generated once the customer pays the invoice or selects a payment method that requires an invoice.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
{% if "event.orders:write" in request.eventpermset %}
|
||||
<p>
|
||||
{% if uncancelled_invoice %}
|
||||
<form action="{% url "control:event.order.reissueinvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code id=uncancelled_invoice.pk %}"
|
||||
method="post">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default" type="submit">
|
||||
{% blocktrans trimmed %}
|
||||
Reissue invoice
|
||||
{% endblocktrans %}
|
||||
</button>
|
||||
</form>
|
||||
{% elif can_generate_invoice %}
|
||||
<form method="post" action="{% url "control:event.order.geninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default">
|
||||
{% trans "Generate invoice" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-lg-10">
|
||||
{% for cr in order.cancellation_requests.all %}
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<form action="{% url "control:organizer.device.bulk_edit" organizer=request.organizer.slug %}" method="post">
|
||||
<form action="#will-be-overridden" method="post">
|
||||
{% csrf_token %}
|
||||
{% for field in filter_form %}
|
||||
{{ field.as_hidden }}
|
||||
|
||||
@@ -58,8 +58,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>
|
||||
@@ -90,13 +90,13 @@
|
||||
{% endif %}
|
||||
</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>
|
||||
|
||||
@@ -26,7 +26,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 %}
|
||||
@@ -41,34 +53,34 @@
|
||||
<dd>
|
||||
{% if medium.customer %}
|
||||
<span class="helper-display-block">
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
{% if "organizer.customers:read" in request.orgapermset %}
|
||||
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=medium.customer.identifier %}">
|
||||
<span class="fa fa-user fa-fw"></span>
|
||||
{% if "organizer.customers:read" in request.orgapermset %}
|
||||
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=medium.customer.identifier %}">
|
||||
{{ medium.customer }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ medium.customer }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ medium.customer }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% if "organizer.giftcards:read" in request.orgapermset %}
|
||||
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=medium.linked_giftcard.id %}">
|
||||
{{ medium.linked_giftcard.secret }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ medium.linked_giftcard.secret|slice:":3" }}…
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="fa fa-credit-card fa-fw"></span>
|
||||
{% if "organizer.giftcards:read" in request.orgapermset %}
|
||||
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=medium.linked_giftcard.id %}">
|
||||
{{ medium.linked_giftcard.secret }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ medium.linked_giftcard.secret|slice:":3" }}…
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if medium.notes %}
|
||||
|
||||
@@ -554,9 +554,6 @@ class OrderDetail(OrderView):
|
||||
ctx['download_buttons'] = self.download_buttons
|
||||
ctx['payment_refund_sum'] = self.order.payment_refund_sum
|
||||
ctx['pending_sum'] = self.order.pending_sum
|
||||
ctx['uncancelled_invoice'] = self.order.invoices.exclude(
|
||||
Exists(self.order.invoices.filter(refers=OuterRef('pk'), is_cancellation=True))
|
||||
).exclude(is_cancellation=True).first()
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -3384,8 +3384,10 @@ class ReusableMediaListView(OrganizerDetailViewMixin, OrganizerPermissionRequire
|
||||
|
||||
def get_queryset(self):
|
||||
qs = self.request.organizer.reusable_media.select_related(
|
||||
'customer', 'linked_orderposition', 'linked_orderposition__order', 'linked_orderposition__order__event',
|
||||
'linked_giftcard'
|
||||
'customer',
|
||||
'linked_giftcard',
|
||||
).prefetch_related(
|
||||
Prefetch('linked_orderpositions', queryset=OrderPosition.objects.select_related("order", "order__event"))
|
||||
)
|
||||
if self.filter_form.is_valid():
|
||||
qs = self.filter_form.filter_qs(qs)
|
||||
@@ -3433,10 +3435,14 @@ class ReusableMediumCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
r = super().form_valid(form)
|
||||
form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data={
|
||||
|
||||
data = {
|
||||
k: getattr(form.instance, k)
|
||||
for k in form.changed_data
|
||||
})
|
||||
}
|
||||
if "linked_orderpositions" in data:
|
||||
data["linked_orderpositions"] = data["linked_orderpositions"].values_list("pk", flat=True)
|
||||
form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data=data)
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return r
|
||||
|
||||
@@ -3461,13 +3467,40 @@ class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
|
||||
|
||||
@transaction.atomic
|
||||
def form_valid(self, form):
|
||||
prev_linked_ops_pks = list(getattr(self.object, "linked_orderpositions").values_list("pk", flat=True))
|
||||
result = super().form_valid(form)
|
||||
if form.has_changed():
|
||||
self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data={
|
||||
data = {
|
||||
k: getattr(self.object, k)
|
||||
for k in form.changed_data
|
||||
})
|
||||
}
|
||||
if "linked_orderpositions" in data:
|
||||
# handle changes to linked_orderpositions separately
|
||||
linked_ops_pks = data["linked_orderpositions"].values_list("pk", flat=True)
|
||||
del data["linked_orderpositions"]
|
||||
for op_pk in prev_linked_ops_pks:
|
||||
if op_pk not in linked_ops_pks:
|
||||
self.object.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.removed',
|
||||
user=self.request.user,
|
||||
data={
|
||||
'linked_orderposition': op_pk,
|
||||
}
|
||||
)
|
||||
for op_pk in linked_ops_pks:
|
||||
if op_pk not in prev_linked_ops_pks:
|
||||
self.object.log_action(
|
||||
'pretix.reusable_medium.linked_orderposition.added',
|
||||
user=self.request.user,
|
||||
data={
|
||||
'linked_orderposition': op_pk,
|
||||
}
|
||||
)
|
||||
if data:
|
||||
# log change-action only for changes other than linked_orderpositions
|
||||
self.object.log_action('pretix.reusable_medium.changed', user=self.request.user, data=data)
|
||||
messages.success(self.request, _('Your changes have been saved.'))
|
||||
return super().form_valid(form)
|
||||
return result
|
||||
|
||||
def get_success_url(self):
|
||||
return reverse('control:organizer.reusable_medium', kwargs={
|
||||
|
||||
@@ -75,10 +75,14 @@ const missingItems = computed(() => {
|
||||
li.active(role="presentation")
|
||||
a(href="#rules-edit", role="tab", data-toggle="tab")
|
||||
span.fa.fa-edit
|
||||
// space between icon and string
|
||||
|
|
||||
| {{ gettext("Edit") }}
|
||||
li(role="presentation")
|
||||
a(href="#rules-viz", role="tab", data-toggle="tab")
|
||||
span.fa.fa-eye
|
||||
// space between icon and string
|
||||
|
|
||||
| {{ gettext("Visualize") }}
|
||||
|
||||
//- Tab panes
|
||||
|
||||
@@ -17,7 +17,7 @@ export const rules = ref<any>({})
|
||||
// grab rules from hidden input
|
||||
const rulesInput = document.querySelector<HTMLInputElement>('#id_rules')
|
||||
if (rulesInput?.value) {
|
||||
rules.value = JSON.parse(rulesInput.value)
|
||||
rules.value = JSON.parse(rulesInput.value) ?? {}
|
||||
}
|
||||
|
||||
// sync back to hidden input
|
||||
|
||||
@@ -180,7 +180,7 @@ g
|
||||
br
|
||||
span(v-if="varresult !== null") {{ varresult }}
|
||||
strong
|
||||
| {{ op.label }} {{ rightoperand }}
|
||||
| {{ op?.label}} {{ rightoperand }}
|
||||
span(v-else-if="vardata && vardata.type === 'int_by_datetime'")
|
||||
span.fa.fa-sign-in(v-if="variable.startsWith('entries_')")
|
||||
| {{ vardata.label }}
|
||||
@@ -193,21 +193,21 @@ g
|
||||
br
|
||||
span(v-if="varresult !== null") {{ varresult }}
|
||||
strong
|
||||
| {{ op.label }} {{ rightoperand }}
|
||||
| {{ op?.label }} {{ rightoperand }}
|
||||
span(v-else-if="vardata && variable === 'now'")
|
||||
span.fa.fa-clock-o
|
||||
| {{ vardata.label }}
|
||||
br
|
||||
span(v-if="varresult !== null") {{ varresult }}
|
||||
strong
|
||||
| {{ op.label }}
|
||||
| {{ op?.label }}
|
||||
br
|
||||
span(v-if="rightoperand.buildTime[0] === 'custom'")
|
||||
| {{ df(rightoperand.buildTime[1]) }}
|
||||
span(v-else-if="rightoperand.buildTime[0] === 'customtime'")
|
||||
| {{ tf(rightoperand.buildTime[1]) }}
|
||||
span(v-if="rightoperand?.buildTime[0] === 'custom'")
|
||||
| {{ df(rightoperand?.buildTime[1]) }}
|
||||
span(v-else-if="rightoperand?.buildTime[0] === 'customtime'")
|
||||
| {{ tf(rightoperand?.buildTime[1]) }}
|
||||
span(v-else)
|
||||
| {{ TEXTS[rightoperand.buildTime[0]] }}
|
||||
| {{ TEXTS[rightoperand?.buildTime[0]] }}
|
||||
span(v-if="operands[2]")
|
||||
span(v-if="operator === 'isBefore'") +
|
||||
span(v-else) -
|
||||
@@ -220,14 +220,14 @@ g
|
||||
span(v-if="varresult !== null") ({{ varresult }})
|
||||
br
|
||||
strong
|
||||
| {{ rightoperand.objectList.map((o: any) => o.lookup[2]).join(", ") }}
|
||||
| {{ rightoperand?.objectList.map((o: any) => o.lookup[2]).join(", ") }}
|
||||
span(v-else-if="vardata && vardata.type === 'enum_entry_status'")
|
||||
span.fa.fa-check-circle-o
|
||||
| {{ vardata.label }}
|
||||
span(v-if="varresult !== null") ({{ varresult }})
|
||||
br
|
||||
strong
|
||||
| {{ op.label }} {{ rightoperand }}
|
||||
| {{ op?.label }} {{ rightoperand }}
|
||||
|
||||
g(v-if="result === false", :transform="`translate(${x + boxWidth - 15}, ${y - 10})`")
|
||||
ellipse(fill="#fff", cx="14.685823", cy="14.318233", rx="12.140151", ry="11.55523")
|
||||
|
||||
@@ -93,11 +93,11 @@ const showTaxline = computed(() => props.price.rate !== '0.00' && props.price.gr
|
||||
span(v-if="!freePrice && !originalPrice", v-html="priceline")
|
||||
span(v-if="!freePrice && originalPrice")
|
||||
del.pretix-widget-pricebox-original-price(:aria-label="originalPriceAriaLabel", v-html="originalLine")
|
||||
|
|
||||
|!{' '}
|
||||
ins.pretix-widget-pricebox-new-price(:aria-label="newPriceAriaLabel", v-html="priceline")
|
||||
div(v-if="freePrice")
|
||||
span.pretix-widget-pricebox-currency(:id="priceBoxId") {{ store.currency }}
|
||||
|
|
||||
|!{' '}
|
||||
input.pretix-widget-pricebox-price-input(
|
||||
type="number",
|
||||
placeholder="0",
|
||||
|
||||
@@ -286,12 +286,12 @@ def test_by_secret_special_chars(token_client, organizer, clist, event, order):
|
||||
@pytest.mark.django_db
|
||||
def test_by_medium(token_client, organizer, clist, event, order):
|
||||
with scopes_disabled():
|
||||
ReusableMedium.objects.create(
|
||||
rm = ReusableMedium.objects.create(
|
||||
type="barcode",
|
||||
identifier="abcdef",
|
||||
organizer=organizer,
|
||||
linked_orderposition=order.positions.first(),
|
||||
)
|
||||
rm.linked_orderpositions.add(order.positions.first())
|
||||
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
@@ -301,6 +301,71 @@ def test_by_medium(token_client, organizer, clist, event, order):
|
||||
assert ci.raw_source_type == "barcode"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_by_medium_multiple_orderpositions(token_client, organizer, clist_all, event, order):
|
||||
with scopes_disabled():
|
||||
rm = ReusableMedium.objects.create(
|
||||
type="barcode",
|
||||
identifier="abcdef",
|
||||
organizer=organizer,
|
||||
)
|
||||
op_item_first = order.positions.first()
|
||||
rm.linked_orderpositions.add(op_item_first)
|
||||
op_item_other = order.positions.all()[1]
|
||||
rm.linked_orderpositions.add(op_item_other)
|
||||
|
||||
# multiple tickets are valid => no check-in
|
||||
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'error'
|
||||
assert resp.data['reason'] == 'ambiguous'
|
||||
|
||||
with scopes_disabled():
|
||||
op_item_other.valid_from = datetime.datetime(2020, 1, 1, 12, 0, 0, tzinfo=event.timezone)
|
||||
op_item_other.valid_until = datetime.datetime(2020, 1, 1, 15, 0, 0, tzinfo=event.timezone)
|
||||
op_item_other.save()
|
||||
|
||||
with freeze_time("2020-01-01 13:45:00"):
|
||||
# multiple tickets are valid => no check-in
|
||||
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'error'
|
||||
assert resp.data['reason'] == 'ambiguous'
|
||||
|
||||
with freeze_time("2020-01-01 10:45:00"):
|
||||
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
|
||||
with freeze_time("2020-01-01 15:45:00"):
|
||||
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'error'
|
||||
assert resp.data['reason'] == 'already_redeemed'
|
||||
|
||||
with scopes_disabled():
|
||||
op_item_first.valid_from = datetime.datetime(2020, 1, 1, 10, 0, 0, tzinfo=event.timezone)
|
||||
op_item_first.valid_until = datetime.datetime(2020, 1, 1, 12, 0, 0, tzinfo=event.timezone)
|
||||
op_item_first.save()
|
||||
|
||||
with freeze_time("2020-01-01 15:45:00"):
|
||||
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'error'
|
||||
assert resp.data['reason'] == 'invalid_time'
|
||||
|
||||
with scopes_disabled():
|
||||
op_item_first.canceled = True
|
||||
op_item_first.save()
|
||||
op_item_other.canceled = True
|
||||
op_item_other.save()
|
||||
|
||||
resp = _redeem(token_client, organizer, clist_all, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'error'
|
||||
assert resp.data['reason'] == 'canceled'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_by_medium_not_connected(token_client, organizer, clist, event, order):
|
||||
with scopes_disabled():
|
||||
@@ -318,12 +383,12 @@ def test_by_medium_not_connected(token_client, organizer, clist, event, order):
|
||||
@pytest.mark.django_db
|
||||
def test_by_medium_wrong_event(token_client, organizer, clist, event, order2):
|
||||
with scopes_disabled():
|
||||
ReusableMedium.objects.create(
|
||||
rm = ReusableMedium.objects.create(
|
||||
type="barcode",
|
||||
identifier="abcdef",
|
||||
organizer=organizer,
|
||||
linked_orderposition=order2.positions.first(),
|
||||
)
|
||||
rm.linked_orderpositions.add(order2.positions.first())
|
||||
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 404
|
||||
assert resp.data['status'] == 'error'
|
||||
@@ -337,12 +402,12 @@ def test_by_medium_wrong_event(token_client, organizer, clist, event, order2):
|
||||
@pytest.mark.django_db
|
||||
def test_by_medium_wrong_type(token_client, organizer, clist, event, order):
|
||||
with scopes_disabled():
|
||||
ReusableMedium.objects.create(
|
||||
rm = ReusableMedium.objects.create(
|
||||
type="nfc_uid",
|
||||
identifier="abcdef",
|
||||
organizer=organizer,
|
||||
linked_orderposition=order.positions.first(),
|
||||
)
|
||||
rm.linked_orderpositions.add(order.positions.first())
|
||||
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 404
|
||||
assert resp.data['status'] == 'error'
|
||||
@@ -355,13 +420,13 @@ def test_by_medium_wrong_type(token_client, organizer, clist, event, order):
|
||||
@pytest.mark.django_db
|
||||
def test_by_medium_inactive(token_client, organizer, clist, event, order):
|
||||
with scopes_disabled():
|
||||
ReusableMedium.objects.create(
|
||||
rm = ReusableMedium.objects.create(
|
||||
type="barcode",
|
||||
identifier="abcdef",
|
||||
organizer=organizer,
|
||||
active=False,
|
||||
linked_orderposition=order.positions.first(),
|
||||
)
|
||||
rm.linked_orderpositions.add(order.positions.first())
|
||||
resp = _redeem(token_client, organizer, clist, "abcdef", {"source_type": "barcode"})
|
||||
assert resp.status_code == 404
|
||||
assert resp.data['status'] == 'error'
|
||||
|
||||
@@ -3121,9 +3121,78 @@ def test_order_create_use_medium(token_client, organizer, event, item, quota, qu
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
medium.refresh_from_db()
|
||||
assert o.positions.first() == medium.linked_orderposition
|
||||
assert o.positions.first() == medium.linked_orderpositions.first()
|
||||
assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier
|
||||
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
medium.refresh_from_db()
|
||||
assert medium.linked_orderpositions.count() == 1
|
||||
assert o.positions.first() == medium.linked_orderpositions.first()
|
||||
assert resp.data['positions'][0]['pdf_data']['medium_identifier'] == medium.identifier
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_add_to_medium(token_client, organizer, event, item, quota, question, medium):
|
||||
item.media_type = medium.type
|
||||
item.media_policy = Item.MEDIA_POLICY_REUSE_OR_NEW
|
||||
item.save()
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['positions'][0]['item'] = item.pk
|
||||
res['positions'][0]['use_reusable_medium'] = medium.pk
|
||||
res['positions'][0]['add_to_reusable_medium'] = medium.pk
|
||||
res['positions'][0]['answers'][0]['question'] = question.pk
|
||||
|
||||
# do not use use_reusable_medium and add_to_reusable_medium
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
del res['positions'][0]['use_reusable_medium']
|
||||
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
medium.refresh_from_db()
|
||||
assert medium.linked_orderpositions.count() == 1
|
||||
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
medium.refresh_from_db()
|
||||
assert medium.linked_orderpositions.count() == 2
|
||||
|
||||
res['positions'][0]['use_reusable_medium'] = medium.pk
|
||||
del res['positions'][0]['add_to_reusable_medium']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
o = Order.objects.get(code=resp.data['code'])
|
||||
medium.refresh_from_db()
|
||||
assert medium.linked_orderpositions.count() == 1
|
||||
assert o.positions.first() == medium.linked_orderpositions.first()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_use_medium_other_organizer(token_client, organizer, event, item, quota, question, medium2):
|
||||
@@ -3168,7 +3237,7 @@ def test_order_create_create_medium(token_client, organizer, event, item, quota,
|
||||
i = resp.data['positions'][0]['pdf_data']['medium_identifier']
|
||||
assert i
|
||||
m = organizer.reusable_media.get(identifier=i)
|
||||
assert m.linked_orderposition == o.positions.first()
|
||||
assert m.linked_orderpositions.first() == o.positions.first()
|
||||
assert m.type == "barcode"
|
||||
|
||||
|
||||
|
||||
@@ -89,10 +89,13 @@ TEST_MEDIUM_RES = {
|
||||
"organizer": "dummy",
|
||||
"identifier": "ABCDEFGH",
|
||||
"type": "barcode",
|
||||
"claim_token": None,
|
||||
"label": None,
|
||||
"active": True,
|
||||
"expires": None,
|
||||
"customer": None,
|
||||
"linked_orderposition": None,
|
||||
"linked_orderpositions": [],
|
||||
"linked_giftcard": None,
|
||||
"notes": None,
|
||||
"info": {},
|
||||
@@ -170,7 +173,7 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome
|
||||
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
|
||||
personalized=True)
|
||||
op = o.positions.create(item=ticket, price=Decimal("14"))
|
||||
medium.linked_orderposition = op
|
||||
medium.linked_orderpositions.add(op)
|
||||
medium.linked_giftcard = giftcard
|
||||
medium.customer = customer
|
||||
medium.save()
|
||||
@@ -273,7 +276,7 @@ def test_medium_detail_event_permission_missing(token_client, organizer, event,
|
||||
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
|
||||
personalized=True)
|
||||
op = o.positions.create(item=ticket, price=Decimal("14"))
|
||||
medium.linked_orderposition = op
|
||||
medium.linked_orderpositions.add(op)
|
||||
medium.linked_giftcard = giftcard
|
||||
medium.customer = customer
|
||||
medium.save()
|
||||
@@ -352,6 +355,110 @@ def test_medium_create(token_client, organizer, giftcard):
|
||||
assert m.updated > now() - timedelta(minutes=10)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_medium_create_linked_orderposition(token_client, organizer, event, org2_event, medium):
|
||||
with scopes_disabled():
|
||||
o = Order.objects.create(
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
total=14, locale='en'
|
||||
)
|
||||
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
|
||||
personalized=True)
|
||||
op = o.positions.create(item=ticket, price=Decimal("14"))
|
||||
op2 = o.positions.create(item=ticket, price=Decimal("14"))
|
||||
|
||||
org2_o = Order.objects.create(
|
||||
code='FOO', event=org2_event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
|
||||
sales_channel=org2_event.organizer.sales_channels.get(identifier="web"),
|
||||
total=14, locale='en'
|
||||
)
|
||||
org2_ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
|
||||
personalized=True)
|
||||
org2_op = org2_o.positions.create(item=org2_ticket, price=Decimal("14"))
|
||||
|
||||
payload = dict(TEST_MEDIUM_CREATE_PAYLOAD)
|
||||
|
||||
# wrong orderposition for organizer
|
||||
payload['linked_orderposition'] = org2_op.pk
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
|
||||
payload,
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
# unkown orderposition
|
||||
payload['linked_orderposition'] = "unknown"
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
|
||||
payload,
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
# create with linked_orderposition
|
||||
payload['linked_orderposition'] = op.pk
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
|
||||
payload,
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
m = ReusableMedium.objects.get(pk=resp.data['id'])
|
||||
assert list(m.linked_orderpositions.values_list('pk', flat=True)) == [op.pk]
|
||||
|
||||
# double-check API-response for fallback-values
|
||||
resp = token_client.get(
|
||||
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, resp.data['id'])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data['linked_orderposition'] == op.pk
|
||||
assert resp.data['linked_orderpositions'] == [op.pk]
|
||||
|
||||
# create with linked_orderposition and linked_orderpositions (not allowed)
|
||||
payload['identifier'] = "FOOBAZ"
|
||||
payload['linked_orderpositions'] = [op.pk, org2_op.pk]
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
|
||||
payload,
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
# multiple linked_orderpositions, but from different organizers
|
||||
del payload['linked_orderposition']
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
|
||||
payload,
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
# multiple linked_orderpositions from same organizer
|
||||
payload['linked_orderpositions'] = [op.pk, op2.pk]
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
|
||||
payload,
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
with scopes_disabled():
|
||||
m = ReusableMedium.objects.get(pk=resp.data['id'])
|
||||
assert list(m.linked_orderpositions.values_list('pk', flat=True)) == [op.pk, op2.pk]
|
||||
|
||||
# double-check API-response for fallback-values
|
||||
resp = token_client.get(
|
||||
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, resp.data['id'])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.data['linked_orderposition'] is None
|
||||
assert resp.data['linked_orderpositions'] == [op.pk, op2.pk]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_medium_foreignkeyval(token_client, organizer, giftcard2):
|
||||
payload = dict(TEST_MEDIUM_CREATE_PAYLOAD)
|
||||
@@ -398,6 +505,68 @@ def test_medium_patch(token_client, organizer, event, medium, giftcard, customer
|
||||
assert medium.info == {'test': 2}
|
||||
assert medium.identifier == "ABCDEFGH"
|
||||
|
||||
# test patch with linked_orderpositions
|
||||
with scopes_disabled():
|
||||
o = Order.objects.create(
|
||||
code='FOO', event=event, email='dummy@dummy.test',
|
||||
status=Order.STATUS_PENDING, datetime=now(), expires=now() + timedelta(days=10),
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
total=14, locale='en'
|
||||
)
|
||||
ticket = event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
|
||||
personalized=True)
|
||||
op = o.positions.create(item=ticket, price=Decimal("14"))
|
||||
op2 = o.positions.create(item=ticket, price=Decimal("14"))
|
||||
|
||||
resp = token_client.patch(
|
||||
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
|
||||
{
|
||||
'linked_orderposition': op.pk,
|
||||
},
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
medium.refresh_from_db()
|
||||
with scopes_disabled():
|
||||
assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op.pk]
|
||||
assert medium.all_logentries().count() == 2
|
||||
|
||||
resp = token_client.patch(
|
||||
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
|
||||
{
|
||||
'linked_orderpositions': [op.pk, op2.pk],
|
||||
},
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
medium.refresh_from_db()
|
||||
with scopes_disabled():
|
||||
assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op.pk, op2.pk]
|
||||
assert medium.all_logentries().count() == 3
|
||||
|
||||
resp = token_client.patch(
|
||||
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
|
||||
{
|
||||
'linked_orderpositions': [op2.pk],
|
||||
},
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
medium.refresh_from_db()
|
||||
with scopes_disabled():
|
||||
assert list(medium.linked_orderpositions.values_list('pk', flat=True)) == [op2.pk]
|
||||
assert medium.all_logentries().count() == 4
|
||||
|
||||
resp = token_client.patch(
|
||||
'/api/v1/organizers/{}/reusablemedia/{}/'.format(organizer.slug, medium.pk),
|
||||
{
|
||||
'linked_orderposition': op.pk,
|
||||
'linked_orderpositions': [op.pk, op2.pk],
|
||||
},
|
||||
format='json'
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_medium_no_deletion(token_client, organizer, event, medium):
|
||||
@@ -538,7 +707,7 @@ def test_medium_lookup_cross_organizer(token_client, organizer, organizer2, org2
|
||||
ticket = org2_event.items.create(name='Early-bird ticket', category=None, default_price=23, admission=True,
|
||||
personalized=True)
|
||||
op = o.positions.create(item=ticket, price=Decimal("14"))
|
||||
medium2.linked_orderposition = op
|
||||
medium2.linked_orderpositions.add(op)
|
||||
medium2.linked_giftcard = giftcard2
|
||||
medium2.save()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user