From 81f58456e5b6a4823d7f01e2b7890084b5200fc3 Mon Sep 17 00:00:00 2001 From: Richard Schreiber Date: Fri, 7 Aug 2026 11:03:36 +0200 Subject: [PATCH 1/6] Fix API-docs example for addon_to on order-change (#6451) * Fix API-docs example for addon_to on order-change * Update orders.rst --- doc/api/resources/orders.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index f14fd1b1b8..2ab607908e 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -2038,7 +2038,7 @@ Manipulating individual positions * ``order`` (mandatory, specified as a string mapping to a ``code``) - * ``addon_to`` (optional, specified as an integer mapping to the ``positionid`` of the parent position) + * ``addon_to`` (optional, specified as an integer mapping to ``positionid`` - the number of the position within the order, see :ref:`_order-position-resource` - of the parent position) * ``item`` (mandatory) @@ -2348,7 +2348,7 @@ otherwise, such as splitting an order or changing fees. "subevent": 562, "seat": "seat-guid-2", "price": "99.99", - "addon_to": 12374, + "addon_to": 1, "attendee_name": "Peter", } ], From 958f75b109fef69fc503e805cca25e186342909d Mon Sep 17 00:00:00 2001 From: luelista Date: Fri, 7 Aug 2026 14:36:46 +0200 Subject: [PATCH 2/6] Add tests to prevent reintroducing CSP nonces (Z#23240534) (#6409) As discussed in PR #6387 --- src/tests/control/test_views.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index f169df7024..1560affe12 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -224,3 +224,28 @@ def test_one_view(logged_in_client, url, expected, event, item, item_category, o ) response = logged_in_client.get(url) assert response.status_code == expected + + # Do not reintroduce any CSP nonces into control responses, as discussed in PR #6387 + if response['Content-Type'] != 'application/json': + assert 'script-src' in response['Content-Security-Policy'] + assert 'nonce-' not in response['Content-Security-Policy'] + + +@pytest.mark.parametrize('url', [ + '/control/login', + '/', + '/{orga}/{event}/', +]) +@pytest.mark.django_db +def test_csp_header_unauthenticated(client, url, event): + # Do not reintroduce any CSP nonces into most presale responses, as discussed in PR #6387 + with scope(organizer=event.organizer): + url = url.format( + event=event.slug, orga=event.organizer.slug, + ) + event.live = True + event.save() + response = client.get(url) + assert response.status_code == 200 + assert 'script-src' in response['Content-Security-Policy'] + assert 'nonce-' not in response['Content-Security-Policy'] From 4a286896906776a335a67d57e0d3ed86c014e7fc Mon Sep 17 00:00:00 2001 From: Lukas Bockstaller Date: Fri, 7 Aug 2026 16:34:36 +0200 Subject: [PATCH 3/6] log paypal payment durations (#6461) * log payment processing durations * remove log noise * fix attribute access * remove debugging import --- src/pretix/plugins/paypal2/payment.py | 19 +++++++++++++++++-- src/pretix/plugins/paypal2/views.py | 1 + 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/pretix/plugins/paypal2/payment.py b/src/pretix/plugins/paypal2/payment.py index 84f3edf605..31634ce004 100644 --- a/src/pretix/plugins/paypal2/payment.py +++ b/src/pretix/plugins/paypal2/payment.py @@ -23,7 +23,7 @@ import json import logging import urllib.parse from collections import OrderedDict -from datetime import timedelta +from datetime import datetime, timedelta from decimal import Decimal from django import forms @@ -645,7 +645,7 @@ class PaypalMethod(BasePaymentProvider): def _execute_payment(self, request: HttpRequest, payment: OrderPayment): payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk) if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED: - logger.warning('payment is already confirmed; possible return-view/webhook race-condition') + # payment is already confirmed; possible return-view/webhook race-condition return try: @@ -832,6 +832,7 @@ class PaypalMethod(BasePaymentProvider): payment.info = json.dumps(pp_captured_order.dict()) payment.save(update_fields=['info']) payment.confirm() + self.log_payment_duration(payment) except Quota.QuotaExceededException as e: raise PaymentException(str(e)) # Payment has not any captures yet - so it's probably in created status @@ -841,6 +842,20 @@ class PaypalMethod(BasePaymentProvider): if 'payment_paypal_oid' in request.session: del request.session['payment_paypal_oid'] + @staticmethod + def log_payment_duration(payment: OrderPayment): + try: + capture = payment.info_data["purchase_units"][0]["payments"]["captures"][0] + create_time: str | None = capture["create_time"] + update_time: str | None = capture["update_time"] + except (KeyError, IndexError, TypeError): + create_time = None + update_time = None + + if create_time is not None and update_time is not None: + duration = datetime.fromisoformat(update_time) - datetime.fromisoformat(create_time) + logger.info('{}: {} - paypal payment processing time'.format(str(payment.global_id), str(duration))) + def payment_pending_render(self, request, payment) -> str: retry = True try: diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py index 37340e22a4..7a125e4593 100644 --- a/src/pretix/plugins/paypal2/views.py +++ b/src/pretix/plugins/paypal2/views.py @@ -490,6 +490,7 @@ def webhook(request, *args, **kwargs): payment.info = json.dumps(sale.dict()) payment.save(update_fields=['info']) payment.confirm() + prov.log_payment_duration(payment) except Quota.QuotaExceededException: pass elif sale['status'] == 'APPROVED': From d08216d8c5fddad9ddded21e21cc9271dc8f8146 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 7 Aug 2026 18:59:01 +0200 Subject: [PATCH 4/6] API: Allow to simulate check-ins (#6360) * API: Allow to simulate check-ins * Add missing file --- doc/api/resources/checkin.rst | 2 ++ src/pretix/api/serializers/checkin.py | 1 + src/pretix/api/views/checkin.py | 6 ++++ src/pretix/base/services/checkin.py | 7 +++-- src/pretix/helpers/database.py | 9 ++++++ src/pretix/testutils/db.py | 25 +++++++++++++++++ src/tests/api/test_checkinrpc.py | 40 +++++++++++++++++++++++++++ 7 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 src/pretix/testutils/db.py diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst index c4571ade22..cb3fc8e04c 100644 --- a/doc/api/resources/checkin.rst +++ b/doc/api/resources/checkin.rst @@ -71,6 +71,8 @@ Checking a ticket in :>json object questions: List of questions to be answered for check-in, only set on status ``"incomplete"``. :>json object media_policy: Reusable media policy (see documentation on items), only set on status ``"exchange"``. :>json object media_type: Reusable media type (see documentation on items), only set on status ``"exchange"``. + :>json boolean simulate: Do not actually perform the check-in, only simulate the response. The ``position`` response + object will not reflect the simulated changes. **Example request**: diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index db716a3154..62e095c3f9 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -90,6 +90,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer): answers = serializers.JSONField(required=False, allow_null=True) exchange_medium_type = serializers.ChoiceField(required=False, choices=MEDIA_TYPES) exchange_medium_identifier = serializers.CharField(required=False) + simulate = serializers.BooleanField(default=False, required=False) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 1eb05b4940..87477dd82c 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -839,6 +839,11 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, ) if exchange_medium_identifier: # other fields are filled, see CheckinRPCRedeemInputSerializer.validate + if simulate: + raise CheckInError( + gettext('You cannot simulate a medium exchange.'), + 'error' + ) with transaction.atomic(): # Do exchange and check-in atomically, i.e. both succeed or both fail medium = perform_media_exchange( @@ -1066,6 +1071,7 @@ class CheckinRPCRedeemView(views.APIView): legacy_url_support=False, exchange_medium_type=s.validated_data.get('exchange_medium_type'), exchange_medium_identifier=s.validated_data.get('exchange_medium_identifier'), + simulate=s.validated_data.get('simulate'), ) diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 3ac7b6792b..039f438d47 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -40,7 +40,7 @@ import dateutil import dateutil.parser from dateutil.tz import datetime_exists from django.core.files import File -from django.db import IntegrityError, transaction +from django.db import IntegrityError from django.db.models import ( BooleanField, Case, Count, ExpressionWrapper, F, IntegerField, Max, Min, OuterRef, Q, Subquery, TextField, Value, When, @@ -59,6 +59,7 @@ from pretix.base.models import ( ) from pretix.base.signals import checkin_created, periodic_task from pretix.helpers import OF_SELF +from pretix.helpers.database import conditional_atomic from pretix.helpers.jsonlogic import Logic from pretix.helpers.jsonlogic_boolalg import convert_to_dnf from pretix.helpers.jsonlogic_query import ( @@ -1043,10 +1044,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, if not simulate: _save_answers(op, answers, given_answers) - with transaction.atomic(): + with conditional_atomic(not simulate): # Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic opqs = OrderPosition.all.select_related("order", "item") - if type != Checkin.TYPE_EXIT: + if type != Checkin.TYPE_EXIT and not simulate: opqs = opqs.select_for_update(of=OF_SELF) op = opqs.get(pk=op.pk) diff --git a/src/pretix/helpers/database.py b/src/pretix/helpers/database.py index 06360cb051..931ba35ee8 100644 --- a/src/pretix/helpers/database.py +++ b/src/pretix/helpers/database.py @@ -288,6 +288,15 @@ def get_deterministic_ordering(model, ordering): return ordering +@contextlib.contextmanager +def conditional_atomic(do_atomic, **kwargs): + if do_atomic: + with transaction.atomic(**kwargs): + yield + else: + yield + + class IgnoreOnSQLiteMixin: # Mixin to allow defining PostgreSQL-specific indexes that will just not be created # on SQLite. SQLite is supported for testing only anyways! diff --git a/src/pretix/testutils/db.py b/src/pretix/testutils/db.py new file mode 100644 index 0000000000..50692a48d1 --- /dev/null +++ b/src/pretix/testutils/db.py @@ -0,0 +1,25 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +def readonly_db(execute, sql, params, many, context): + if not sql.lower().startswith("select"): + raise Exception(f"Should not write anything to the database, but detected query: {sql}") + return execute(sql, params, many, context) diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 6ea5066cb4..da27c78adf 100644 --- a/src/tests/api/test_checkinrpc.py +++ b/src/tests/api/test_checkinrpc.py @@ -25,6 +25,7 @@ from unittest import mock import pytest from django.core.files.base import ContentFile +from django.db import connection from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scopes_disabled @@ -36,6 +37,7 @@ from pretix.api.serializers.item import QuestionSerializer from pretix.base.models import ( Checkin, InvoiceAddress, Item, Order, OrderPosition, ReusableMedium, ) +from pretix.testutils.db import readonly_db # Lots of this code is overlapping with test_checkin.py, and some of it is arguably redundant since it's triggering # the same backend code paths (for now). However, this is SUCH a critical part of pretix that we don't want to take @@ -1739,3 +1741,41 @@ def test_exchange_create_gift_card(token_client, organizer, clist, event, order, with scopes_disabled(): rm = ReusableMedium.objects.get(identifier="0412345") assert rm.linked_giftcard.currency == "EUR" + + +@pytest.mark.django_db +def test_simulate(token_client, organizer, clist, event, order): + with scopes_disabled(): + p = order.positions.first() + with connection.execute_wrapper(readonly_db): + resp = _redeem(token_client, organizer, clist, p.secret, {"simulate": True}) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + assert not p.checkins.exists() + + +@pytest.mark.django_db +def test_simulate_no_exchange(token_client, organizer, clist, event, order, item): + organizer.settings.reusable_media_type_nfc_uid = True + item.media_type = "nfc_uid" + item.media_policy = Item.MEDIA_POLICY_NEW + item.save() + with scopes_disabled(): + rm = ReusableMedium.objects.create( + type="nfc_uid", + identifier="12345678", + organizer=organizer, + ) + with connection.execute_wrapper(readonly_db): + resp = _redeem(token_client, organizer, clist, "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", { + "source_type": "barcode", + "exchange_medium_type": "nfc_uid", + "exchange_medium_identifier": "12345678", + "simulate": True, + }) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + assert resp.data['reason'] == 'error' + with scopes_disabled(): + assert not rm.linked_orderpositions.exists() From 5cf28f1b815de5f836b7549ca222b72fa3e53fd3 Mon Sep 17 00:00:00 2001 From: robbi5 Date: Mon, 10 Aug 2026 15:58:15 +0200 Subject: [PATCH 5/6] Fix typo in devicesecurity blocking checkinrpc.annul (#6467) --- src/pretix/api/auth/devicesecurity.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index b959c0a7dc..8ef6d02181 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -115,7 +115,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:event.settings'), ('POST', 'api-v1:upload'), ('POST', 'api-v1:checkinrpc.redeem'), - ('POST', 'api-v1:checkinrpc.annull'), + ('POST', 'api-v1:checkinrpc.annul'), ('GET', 'api-v1:checkinrpc.search'), ('GET', 'api-v1:reusablemedium-list'), ('POST', 'api-v1:reusablemedium-lookup'), @@ -154,7 +154,7 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:event.settings'), ('POST', 'api-v1:upload'), ('POST', 'api-v1:checkinrpc.redeem'), - ('POST', 'api-v1:checkinrpc.annull'), + ('POST', 'api-v1:checkinrpc.annul'), ('GET', 'api-v1:checkinrpc.search'), ) @@ -191,7 +191,7 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:event.settings'), ('POST', 'api-v1:upload'), ('POST', 'api-v1:checkinrpc.redeem'), - ('POST', 'api-v1:checkinrpc.annull'), + ('POST', 'api-v1:checkinrpc.annul'), ('GET', 'api-v1:checkinrpc.search'), ) From f25c233e919b8fb3a8ddf88a4e387bd9aebd58a9 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 10 Aug 2026 16:13:54 +0200 Subject: [PATCH 6/6] Fix performance issues in download reminder (#6393) * Fix performance issues in download reminder * Update src/pretix/base/services/orders.py Co-authored-by: Richard Schreiber * Fixes after review * Fix check in loop --------- Co-authored-by: Richard Schreiber --- src/pretix/base/services/orders.py | 163 ++++++++++++++++------------- src/pretix/base/settings.py | 2 +- 2 files changed, 93 insertions(+), 72 deletions(-) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 23b21c9be1..17a66924b2 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -48,12 +48,12 @@ from celery.exceptions import MaxRetriesExceededError from django.conf import settings from django.core.cache import cache from django.core.exceptions import ValidationError -from django.db import transaction +from django.db import models, transaction from django.db.models import ( - Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum, - Value, + Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Subquery, + Sum, Value, ) -from django.db.models.functions import Coalesce, Greatest +from django.db.models.functions import Cast, Greatest from django.db.transaction import get_connection from django.dispatch import receiver from django.utils.functional import cached_property @@ -71,7 +71,7 @@ from pretix.base.models import ( Membership, Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher, ) -from pretix.base.models.event import SubEvent +from pretix.base.models.event import Event_SettingsStore, SubEvent from pretix.base.models.orders import ( BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund, generate_secret, @@ -1494,83 +1494,104 @@ def send_expiry_warnings(sender, **kwargs): @scopes_disabled() def send_download_reminders(sender, **kwargs): today = now().replace(hour=0, minute=0, second=0, microsecond=0) - qs = Order.objects.annotate( - first_date=Coalesce( - Min('all_positions__subevent__date_from'), - F('event__date_from') + + events = Event.objects.filter( + Q(has_subevents=False, date_from__gte=now()) | + (Q(has_subevents=True) & Q(Exists( + SubEvent.objects.filter(event_id=OuterRef('id'), date_from__gte=now()) + ))) + ).annotate( + reminder_days=Subquery( + Event_SettingsStore.objects.filter( + object=OuterRef('id'), + key='mail_days_download_reminder' + ).exclude( + value="None" + ).annotate( + val=Cast(F("value"), output_field=models.IntegerField()), + ).values("val") ) ).filter( - download_reminder_sent=False, - datetime__lte=now() - timedelta(hours=2), - first_date__gte=today, - ).only( - 'pk', 'event_id', 'sales_channel', 'datetime', - ).order_by('event_id') - event_id = None - days = None - event = None + reminder_days__isnull=False, + ).order_by() - for o in qs: - if o.event_id != event_id: - days = o.event.settings.get('mail_days_download_reminder', as_type=int) - event = o.event - event_id = o.event_id + for event in events.iterator(chunk_size=10_000): + qs = event.orders.filter( + download_reminder_sent=False, + datetime__lte=now() - timedelta(hours=2), + ) - if days is None: - continue - - if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder: - continue - - reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0) - if now() < reminder_date or o.datetime > reminder_date: - continue - - with transaction.atomic(): - o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk) - if o.download_reminder_sent: - # Race condition - continue - positions = list(o.positions_with_tickets) - if not positions: + if event.has_subevents: + qs = qs.annotate( + first_date=Min('all_positions__subevent__date_from') + ).filter( + Q(first_date__gte=today) + ) + else: + event_reminder_date = (event.date_from - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0) + if now() < event_reminder_date: continue - if not o.ticket_download_available: + qs = qs.only( + 'pk', 'event_id', 'sales_channel', 'datetime', + ).order_by() + + for o in qs: + if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder: continue - if o.status != Order.STATUS_PAID: - if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending): + if event.has_subevents: + reminder_date = ((o.first_date or event.date_from) - timedelta(days=event.reminder_days)).replace(hour=0, minute=0, second=0, microsecond=0) + else: + reminder_date = event_reminder_date + if now() < reminder_date or o.datetime > reminder_date: + continue + + with transaction.atomic(): + o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk) + if o.download_reminder_sent: + # Race condition + continue + positions = list(o.positions_with_tickets) + if not positions: continue - with language(o.locale, o.event.settings.region): - o.download_reminder_sent = True - o.save(update_fields=['download_reminder_sent']) - email_template = event.settings.mail_text_download_reminder - email_subject = event.settings.mail_subject_download_reminder - email_context = get_email_context(event=event, order=o) - o.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.download_reminder_sent', - attach_tickets=True - ) + if not o.ticket_download_available: + continue - if event.settings.mail_send_download_reminder_attendee: - for p in positions: - if p.subevent_id: - reminder_date = (p.subevent.date_from - timedelta(days=days)).replace( - hour=0, minute=0, second=0, microsecond=0 - ) - if now() < reminder_date: - continue - if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email: - email_template = event.settings.mail_text_download_reminder_attendee - email_subject = event.settings.mail_subject_download_reminder_attendee - email_context = get_email_context(event=event, order=o, position=p) - o.send_mail( - email_subject, email_template, email_context, - 'pretix.event.order.email.download_reminder_sent', - attach_tickets=True, position=p - ) + if o.status != Order.STATUS_PAID: + if o.status != Order.STATUS_PENDING or o.require_approval or (not o.valid_if_pending and not o.event.settings.ticket_download_pending): + continue + + with language(o.locale, o.event.settings.region): + o.download_reminder_sent = True + o.save(update_fields=['download_reminder_sent']) + email_template = event.settings.mail_text_download_reminder + email_subject = event.settings.mail_subject_download_reminder + email_context = get_email_context(event=event, order=o) + o.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.download_reminder_sent', + attach_tickets=True + ) + + if event.settings.mail_send_download_reminder_attendee: + for p in positions: + if p.subevent_id: + reminder_date = (p.subevent.date_from - timedelta(days=event.reminder_days)).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + if now() < reminder_date: + continue + if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email: + email_template = event.settings.mail_text_download_reminder_attendee + email_subject = event.settings.mail_subject_download_reminder_attendee + email_context = get_email_context(event=event, order=o, position=p) + o.send_mail( + email_subject, email_template, email_context, + 'pretix.event.order.email.download_reminder_sent', + attach_tickets=True, position=p + ) def notify_user_changed_order(order, user=None, auth=None, invoices=[]): diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index ae679d186c..bdf8813598 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -2930,7 +2930,7 @@ Your {event} team""")) # noqa: W291 }, 'mail_days_download_reminder': { 'type': int, - 'default': None + 'default': None # when this default is changed, send_download_reminders needs to change }, 'mail_send_download_reminder_attendee': { 'type': bool,