From 5c448279f0abf54c8f2994c2ba6cc3bd6f3b9df4 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 5 Aug 2026 16:52:28 +0200 Subject: [PATCH] API: Make all mutating endpoints transactional (#6430) All of our API endpoints that do something in the system do at least two SQL queries, one for the actual change and one for the log entry. Often many more. We want all of this to happen in a transaction so we know an API call was executed or not at all, not half-way. --- src/pretix/api/views/checkin.py | 2 ++ src/pretix/api/views/discount.py | 4 ++++ src/pretix/api/views/event.py | 11 +++++++++++ src/pretix/api/views/exporters.py | 7 +++++++ src/pretix/api/views/item.py | 28 ++++++++++++++++++++++++++++ src/pretix/api/views/organizer.py | 3 +++ src/pretix/api/views/waitinglist.py | 4 ++++ src/pretix/api/views/webhooks.py | 4 ++++ src/pretix/plugins/sendmail/api.py | 4 ++++ 9 files changed, 67 insertions(+) diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 7bafddd3a3..1eb05b4940 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -139,6 +139,7 @@ class CheckinListViewSet(viewsets.ModelViewSet): ) return qs + @transaction.atomic() def perform_create(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -153,6 +154,7 @@ class CheckinListViewSet(viewsets.ModelViewSet): ctx['event'] = self.request.event return ctx + @transaction.atomic() def perform_update(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( diff --git a/src/pretix/api/views/discount.py b/src/pretix/api/views/discount.py index 53386d3d04..92bbd671e0 100644 --- a/src/pretix/api/views/discount.py +++ b/src/pretix/api/views/discount.py @@ -32,6 +32,7 @@ # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. +from django.db import transaction from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled from rest_framework import viewsets @@ -64,6 +65,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet): 'limit_sales_channels', ) + @transaction.atomic() def perform_create(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -78,6 +80,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet): ctx['event'] = self.request.event return ctx + @transaction.atomic() def perform_update(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -87,6 +90,7 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): if not instance.allow_delete(): raise PermissionDenied('You cannot delete this discount because it already has ' diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 630988aacd..e92f99ed8b 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -257,6 +257,7 @@ class EventViewSet(viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_create(self, serializer): copy_from = None if 'clone_from' in self.request.GET: @@ -320,6 +321,7 @@ class EventViewSet(viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): if not instance.allow_delete(): raise PermissionDenied('The event can not be deleted as it already contains orders. Please set \'live\'' @@ -355,6 +357,7 @@ class CloneEventViewSet(viewsets.ModelViewSet): ctx['organizer'] = self.request.organizer return ctx + @transaction.atomic() def perform_create(self, serializer): # Weird edge case: Requires settings permission on the event (to read) but also on the organizer (two write) perm_holder = (self.request.auth if isinstance(self.request.auth, (Device, TeamAPIToken)) @@ -513,6 +516,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): resp['X-Page-Generated'] = date return resp + @transaction.atomic() def perform_update(self, serializer): original_data = self.get_serializer(instance=serializer.instance).data super().perform_update(serializer) @@ -529,6 +533,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_create(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -538,6 +543,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): if not instance.allow_delete(): raise PermissionDenied('The sub-event can not be deleted as it has already been used in orders. Please set' @@ -566,6 +572,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): def get_queryset(self): return self.request.event.tax_rules.all() + @transaction.atomic() def perform_update(self, serializer): super().perform_update(serializer) serializer.instance.log_action( @@ -575,6 +582,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_create(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -584,6 +592,7 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): if not instance.allow_delete(): raise PermissionDenied('This tax rule can not be deleted as it is currently in use.') @@ -757,6 +766,7 @@ class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelV } return ctx + @transaction.atomic() def perform_update(self, serializer): super().perform_update(serializer) serializer.instance.event.log_action( @@ -766,6 +776,7 @@ class SeatViewSet(ConditionalListView, UpdateModelMixin, viewsets.ReadOnlyModelV data={"seats": [serializer.instance.pk]}, ) + @transaction.atomic() def bulk_change_blocked(self, blocked): s = SeatBulkBlockInputSerializer( data=self.request.data, diff --git a/src/pretix/api/views/exporters.py b/src/pretix/api/views/exporters.py index 26aca588f8..71931afcc1 100644 --- a/src/pretix/api/views/exporters.py +++ b/src/pretix/api/views/exporters.py @@ -23,6 +23,7 @@ from datetime import timedelta from celery.result import AsyncResult from django.conf import settings +from django.db import transaction from django.http import Http404 from django.shortcuts import get_object_or_404 from django.utils.functional import cached_property @@ -227,6 +228,7 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet): qs = self.request.event.scheduled_exports return qs.select_related("owner") + @transaction.atomic() def perform_create(self, serializer): if not self.request.user.is_authenticated: raise PermissionDenied('Creation of exports requires user-specific API access.') @@ -257,6 +259,7 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet): )) return {e.identifier: e for e in exporters} + @transaction.atomic() def perform_update(self, serializer): if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: # This is to prevent a possible privilege escalation where user A creates a scheduled export and @@ -282,6 +285,7 @@ class ScheduledEventExportViewSet(ScheduledExportersViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): self.request.event.log_action( 'pretix.event.export.schedule.deleted', @@ -309,6 +313,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet): qs = self.request.organizer.scheduled_exports return qs.select_related("owner") + @transaction.atomic() def perform_create(self, serializer): if not self.request.user.is_authenticated: raise PermissionDenied('Creation of exports requires user-specific API access.') @@ -339,6 +344,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet): )) return {e.identifier: e for e in exporters} + @transaction.atomic() def perform_update(self, serializer): if not self.request.user.is_authenticated or self.request.user != serializer.instance.owner: # This is to prevent a possible privilege escalation where user A creates a scheduled export and @@ -389,6 +395,7 @@ class ScheduledOrganizerExportViewSet(ScheduledExportersViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): self.request.organizer.log_action( 'pretix.organizer.export.schedule.deleted', diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index f2f0949392..8250600a0a 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -33,6 +33,7 @@ # License for the specific language governing permissions and limitations under the License. import django_filters +from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404 from django.utils.functional import cached_property @@ -109,6 +110,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): 'limit_sales_channels', 'variations__limit_sales_channels', 'program_times' ).all() + @transaction.atomic() def perform_create(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -123,6 +125,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): ctx['event'] = self.request.event return ctx + @transaction.atomic() def perform_update(self, serializer): original_data = self.get_serializer(instance=serializer.instance).data @@ -139,6 +142,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): if not instance.allow_delete(): raise PermissionDenied('This item cannot be deleted because it has already been ordered ' @@ -183,6 +187,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet): ctx['event'] = self.request.event return ctx + @transaction.atomic() def perform_create(self, serializer): item = self.item if not item.has_variations: @@ -197,6 +202,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet): {'value': serializer.instance.value}) ) + @transaction.atomic() def perform_update(self, serializer): serializer.save(event=self.request.event) serializer.instance.item.log_action( @@ -207,6 +213,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet): {'value': serializer.instance.value}) ) + @transaction.atomic() def perform_destroy(self, instance): if not instance.allow_delete(): raise PermissionDenied('This variation cannot be deleted because it has already been ordered ' @@ -249,6 +256,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet): ctx['item'] = self.item return ctx + @transaction.atomic() def perform_create(self, serializer): item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) serializer.save(base_item=item) @@ -259,6 +267,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) ) + @transaction.atomic() def perform_update(self, serializer): serializer.save(event=self.request.event) serializer.instance.base_item.log_action( @@ -268,6 +277,7 @@ class ItemBundleViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) ) + @transaction.atomic() def perform_destroy(self, instance): super().perform_destroy(instance) instance.base_item.log_action( @@ -303,6 +313,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet): ctx['item'] = self.item return ctx + @transaction.atomic() def perform_create(self, serializer): item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) serializer.save(item=item) @@ -313,6 +324,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) ) + @transaction.atomic() def perform_update(self, serializer): serializer.save(event=self.request.event) serializer.instance.item.log_action( @@ -322,6 +334,7 @@ class ItemProgramTimeViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) ) + @transaction.atomic() def perform_destroy(self, instance): super().perform_destroy(instance) instance.item.log_action( @@ -354,6 +367,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet): ctx['item'] = self.item return ctx + @transaction.atomic() def perform_create(self, serializer): item = self.item category = get_object_or_404(ItemCategory, pk=self.request.data['addon_category']) @@ -365,6 +379,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) ) + @transaction.atomic() def perform_update(self, serializer): serializer.save(event=self.request.event) serializer.instance.base_item.log_action( @@ -374,6 +389,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) ) + @transaction.atomic() def perform_destroy(self, instance): super().perform_destroy(instance) instance.base_item.log_action( @@ -403,6 +419,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): def get_queryset(self): return self.request.event.categories.all() + @transaction.atomic() def perform_create(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -417,6 +434,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): ctx['event'] = self.request.event return ctx + @transaction.atomic() def perform_update(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -426,6 +444,7 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): for item in instance.items.all(): item.category = None @@ -458,6 +477,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): def get_queryset(self): return self.request.event.questions.prefetch_related('options').all() + @transaction.atomic() def perform_create(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -472,6 +492,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): ctx['event'] = self.request.event return ctx + @transaction.atomic() def perform_update(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -481,6 +502,7 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): instance.log_action( 'pretix.event.question.deleted', @@ -509,6 +531,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): ctx['question'] = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) return ctx + @transaction.atomic() def perform_create(self, serializer): q = get_object_or_404(Question, pk=self.kwargs['question'], event=self.request.event) serializer.save(question=q) @@ -519,6 +542,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) ) + @transaction.atomic() def perform_update(self, serializer): serializer.save(event=self.request.event) serializer.instance.question.log_action( @@ -528,6 +552,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'ORDER': serializer.instance.position}, {'id': serializer.instance.pk}) ) + @transaction.atomic() def perform_destroy(self, instance): instance.question.log_action( 'pretix.event.question.option.deleted', @@ -586,6 +611,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): serializer = self.get_serializer(page, many=True) return self.get_paginated_response(serializer.data) + @transaction.atomic() def perform_create(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -608,6 +634,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): ctx['request'] = self.request return ctx + @transaction.atomic() def perform_update(self, serializer): original_data = self.get_serializer(instance=serializer.instance).data @@ -663,6 +690,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): ) serializer.instance.rebuild_cache() + @transaction.atomic() def perform_destroy(self, instance): instance.log_action( 'pretix.event.quota.deleted', diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index 3551437b6f..db28044b30 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -394,6 +394,7 @@ class TeamViewSet(viewsets.ModelViewSet): ) return inst + @transaction.atomic() def perform_destroy(self, instance): instance.log_action('pretix.team.deleted', user=self.request.user, auth=self.request.auth) instance.delete() @@ -693,6 +694,7 @@ class MembershipTypeViewSet(viewsets.ModelViewSet): ctx['organizer'] = self.request.organizer return ctx + @transaction.atomic() def perform_destroy(self, instance): if not instance.allow_delete(): raise PermissionDenied("Can only be deleted if unused.") @@ -833,6 +835,7 @@ class SalesChannelViewSet(viewsets.ModelViewSet): ) return inst + @transaction.atomic() def perform_destroy(self, instance): if not instance.allow_delete(): raise PermissionDenied("Can only be deleted if unused.") diff --git a/src/pretix/api/views/waitinglist.py b/src/pretix/api/views/waitinglist.py index 569c64b569..c2fd0b30ba 100644 --- a/src/pretix/api/views/waitinglist.py +++ b/src/pretix/api/views/waitinglist.py @@ -20,6 +20,7 @@ # . # import django_filters +from django.db import transaction from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled from rest_framework import viewsets @@ -62,6 +63,7 @@ class WaitingListViewSet(viewsets.ModelViewSet): ctx['event'] = self.request.event return ctx + @transaction.atomic() def perform_create(self, serializer): serializer.save(event=self.request.event) serializer.instance.log_action( @@ -70,6 +72,7 @@ class WaitingListViewSet(viewsets.ModelViewSet): auth=self.request.auth, ) + @transaction.atomic() def perform_update(self, serializer): if serializer.instance.voucher: raise PermissionDenied('This entry can not be changed as it has already been assigned a voucher.') @@ -80,6 +83,7 @@ class WaitingListViewSet(viewsets.ModelViewSet): auth=self.request.auth, ) + @transaction.atomic() def perform_destroy(self, instance): if instance.voucher: raise PermissionDenied('This entry can not be deleted as it has already been assigned a voucher.') diff --git a/src/pretix/api/views/webhooks.py b/src/pretix/api/views/webhooks.py index b7b883906c..d42c40c6bd 100644 --- a/src/pretix/api/views/webhooks.py +++ b/src/pretix/api/views/webhooks.py @@ -20,6 +20,7 @@ # . # import django_filters +from django.db import transaction from django_filters.rest_framework import DjangoFilterBackend, FilterSet from rest_framework import viewsets @@ -48,6 +49,7 @@ class WebHookViewSet(viewsets.ModelViewSet): ctx['organizer'] = self.request.organizer return ctx + @transaction.atomic() def perform_create(self, serializer): inst = serializer.save(organizer=self.request.organizer) self.request.organizer.log_action( @@ -57,6 +59,7 @@ class WebHookViewSet(viewsets.ModelViewSet): data=merge_dicts(self.request.data, {'id': inst.pk}) ) + @transaction.atomic() def perform_update(self, serializer): inst = serializer.save(organizer=self.request.organizer) self.request.organizer.log_action( @@ -67,6 +70,7 @@ class WebHookViewSet(viewsets.ModelViewSet): ) return inst + @transaction.atomic() def perform_destroy(self, instance): self.request.organizer.log_action( 'pretix.webhook.changed', diff --git a/src/pretix/plugins/sendmail/api.py b/src/pretix/plugins/sendmail/api.py index 7d5e1548ae..2e751e5ef5 100644 --- a/src/pretix/plugins/sendmail/api.py +++ b/src/pretix/plugins/sendmail/api.py @@ -20,6 +20,7 @@ # . # from django.core.exceptions import ValidationError +from django.db import transaction from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled from rest_framework import viewsets @@ -118,6 +119,7 @@ class RuleViewSet(viewsets.ModelViewSet): def get_queryset(self): return Rule.objects.filter(event=self.request.event) + @transaction.atomic() def perform_create(self, serializer): super().perform_create(serializer) serializer.instance.log_action( @@ -128,6 +130,7 @@ class RuleViewSet(viewsets.ModelViewSet): ) + @transaction.atomic() def perform_update(self, serializer): super().perform_update(serializer) serializer.instance.log_action( @@ -137,6 +140,7 @@ class RuleViewSet(viewsets.ModelViewSet): data=self.request.data ) + @transaction.atomic() def perform_destroy(self, instance): instance.log_action( 'pretix.plugins.sendmail.rule.deleted',