From d14dc4c5ff1ff847d559d65e5a2396edd1f442df Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 22 Jun 2026 09:49:27 +0200 Subject: [PATCH] Test order deletion: Improve bulk performance (Z#23237160) (#6274) * Test order deletion: Improve bulk performance (Z#23237160) * Apply suggestion from @pajowu Co-authored-by: pajowu * Fix style issue --------- Co-authored-by: pajowu --- src/pretix/base/models/orders.py | 78 +++++++++++++++++++----------- src/pretix/control/views/event.py | 7 ++- src/pretix/control/views/orders.py | 21 ++++++-- 3 files changed, 72 insertions(+), 34 deletions(-) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index e74e450e8c..305659ccde 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -354,38 +354,60 @@ class Order(LockModel, LoggedModel): def _transaction_key_reset(self): self.__initial_status_paid_or_pending = self.status in (Order.STATUS_PENDING, Order.STATUS_PAID) and not self.require_approval - def gracefully_delete(self, user=None, auth=None): - from . import GiftCard, GiftCardTransaction, Membership, Voucher - - if not self.testmode: - raise TypeError("Only test mode orders can be deleted.") - self.log_action( - 'pretix.event.order.deleted', user=user, auth=auth, - data={ - 'code': self.code, - } + @classmethod + def gracefully_delete_bulk(cls, event, orders, user=None, auth=None): + # Expects to be called in a transaction + from . import ( + GiftCard, GiftCardTransaction, LogEntry, Membership, Voucher, ) - order_gracefully_delete.send(self.event, order=self) + if not transaction.get_connection().in_atomic_block: + raise Exception('gracefully_delete_bulk should only be called in atomic transaction!') - if self.status != Order.STATUS_CANCELED: - for position in self.positions.all(): - if position.voucher: - Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) + logs_create = [] + for o in orders: + if not o.testmode: + raise TypeError("Only test mode orders can be deleted.") + order_gracefully_delete.send(event, order=o) + logs_create.append(o.log_action( + 'pretix.event.order.deleted', user=user, auth=auth, + data={ + 'code': o.code, + }, + save=False, + )) + LogEntry.bulk_create_and_postprocess(logs_create) - GiftCardTransaction.objects.filter(payment__in=self.payments.all()).update(payment=None) - GiftCardTransaction.objects.filter(refund__in=self.refunds.all()).update(refund=None) - GiftCardTransaction.objects.filter(order=self).update(order=None) - GiftCard.objects.filter(issued_in__in=self.positions.all()).update(issued_in=None) - Membership.objects.filter(granted_in__order=self, testmode=True).update(granted_in=None) - OrderPosition.all.filter(order=self, addon_to__isnull=False).delete() - OrderPosition.all.filter(order=self).delete() - OrderFee.all.filter(order=self).delete() - Transaction.objects.filter(order=self).delete() - self.refunds.all().delete() - self.payments.all().delete() - self.event.cache.delete('complain_testmode_orders') - self.delete() + voucher_ids = OrderPosition.objects.filter( + order__in=orders, + voucher__isnull=False + ).exclude(order__status=Order.STATUS_CANCELED).values_list("voucher_id", flat=True) + voucher_usages = Counter(voucher_ids) + for v_id, usage_count in voucher_usages.items(): + Voucher.objects.filter(pk=v_id).update(redeemed=Greatest(0, F('redeemed') - usage_count)) + + GiftCardTransaction.objects.filter(payment__order__in=orders).update(payment=None) + GiftCardTransaction.objects.filter(refund__order__in=orders).update(refund=None) + GiftCardTransaction.objects.filter(order__in=orders).update(order=None) + GiftCard.objects.filter(issued_in__order__in=orders).update(issued_in=None) + Membership.objects.filter(granted_in__order__in=orders, testmode=True).update(granted_in=None) + OrderPosition.all.filter(order__in=orders, addon_to__isnull=False).delete() + OrderPosition.all.filter(order__in=orders).delete() + OrderFee.all.filter(order__in=orders).delete() + Transaction.objects.filter(order__in=orders).delete() + OrderRefund.objects.filter(order__in=orders).delete() + OrderPayment.objects.filter(order__in=orders).delete() + if isinstance(orders, models.QuerySet): + orders.delete() + else: + Order.objects.filter(pk__in=[o.pk for o in orders]).delete() + event.cache.delete('complain_testmode_orders') + + def gracefully_delete(self, user=None, auth=None): + if not self.testmode: + raise TypeError("Only test mode orders can be deleted.") + + Order.gracefully_delete_bulk(self.event, Order.objects.filter(pk=self.pk), user, auth) def email_confirm_secret(self): return self.tagged_secret("email_confirm", 9) diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 6949f76759..322c0761c0 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -1150,8 +1150,11 @@ class EventLive(EventPermissionRequiredMixin, TemplateView): if request.POST.get("delete") == "yes": try: with transaction.atomic(): - for order in request.event.orders.filter(testmode=True): - order.gracefully_delete(user=self.request.user) + Order.gracefully_delete_bulk( + request.event, + request.event.orders.filter(testmode=True), + user=self.request.user + ) except ProtectedError: messages.error(self.request, _('An order could not be deleted as some constraints (e.g. data ' 'created by plug-ins) do not allow it.')) diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 1ac565b876..53b628ed58 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -139,6 +139,7 @@ from pretix.helpers import OF_SELF from pretix.helpers.compat import CompatDeleteView from pretix.helpers.format import SafeFormatter, format_map from pretix.helpers.hierarkey import clean_filename +from pretix.helpers.iter import chunked_iterable from pretix.helpers.json import CustomJSONEncoder from pretix.helpers.safedownload import check_token from pretix.presale.signals import question_form_fields @@ -240,7 +241,7 @@ class BaseOrderBulkActionView(OrderSearchMixin, EventPermissionRequiredMixin, As raise NotImplementedError() def execute_bulk(self, queryset: QuerySet, form: forms.Form): - qs = self.allowed_for(self.allowed_for(self.get_queryset())) + qs = self.allowed_for(self.get_queryset()) total = qs.count() orders_with_successful_action = 0 for i, o in enumerate(qs): @@ -394,9 +395,21 @@ class OrderDeleteBulkActionView(BaseOrderBulkActionView): testmode=True, ) - def execute_single(self, instance, form: forms.Form): - instance.gracefully_delete(user=self.request.user) - return True + def execute_bulk(self, queryset: QuerySet, form: forms.Form): + qs = self.allowed_for(self.get_queryset()) + total = qs.count() + all_ids = list(qs.values_list("id", flat=True)) + + orders_with_successful_action = 0 + for chunk in chunked_iterable(all_ids, 1000): + Order.gracefully_delete_bulk( + self.request.event, + qs.filter(id__in=chunk), + user=self.request.user, + ) + orders_with_successful_action += len(chunk) + self.async_set_progress(orders_with_successful_action / total * 100) + return orders_with_successful_action, total class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin, ListView):