Compare commits

...

71 Commits

Author SHA1 Message Date
Richard Schreiber
ec628e0328 fix flake8 2026-04-17 12:08:52 +02:00
Richard Schreiber
dadcfdba59 unify qutation marks 2026-04-17 12:08:09 +02:00
Richard Schreiber
541dfce46b fix test 2026-04-17 12:08:01 +02:00
Richard Schreiber
f4ed23598c fix migrations numbering 2026-04-17 12:02:15 +02:00
Richard Schreiber
2485e69584 Fix indentation 2026-04-17 11:48:48 +02:00
Richard Schreiber
c9514f0460 fix flake8 2026-04-17 11:46:14 +02:00
Richard Schreiber
67525ed423 API add test for fallback-values in medium patch 2026-04-17 11:46:14 +02:00
Richard Schreiber
f40f68a0cd add tests for create with linked_orderposition 2026-04-17 11:46:14 +02:00
Richard Schreiber
9722089008 add missing label_from_instance for SafeOrderPositionMultipleChoiceField 2026-04-17 11:46:14 +02:00
Richard Schreiber
aa51a29277 Add logentrytype reusable_medium.linked_orderposition.removed 2026-04-17 11:46:14 +02:00
Martin Gross
bf62123084 Rebase against origin/master 2026-04-17 11:46:14 +02:00
Maximilian Richt
da270e3088 Add copy and qr button to reusable medium detail view 2026-04-17 11:46:14 +02:00
Maximilian Richt
0bce70b27d Fix sorting of reusable media type in overview 2026-04-17 11:41:18 +02:00
Richard Schreiber
977a437e1c improve check 2026-04-17 11:41:18 +02:00
Richard Schreiber
55fd521648 fix flake8 2026-04-17 11:41:18 +02:00
Richard Schreiber
acf941a52f fix multi-op media filter 2026-04-17 11:41:18 +02:00
Richard Schreiber
782c434087 Add help-text 2026-04-17 11:39:04 +02:00
Richard Schreiber
2a684d6953 add test 2026-04-17 11:39:04 +02:00
Richard Schreiber
e862cdf488 fix linked_orderpositions filter in checkinrpc 2026-04-17 11:39:04 +02:00
Richard Schreiber
bb5333daed Add test for order-API add_to_reusable_medium 2026-04-17 11:39:04 +02:00
Richard Schreiber
4736f670cd list ops comma-separated in export 2026-04-17 11:39:04 +02:00
Richard Schreiber
b42f2faea2 Clarify docs 2026-04-17 11:39:04 +02:00
Richard Schreiber
d7074e0e38 fix flake8 2026-04-17 11:39:04 +02:00
Richard Schreiber
86715466e8 fix tests regarding claim_token 2026-04-17 11:39:04 +02:00
Richard Schreiber
8e64b467c3 add add_to_reusable_medium to order-serializer 2026-04-17 11:39:04 +02:00
Richard Schreiber
b7d1bd9ef1 fix flake8 2026-04-17 11:39:04 +02:00
Richard Schreiber
3988ba1cd1 fix missing claim_token in serializer 2026-04-17 11:39:04 +02:00
Richard Schreiber
fe1fa336ad Update reusablemedia.rst 2026-04-17 11:38:29 +02:00
Richard Schreiber
23b53dba74 Update reusablemedia.rst 2026-04-17 11:38:29 +02:00
Richard Schreiber
145b7892db Update reusablemedia.rst 2026-04-17 11:38:29 +02:00
Richard Schreiber
c4f0fc1000 unifiy deprecated style 2026-04-17 11:38:29 +02:00
Richard Schreiber
8ea5c42944 Update docs for claim_token 2026-04-17 11:38:29 +02:00
Richard Schreiber
93f69c3bbe rename secret to claim_token 2026-04-17 11:38:29 +02:00
Richard Schreiber
2cf4d03d05 add filter based on op.valid_from/until 2026-04-17 11:38:29 +02:00
Richard Schreiber
0862fdd589 select_related order instead prefetch 2026-04-17 11:38:29 +02:00
Richard Schreiber
e6e874af79 improve readability 2026-04-17 11:38:29 +02:00
Richard Schreiber
9e0ce9dcaf no need to prefetch linked_orderpositions 2026-04-17 11:38:29 +02:00
Richard Schreiber
94d2478ee0 clarify docs 2026-04-17 11:38:29 +02:00
Richard Schreiber
43bdcc9843 clarify docs updating multiple linked_orderpositions 2026-04-17 11:38:29 +02:00
Richard Schreiber
a6b4186a49 update docs 2026-04-17 11:38:29 +02:00
Richard Schreiber
f601451d03 fix tests 2026-04-17 11:38:29 +02:00
Richard Schreiber
3ac0fafaef fix migration NOT NULL 2026-04-17 11:38:29 +02:00
Richard Schreiber
5723cba275 add label to reusablemedium 2026-04-17 11:38:29 +02:00
Richard Schreiber
d49313c902 fix code style 2026-04-17 11:38:29 +02:00
Richard Schreiber
50fff39f4b fix more tests 2026-04-17 11:35:18 +02:00
Richard Schreiber
c62425c7f6 fix tests 2026-04-17 11:35:18 +02:00
Richard Schreiber
bb793c9011 fix create/update logging 2026-04-17 11:35:18 +02:00
Richard Schreiber
96188ae002 remove unneeded comment 2026-04-17 11:35:18 +02:00
Richard Schreiber
c71bfafae2 adapt checkin API for multiple orderpositions 2026-04-17 11:35:18 +02:00
Richard Schreiber
f2b5960ea0 fix media-issue signal 2026-04-17 11:35:18 +02:00
Richard Schreiber
44e53b4dee remove cached_property linked_orderposition - keep only in API 2026-04-17 11:35:18 +02:00
Richard Schreiber
c2cfd2891c fix API orders matching media 2026-04-17 11:35:18 +02:00
Richard Schreiber
f37808294c fix API orders 2026-04-17 11:35:18 +02:00
Richard Schreiber
9972628543 update control media forms 2026-04-17 11:35:18 +02:00
Richard Schreiber
5960653d6b fix media-view filter 2026-04-17 11:35:18 +02:00
Richard Schreiber
c4833c4c32 update media-API 2026-04-17 11:32:27 +02:00
Richard Schreiber
8a5e249bb6 add multi-op to export 2026-04-17 10:53:44 +02:00
Richard Schreiber
fbaeb76b2a return last op as fallback for linked_orderposition 2026-04-17 10:53:44 +02:00
Richard Schreiber
233ed6824a Update media views to list ops 2026-04-17 10:53:44 +02:00
Richard Schreiber
b9fca300bf change linked orderpositions to many-to-many 2026-04-17 10:48:03 +02:00
Mie Frydensbjerg
4beea63b49 Translations: Update Danish
Currently translated at 46.5% (2925 of 6287 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/da/

powered by weblate
2026-04-16 11:01:56 +02:00
Nikolai
5e49df0ef6 Translations: Update Danish
Currently translated at 46.1% (2904 of 6287 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/da/

powered by weblate
2026-04-16 11:01:56 +02:00
pajowu
b3bb9fccb5 Translations: Update Danish
Currently translated at 44.2% (2784 of 6287 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/da/

powered by weblate
2026-04-16 11:01:56 +02:00
Lukas Bockstaller
e3ffd66691 Giftcard/Reusable Media API: fix expand permission check (Z#23230608) (#6091)
* add failing tests

* add permission checks in to_representation

* only overwrite final representation not the serializer

* styling

* include review
2026-04-15 15:59:08 +02:00
Martin Gross
0f2ebb8687 PPv2: Fix permission-check for ISU (event.settings.general:write to event.settings.payment:write) 2026-04-14 17:02:47 +02:00
Richard Schreiber
efd887b439 API: fix PDF-download name (Z#23231496) 2026-04-14 14:13:14 +02:00
pajowu
8690d65e99 Do not show payment text of canceled and failed payments on invoice (Z#23231070) (#6075) 2026-04-14 13:02:12 +02:00
Richard Schreiber
5682d3ed56 Do not force PDFs to be downloaded (Z#23225892) (#5994)
* Display invoice and tickets inline in browser (Z#23225892)

* Use FileResponse filename for AnswerDownload

* Use inline for PDF-view in pretix-control editor

* use as_attachment for API FileResponses

* do not ignore csp even for disposition=inline

* use as_attachment for file responses in control

* remove unused code

* improve code style

* Invoice preview inline

* do not force download on tickets in backend

* do not force download on AnswerDownload

* imrpove code style

* improve code style

* fix missing int str conversion

* Apply suggestions from code review

Co-authored-by: luelista <mira@teamwiki.de>

---------

Co-authored-by: luelista <mira@teamwiki.de>
2026-04-14 09:12:09 +02:00
pajowu
059ff6c99b Allow buttons to reuse cart (Z#23226853) (#6047)
* Allow buttons to reuse cart (Z#23226853)

* Always keep cart of buttons with items set
2026-04-13 19:32:33 +02:00
Mie Frydensbjerg
f46fc7fa69 Translations: Update Danish
Currently translated at 44.2% (2784 of 6287 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/da/

powered by weblate
2026-04-13 16:02:34 +02:00
pajowu
3473fa738d Fix AttributeError in CheckPrivateNetworkMixin (#6076) 2026-04-10 12:47:53 +02:00
37 changed files with 1150 additions and 498 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

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

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

@@ -31,7 +31,9 @@ from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.serializers.organizer import (
CustomerSerializer, GiftCardSerializer,
)
from pretix.base.models import Order, OrderPosition, ReusableMedium
from pretix.base.models import (
Device, Order, OrderPosition, ReusableMedium, TeamAPIToken,
)
logger = logging.getLogger(__name__)
@@ -64,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(
@@ -79,18 +82,27 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
queryset=self.context['organizer'].issued_gift_cards.all()
)
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
# No additional permission check performed, documented limitation of the permission system
# Would get to complex/unusable otherwise since the permission depends on the event
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.")
@@ -105,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']
@@ -117,6 +144,41 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
)
return data
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 ops and 'linked_orderposition' in expand_nested or 'linked_orderpositions' in expand_nested:
ops_noperm = []
for lop in instance.linked_orderpositions.all().prefetch_related('order__event', 'order__event__organizer'):
event = lop.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
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
if gc is not None and gc.owner_ticket is not None:
event = gc.owner_ticket.order.event
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['linked_giftcard']['owner_ticket'] = {'id': instance.linked_giftcard.owner_ticket.id}
return r
class Meta:
model = ReusableMedium
fields = (
@@ -126,10 +188,12 @@ class ReusableMediaSerializer(I18nAwareModelSerializer):
'updated',
'type',
'identifier',
'claim_token',
'label',
'active',
'expires',
'customer',
'linked_orderposition',
'linked_orderpositions',
'linked_giftcard',
'info',
'notes',

View File

@@ -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
@@ -1585,7 +1599,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:
@@ -1659,6 +1673,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)
@@ -1700,8 +1715,14 @@ 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 in use_reusable_medium.linked_orderpositions.all():
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',
data={
@@ -1709,6 +1730,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

@@ -286,6 +286,19 @@ class GiftCardSerializer(I18nAwareModelSerializer):
)
return data
def to_representation(self, instance):
r = super().to_representation(instance)
request = self.context.get('request')
# late permission evaluations for checks that depend on the actual linked events
if 'owner_ticket' in self.context['request'].query_params.getlist('expand'):
owner_ticket = instance.owner_ticket
if owner_ticket:
event = owner_ticket.order.event
perm_holder = request.auth if isinstance(request.auth, (Device, TeamAPIToken)) else request.user
if not perm_holder.has_event_permission(event.organizer, event, 'event.orders:read', request):
r['owner_ticket'] = {'id': instance.owner_ticket.id}
return r
class Meta:
model = GiftCard
fields = ('id', 'secret', 'issuance', 'value', 'currency', 'testmode', 'expires', 'conditions', 'owner_ticket',

View File

@@ -521,11 +521,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:
@@ -628,7 +630,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={
@@ -654,21 +657,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(
@@ -157,7 +159,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

@@ -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
@@ -381,12 +381,15 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
return FileResponse(
ct.file.file,
filename='{}-{}-{}{}'.format(
self.request.event.slug.upper(), order.code,
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
)
return resp
@action(detail=True, methods=['POST'])
def mark_paid(self, request, **kwargs):
@@ -1031,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)
@@ -1303,14 +1306,17 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
raise NotFound()
ftype, ignored = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
return FileResponse(
answer.file,
filename='{}-{}-{}-{}'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
os.path.basename(answer.file.name).split('.', 1)[1]
),
as_attachment=True,
content_type=ftype or 'application/binary'
)
return resp
@action(detail=True, url_name="printlog", url_path="printlog", methods=["POST"])
def printlog(self, request, **kwargs):
@@ -1365,15 +1371,18 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
if hasattr(image_file, 'seek'):
image_file.seek(0)
resp = FileResponse(image_file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}.{}"'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
key,
extension,
return FileResponse(
image_file,
filename='{}-{}-{}-{}.{}'.format(
self.request.event.slug.upper(),
pos.order.code,
pos.positionid,
key,
extension,
),
as_attachment=True,
content_type=ftype or 'application/binary'
)
return resp
@action(detail=True, url_name='download', url_path='download/(?P<output>[^/]+)')
def download(self, request, output, **kwargs):
@@ -1399,12 +1408,15 @@ class EventOrderPositionViewSet(OrderPositionViewSetMixin, viewsets.ModelViewSet
resp = HttpResponse(ct.file.file.read(), content_type='text/uri-list')
return resp
else:
resp = FileResponse(ct.file.file, content_type=ct.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
return FileResponse(
ct.file.file,
filename='{}-{}-{}-{}{}'.format(
self.request.event.slug.upper(), pos.order.code, pos.positionid,
provider.identifier, ct.extension
),
as_attachment=True,
content_type=ct.type
)
return resp
@action(detail=True, methods=['POST'])
def regenerate_secrets(self, request, **kwargs):
@@ -1986,9 +1998,12 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
if not invoice.file:
raise RetryException()
resp = FileResponse(invoice.file.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}.pdf"'.format(invoice.number)
return resp
return FileResponse(
invoice.file.file,
filename='{}.pdf'.format(invoice.number),
as_attachment=True,
content_type='application/pdf'
)
@action(detail=True, methods=['POST'])
def transmit(self, request, **kwargs):

View File

@@ -251,7 +251,7 @@ def create_connection(address, timeout=socket.getdefaulttimeout(),
for res in socket.getaddrinfo(host, port, 0, socket.SOCK_STREAM):
af, socktype, proto, canonname, sa = res
if not settings.get("MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
if not getattr(settings, "MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS", False):
ip_addr = ipaddress.ip_address(sa[0])
if ip_addr.is_multicast:
raise socket.error(f"Request to multicast address {sa[0]} blocked")

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

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", "0298_pluggable_permissions"),
]
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", "0299_add_reusablemedium_label"),
]
operations = [
# according to the docs, UPDATE FROM should run similarly on sqlite and postgres, but I could not get it to work
# so roll back the data migration with code before deleting data from through-table in 0297
migrations.RunPython(migrations.RunPython.noop, reverse),
migrations.RemoveField(
model_name="reusablemedium",
name="linked_orderposition",
),
# change related_name for new ManyToManyField to previously used linked_media
migrations.AlterField(
model_name="reusablemedium",
name="linked_orderpositions",
field=models.ManyToManyField(
related_name="linked_media", to="pretixbase.orderposition"
),
),
]

View File

@@ -72,6 +72,16 @@ class ReusableMedium(LoggedModel):
max_length=200,
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

@@ -58,6 +58,7 @@ from pretix.base.invoicing.transmission import (
from pretix.base.models import (
ExchangeRate, Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee,
)
from pretix.base.models.orders import OrderPayment
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.tasks import (
TransactionAwareProfiledEventTask, TransactionAwareTask,
@@ -102,7 +103,7 @@ def build_invoice(invoice: Invoice) -> Invoice:
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if lp and lp.payment_provider:
if lp and lp.payment_provider and lp.state not in (OrderPayment.PAYMENT_STATE_FAILED, OrderPayment.PAYMENT_STATE_CANCELED):
if 'payment' in inspect.signature(lp.payment_provider.render_invoice_text).parameters:
payment = str(lp.payment_provider.render_invoice_text(invoice.order, lp))
else:

View File

@@ -3514,8 +3514,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

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

View File

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

View File

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

View File

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

View File

@@ -763,12 +763,7 @@ class InvoicePreview(EventPermissionRequiredMixin, View):
def get(self, request, *args, **kwargs):
fname, ftype, fcontent = build_preview_invoice_pdf(request.event)
resp = HttpResponse(fcontent, content_type=ftype)
if settings.DEBUG:
# attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging
resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname)
resp._csp_ignore = True
else:
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(fname)
resp['Content-Disposition'] = 'inline; filename="{}"'.format(fname)
return resp

View File

@@ -300,5 +300,4 @@ class SysReportView(AdministratorPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data)
resp['Content-Type'] = mime
resp['Content-Disposition'] = 'inline; filename="{}"'.format(name)
resp._csp_ignore = True
return resp

View File

@@ -710,22 +710,26 @@ class OrderDownload(AsyncAction, OrderView):
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
return FileResponse(
value.file.file,
filename='{}-{}-{}-{}{}'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
),
content_type=value.type
)
return resp
elif isinstance(value, CachedCombinedTicket):
if value.type == 'text/uri-list':
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
return FileResponse(
value.file.file,
filename='{}-{}-{}{}'.format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
),
content_type=value.type
)
return resp
else:
return redirect(self.get_self_url())
@@ -1831,15 +1835,15 @@ class InvoiceDownload(EventPermissionRequiredMixin, View):
return redirect(self.get_order_url())
try:
resp = FileResponse(self.invoice.file.file, content_type='application/pdf')
return FileResponse(
self.invoice.file.file,
filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number)),
content_type='application/pdf'
)
except FileNotFoundError:
invoice_pdf_task.apply(args=(self.invoice.pk,))
return self.get(request, *args, **kwargs)
resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", self.invoice.number))
resp._csp_ignore = True # Some browser's PDF readers do not work with CSP
return resp
class OrderExtend(OrderView):
permission = 'event.orders:write'

View File

@@ -3388,8 +3388,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)
@@ -3437,10 +3439,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
@@ -3466,10 +3472,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

@@ -263,12 +263,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
resp = HttpResponse(data, content_type=mimet)
ftype = fname.split(".")[-1]
if settings.DEBUG:
# attachment is more secure as we're dealing with user-generated stuff here, but inline is much more convenient during debugging
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
resp._csp_ignore = True
else:
resp['Content-Disposition'] = 'attachment; filename="ticket-preview.{}"'.format(ftype)
resp['Content-Disposition'] = 'inline; filename="ticket-preview.{}"'.format(ftype)
return resp
elif "data" in request.POST:
if cf:
@@ -309,6 +304,5 @@ class FontsCSSView(TemplateView):
class PdfView(TemplateView):
def get(self, request, *args, **kwargs):
cf = get_object_or_404(CachedFile, id=kwargs.get("filename"), filename="background_preview.pdf")
resp = FileResponse(cf.file, content_type='application/pdf')
resp['Content-Disposition'] = 'attachment; filename="{}"'.format(cf.filename)
resp = FileResponse(cf.file, filename=cf.filename, content_type='application/pdf')
return resp

File diff suppressed because it is too large Load Diff

View File

@@ -216,7 +216,7 @@ class PayView(PaypalOrderView, TemplateView):
@scopes_disabled()
@event_permission_required('event.settings.general:write')
@event_permission_required('event.settings.payment:write')
def isu_return(request, *args, **kwargs):
getparams = ['merchantId', 'merchantIdInPayPal', 'permissionsGranted', 'accountStatus', 'consentStatus', 'productIntentID', 'isEmailConfirmed']
sessionparams = ['payment_paypal_isu_event', 'payment_paypal_isu_tracking_id']
@@ -526,7 +526,7 @@ def webhook(request, *args, **kwargs):
return HttpResponse(status=200)
@event_permission_required('event.settings.general:write')
@event_permission_required('event.settings.payment:write')
@require_POST
def isu_disconnect(request, **kwargs):
del request.event.settings.payment_paypal_connect_refresh_token

View File

@@ -91,6 +91,9 @@ event_patterns = [
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/cart/add',
csrf_exempt(pretix.presale.views.cart.CartAdd.as_view()),
name='event.cart.add'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/cart/create',
csrf_exempt(pretix.presale.views.cart.CartCreate.as_view()),
name='event.cart.create'),
re_path(r'unlock/(?P<hash>[a-z0-9]{64})/$', pretix.presale.views.user.UnlockHashView.as_view(),
name='event.payment.unlock'),

View File

@@ -555,6 +555,18 @@ class CartClear(EventViewMixin, CartActionMixin, AsyncAction, View):
request.sales_channel.identifier, time_machine_now(default=None))
@method_decorator(allow_cors_if_namespaced, 'dispatch')
class CartCreate(EventViewMixin, CartActionMixin, View):
def get(self, request, *args, **kwargs):
if 'ajax' in self.request.GET:
cart_id = get_or_create_cart_id(self.request, create=True)
return JsonResponse({
'cart_id': cart_id,
})
else:
return redirect_to_url(self.get_success_url())
@method_decorator(allow_frame_if_namespaced, 'dispatch')
class CartExtendReservation(EventViewMixin, CartActionMixin, AsyncAction, View):
task = extend_cart_reservation
@@ -843,9 +855,13 @@ class AnswerDownload(EventViewMixin, View):
return Http404()
ftype, _ = mimetypes.guess_type(answer.file.name)
resp = FileResponse(answer.file, content_type=ftype or 'application/binary')
resp['Content-Disposition'] = 'attachment; filename="{}-cart-{}"'.format(
filename = '{}-cart-{}'.format(
self.request.event.slug.upper(),
os.path.basename(answer.file.name).split('.', 1)[1]
).encode("ascii", "ignore")
)
resp = FileResponse(
answer.file,
filename=filename,
content_type=ftype or 'application/binary'
)
return resp

View File

@@ -1220,30 +1220,26 @@ class OrderDownloadMixin:
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
if self.order_position.subevent:
# Subevent date in filename improves accessibility e.g. for screen reader users
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.order_position.subevent.date_from.strftime('%Y_%m_%d'),
self.output.identifier, value.extension
)
else:
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.order_position.positionid,
self.output.identifier, value.extension
)
return resp
name_parts = (
self.request.event.slug.upper(),
self.order.code,
str(self.order_position.positionid),
self.order_position.subevent.date_from.strftime('%Y_%m_%d') if self.order_position.subevent else None,
self.output.identifier
)
filename = "-".join(filter(None, name_parts)) + value.extension
return FileResponse(value.file.file, filename=filename, content_type=value.type)
elif isinstance(value, CachedCombinedTicket):
if value.type == 'text/uri-list':
resp = HttpResponseRedirect(value.file.file.read())
return resp
else:
resp = FileResponse(value.file.file, content_type=value.type)
resp['Content-Disposition'] = 'attachment; filename="{}-{}-{}{}"'.format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension
return FileResponse(
value.file.file,
filename="{}-{}-{}{}".format(
self.request.event.slug.upper(), self.order.code, self.output.identifier, value.extension),
content_type=value.type
)
return resp
else:
return redirect(self.get_self_url())
@@ -1383,13 +1379,14 @@ class InvoiceDownload(EventViewMixin, OrderDetailMixin, View):
return redirect(self.get_order_url())
try:
resp = FileResponse(invoice.file.file, content_type='application/pdf')
return FileResponse(
invoice.file.file,
filename='{}.pdf'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", invoice.number)),
content_type='application/pdf'
)
except FileNotFoundError:
invoice_pdf_task.apply(args=(invoice.pk,))
return self.get(request, *args, **kwargs)
resp['Content-Disposition'] = 'inline; filename="{}.pdf"'.format(re.sub("[^a-zA-Z0-9-_.]+", "_", invoice.number))
resp._csp_ignore = True # Some browser's PDF readers do not work with CSP
return resp
class OrderChangeMixin:

View File

@@ -110,6 +110,10 @@ var setCookie = function (cname, cvalue, exdays) {
var d = new Date();
d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000));
var expires = "expires=" + d.toUTCString();
if (!cvalue) {
var expires = "expires=Thu, 01 Jan 1970 00:00:00 GMT";
cvalue = "";
}
document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
};
var getCookie = function (name) {
@@ -726,17 +730,16 @@ var shared_methods = {
buy_callback: function (data) {
if (data.redirect) {
if (data.cart_id) {
this.$root.cart_id = data.cart_id;
setCookie(this.$root.cookieName, data.cart_id, 30);
this.$root.set_cart_id(data.cart_id);
}
if (data.redirect.substr(0, 1) === '/') {
data.redirect = this.$root.target_url.replace(/^([^\/]+:\/\/[^\/]+)\/.*$/, "$1") + data.redirect;
}
var url = data.redirect;
if (url.indexOf('?')) {
url = url + '&iframe=1&locale=' + lang + '&take_cart_id=' + this.$root.cart_id;
url = url + '&iframe=1&locale=' + lang + '&take_cart_id=' + encodeURIComponent(this.$root.get_cart_id());
} else {
url = url + '?iframe=1&locale=' + lang + '&take_cart_id=' + this.$root.cart_id;
url = url + '?iframe=1&locale=' + lang + '&take_cart_id=' + encodeURIComponent(this.$root.get_cart_id());
}
url += this.$root.consent_parameter;
if (this.$root.additionalURLParams) {
@@ -779,15 +782,24 @@ var shared_methods = {
}
},
resume: function () {
if (!this.$root.get_cart_id() && this.$root.keep_cart) {
// create an empty cart whose id we can persist
this.$root.create_cart(this.resume)
return;
}
var redirect_url;
redirect_url = this.$root.target_url + 'w/' + widget_id + '/';
if (this.$root.subevent && !this.$root.cart_id) {
if (this.$root.subevent && this.$root.is_button && this.$root.items.length === 0) {
// button with subevent but no items
redirect_url += this.$root.subevent + '/';
}
redirect_url += '?iframe=1&locale=' + lang;
if (this.$root.cart_id) {
redirect_url += '&take_cart_id=' + this.$root.cart_id;
if (this.$root.get_cart_id()) {
redirect_url += '&take_cart_id=' + encodeURIComponent(this.$root.get_cart_id());
if (this.$root.keep_cart) {
// make sure the cart-id is used, even if the cart is currently empty
redirect_url += '&ajax=1'
}
}
if (this.$root.widget_data) {
redirect_url += '&widget_data=' + encodeURIComponent(this.$root.widget_data_json);
@@ -1864,12 +1876,11 @@ var shared_root_methods = {
if (this.$root.variation_filter) {
url += '&variations=' + encodeURIComponent(this.$root.variation_filter);
}
var cart_id = getCookie(this.cookieName);
if (this.$root.voucher_code) {
url += '&voucher=' + encodeURIComponent(this.$root.voucher_code);
}
if (cart_id) {
url += "&cart_id=" + encodeURIComponent(cart_id);
if (this.$root.get_cart_id()) {
url += "&cart_id=" + encodeURIComponent(this.$root.get_cart_id());
}
if (this.$root.date !== null) {
url += "&date=" + this.$root.date.substr(0, 7);
@@ -1939,7 +1950,6 @@ var shared_root_methods = {
root.display_add_to_cart = data.display_add_to_cart;
root.waiting_list_enabled = data.waiting_list_enabled;
root.show_variations_expanded = data.show_variations_expanded || !!root.variation_filter;
root.cart_id = cart_id;
root.cart_exists = data.cart_exists;
root.vouchers_exist = data.vouchers_exist;
root.has_seating_plan = data.has_seating_plan;
@@ -2004,8 +2014,8 @@ var shared_root_methods = {
if (this.$root.voucher_code) {
redirect_url += '&voucher=' + encodeURIComponent(this.$root.voucher_code);
}
if (this.$root.cart_id) {
redirect_url += '&take_cart_id=' + this.$root.cart_id;
if (this.$root.get_cart_id()) {
redirect_url += '&take_cart_id=' + encodeURIComponent(this.$root.get_cart_id());
}
if (this.$root.widget_data) {
redirect_url += '&widget_data=' + encodeURIComponent(this.$root.widget_data_json);
@@ -2027,7 +2037,28 @@ var shared_root_methods = {
this.$root.subevent = event.subevent;
this.$root.loading++;
this.$root.reload();
}
},
create_cart: function(callback) {
var url = this.$root.target_url + 'w/' + widget_id + '/cart/create?ajax=1';
this.$root.overlay.frame_loading = true;
api._getJSON(url, (data) => {
this.$root.set_cart_id(data.cart_id);
this.$root.overlay.frame_loading = false;
callback()
}, () => {
this.$root.overlay.error_message = strings['cart_error'];
this.$root.overlay.frame_loading = false;
})
},
get_cart_id: function() {
if (this.$root.keep_cart) {
return getCookie(this.$root.cookieName);
}
},
set_cart_id: function(newValue) {
setCookie(this.$root.cookieName, newValue, 30);
},
};
var shared_root_computed = {
@@ -2049,9 +2080,8 @@ var shared_root_computed = {
},
voucherFormTarget: function () {
var form_target = this.target_url + 'w/' + widget_id + '/redeem?iframe=1&locale=' + lang;
var cookie = getCookie(this.cookieName);
if (cookie) {
form_target += "&take_cart_id=" + cookie;
if (this.get_cart_id()) {
form_target += "&take_cart_id=" + encodeURIComponent(this.get_cart_id());
}
if (this.subevent) {
form_target += "&subevent=" + this.subevent;
@@ -2091,9 +2121,8 @@ var shared_root_computed = {
checkout_url += '?' + this.$root.additionalURLParams;
}
var form_target = this.target_url + 'w/' + widget_id + '/cart/add?iframe=1&next=' + encodeURIComponent(checkout_url);
var cookie = getCookie(this.cookieName);
if (cookie) {
form_target += "&take_cart_id=" + cookie;
if (this.get_cart_id()) {
form_target += "&take_cart_id=" + encodeURIComponent(this.get_cart_id());
}
form_target += this.$root.consent_parameter
return form_target
@@ -2329,6 +2358,7 @@ var create_widget = function (element, html_id=null) {
has_seating_plan: false,
has_seating_plan_waitinglist: false,
meta_filter_fields: [],
keep_cart: true,
}
},
created: function () {
@@ -2366,6 +2396,7 @@ var create_button = function (element, html_id=null) {
var raw_items = element.attributes.items ? element.attributes.items.value : "";
var skip_ssl = element.attributes["skip-ssl-check"] ? true : false;
var disable_iframe = element.attributes["disable-iframe"] ? true : false;
var keep_cart = element.attributes["keep-cart"] ? true : false;
var button_text = element.innerHTML;
var widget_data = JSON.parse(JSON.stringify(window.PretixWidget.widget_data));
for (var i = 0; i < element.attributes.length; i++) {
@@ -2417,7 +2448,8 @@ var create_button = function (element, html_id=null) {
widget_data: widget_data,
widget_id: 'pretix-widget-' + widget_id,
html_id: html_id,
button_text: button_text
button_text: button_text,
keep_cart: keep_cart || items.length > 0,
}
},
created: function () {
@@ -2426,7 +2458,7 @@ var create_button = function (element, html_id=null) {
observer.observe(this.$el, observerOptions);
},
computed: shared_root_computed,
methods: shared_root_methods
methods: shared_root_methods,
});
create_overlay(app);
return app;
@@ -2492,13 +2524,14 @@ window.PretixWidget.open = function (target_url, voucher, subevent, items, widge
frame_dismissed: false,
widget_data: all_widget_data,
widget_id: 'pretix-widget-' + widget_id,
button_text: ""
button_text: "",
keep_cart: true
}
},
created: function () {
},
computed: shared_root_computed,
methods: shared_root_methods
methods: shared_root_methods,
});
create_overlay(app);
app.$nextTick(function () {

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

@@ -171,6 +171,35 @@ def test_giftcard_detail_expand(token_client, organizer, event, giftcard):
}
@pytest.mark.django_db
def test_giftcard_detail_expand_without_permissions(team, token_client, organizer, event, giftcard):
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"))
giftcard.owner_ticket = op
giftcard.save()
team.all_event_permissions = False
team.save()
res = dict(TEST_GC_RES)
res["id"] = giftcard.pk
res["issuance"] = giftcard.issuance.isoformat().replace('+00:00', 'Z')
resp = token_client.get('/api/v1/organizers/{}/giftcards/{}/?expand=owner_ticket'.format(organizer.slug, giftcard.pk))
assert resp.status_code == 200
assert resp.data["owner_ticket"] == {
"id": op.pk,
}
TEST_GIFTCARD_CREATE_PAYLOAD = {
"secret": "DEFABC",
"value": "12.00",

View File

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

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": {},
@@ -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()
@@ -252,6 +255,76 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome
}
@pytest.mark.django_db
def test_medium_detail_event_permission_missing(token_client, organizer, event, medium, giftcard, customer, team):
team.all_organizer_permissions = False
team.limit_organizer_permissions = {
"organizer.reusablemedia:read": True,
"organizer.customers:read": True,
"organizer.giftcards:read": True,
}
team.all_event_permissions = False
team.save()
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"))
medium.linked_orderpositions.add(op)
medium.linked_giftcard = giftcard
medium.customer = customer
medium.save()
giftcard.owner_ticket = op
giftcard.save()
resp = token_client.get(
'/api/v1/organizers/{}/reusablemedia/{}/?expand=linked_giftcard&expand='
'linked_giftcard.owner_ticket&expand=linked_orderposition&expand=customer'.format(
organizer.slug, medium.pk
)
)
assert resp.status_code == 200
assert resp.data["linked_orderposition"] == {
"id": op.pk,
}
assert resp.data["linked_giftcard"] == {
"id": giftcard.pk,
"secret": "ABCDEF",
"issuance": giftcard.issuance.isoformat().replace("+00:00", "Z"),
"value": "23.00",
"currency": "EUR",
"testmode": False,
"expires": None,
"conditions": None,
"owner_ticket": {"id": op.pk},
"issuer": "dummy",
}
assert resp.data["customer"] == {
"identifier": customer.identifier,
"external_identifier": None,
"email": "foo@example.org",
"phone": None,
"name": "Foo",
"name_parts": {"_legacy": "Foo"},
"is_active": True,
"is_verified": False,
"last_login": None,
"date_joined": customer.date_joined.isoformat().replace("+00:00", "Z"),
"locale": "en",
"last_modified": customer.last_modified.isoformat().replace("+00:00", "Z"),
"notes": None
}
TEST_MEDIUM_CREATE_PAYLOAD = {
"type": "barcode",
"identifier": "FOOBAR",
@@ -282,6 +355,90 @@ 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, organizer2, 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"))
payload = dict(TEST_MEDIUM_CREATE_PAYLOAD)
# wrong organizer for orderposition
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer2.slug),
payload,
format='json'
)
assert resp.status_code == 403
# 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, op2.pk]
resp = token_client.post(
'/api/v1/organizers/{}/reusablemedia/'.format(organizer.slug),
payload,
format='json'
)
assert resp.status_code == 400
# multiple linked_orderpositions
del payload['linked_orderposition']
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)
@@ -328,6 +485,53 @@ 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]
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]
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):
@@ -468,7 +672,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()

View File

@@ -610,7 +610,7 @@ PRIVATE_IPS_RES = [
@contextmanager
def test_mail_connection(res, should_connect, use_ssl):
def assert_mail_connection(res, should_connect, use_ssl):
with (
mock.patch('socket.socket') as mock_socket,
mock.patch('socket.getaddrinfo', return_value=res),
@@ -638,14 +638,14 @@ def test_mail_connection(res, should_connect, use_ssl):
def test_private_smtp_ip(res, use_ssl, settings):
settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend'
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = False
with test_mail_connection(res=res, should_connect=False, use_ssl=use_ssl), pytest.raises(match="Request to .* blocked"):
with assert_mail_connection(res=res, should_connect=False, use_ssl=use_ssl), pytest.raises(match="Request to .* blocked"):
connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host="localhost",
use_ssl=use_ssl)
connection.open()
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = True
with test_mail_connection(res=res, should_connect=True, use_ssl=use_ssl):
with assert_mail_connection(res=res, should_connect=True, use_ssl=use_ssl):
connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host="localhost",
use_ssl=use_ssl)
@@ -662,7 +662,7 @@ def test_public_smtp_ip(use_ssl, allow_private, settings):
settings.EMAIL_CUSTOM_SMTP_BACKEND = 'pretix.base.email.CheckPrivateNetworkSmtpBackend'
settings.MAIL_CUSTOM_SMTP_ALLOW_PRIVATE_NETWORKS = allow_private
with test_mail_connection(res=[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('8.8.8.8', 443))], should_connect=True, use_ssl=use_ssl):
with assert_mail_connection(res=[(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('8.8.8.8', 443))], should_connect=True, use_ssl=use_ssl):
connection = djmail.get_connection(backend=settings.EMAIL_CUSTOM_SMTP_BACKEND,
host="localhost",
use_ssl=use_ssl)
@@ -702,7 +702,7 @@ def test_send_mail_private_ip(res, use_ssl, allow_private_networks, env):
m.refresh_from_db()
return m
with test_mail_connection(res=res, should_connect=allow_private_networks, use_ssl=use_ssl):
with assert_mail_connection(res=res, should_connect=allow_private_networks, use_ssl=use_ssl):
m = send_mail()
if allow_private_networks:
assert m.status == OutgoingMail.STATUS_SENT