From dc1b62fc569fb750bcb23ceadc76522f5b2aa27f Mon Sep 17 00:00:00 2001
From: Raphael Michel
Date: Tue, 7 Jul 2026 17:03:25 +0200
Subject: [PATCH] Vouchers: Allow to bulk-update (#6096)
* Vouchers: Allow to bulk-delete larger numbers
* [DRAFT] voucher bulk update
* untested draft
* Minor fixes
* some tests and fixes
* More tests
* Bulk vouchers - refactor (#6353)
* Reuse parse_itemvar from VoucherForm
* Refactor: Method for _bulk update check
* remove unused parameter
* Refactor: Deduplicate get_affected_quotas code
* Remove unused delete button
* Prevent accidental update of *all* vouchers (cf PR #4960)
* Make sure we actually edit the selected items
* Use dataclass for bulk-edit data, deduplicate quota-blocking logic
* Move dataclass to models.py
* Fix linter errors
* Fix tests
* Fix tests
---------
Co-authored-by: luelista
---
src/pretix/base/models/vouchers.py | 83 ++--
src/pretix/control/forms/vouchers.py | 330 ++++++++++---
.../pretixcontrol/vouchers/bulk_edit.html | 83 ++++
.../pretixcontrol/vouchers/index.html | 19 +
src/pretix/control/urls.py | 1 +
src/pretix/control/views/vouchers.py | 195 ++++++--
src/tests/api/test_vouchers.py | 10 +
src/tests/control/test_vouchers.py | 450 +++++++++++++++++-
8 files changed, 1040 insertions(+), 131 deletions(-)
create mode 100644 src/pretix/control/templates/pretixcontrol/vouchers/bulk_edit.html
diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py
index 12ccd4a46..685265ea5 100644
--- a/src/pretix/base/models/vouchers.py
+++ b/src/pretix/base/models/vouchers.py
@@ -32,8 +32,10 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# 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.
-
+import datetime
+from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
+from typing import Union
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -421,27 +423,33 @@ class Voucher(LoggedModel):
return False
@staticmethod
- def clean_quota_get_ignored(old_instance):
- quotas = set()
- was_valid = old_instance and (
- old_instance.valid_until is None or old_instance.valid_until >= now()
- )
- if old_instance and old_instance.block_quota and was_valid:
- if old_instance.quota:
- quotas.add(old_instance.quota)
- elif old_instance.variation:
- quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
- elif old_instance.item:
- if old_instance.item.has_variations:
- quotas |= set(
- Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
- itemvariation__item=old_instance.item,
- quota__subevent=old_instance.subevent,
- ).values('quota_id'))
- )
- else:
- quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
- return quotas
+ def get_affected_quotas(quota, item, variation, subevent):
+ if quota:
+ return {quota}
+ elif item and variation:
+ return set(variation.quotas.filter(subevent=subevent))
+ elif item and not item.has_variations:
+ return set(item.quotas.filter(subevent=subevent))
+ elif item and item.has_variations:
+ return set(
+ Quota.objects.filter(
+ pk__in=Quota.variations.through.objects.filter(
+ itemvariation__item=item,
+ quota__subevent=subevent,
+ ).values('quota_id')
+ )
+ )
+ else:
+ return set()
+
+ @staticmethod
+ def clean_quota_get_ignored(voucher_data: Union["VoucherBulkData", "Voucher"]):
+ if voucher_data:
+ valid = voucher_data.valid_until is None or voucher_data.valid_until >= now()
+ if valid and voucher_data.block_quota and voucher_data.max_usages > voucher_data.redeemed:
+ return Voucher.get_affected_quotas(voucher_data.quota, voucher_data.item, voucher_data.variation, voucher_data.subevent)
+
+ return set()
@staticmethod
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
@@ -453,22 +461,8 @@ class Voucher(LoggedModel):
if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
- if quota:
- new_quotas = {quota}
- elif item and variation:
- new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
- elif item and not item.has_variations:
- new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
- elif item and item.has_variations:
- new_quotas = set(
- Quota.objects.filter(
- pk__in=Quota.variations.through.objects.filter(
- itemvariation__item=item,
- quota__subevent=data.get('subevent'),
- ).values('quota_id')
- )
- )
- else:
+ new_quotas = Voucher.get_affected_quotas(quota, item, variation, data.get('subevent'))
+ if not new_quotas:
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.'))
@@ -644,3 +638,16 @@ class Voucher(LoggedModel):
]
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
return ops
+
+
+@dataclass
+class VoucherBulkData:
+ item: object
+ variation: object
+ quota: object
+ block_quota: bool
+ valid_until: datetime.datetime
+ subevent: object
+ redeemed: int
+ max_usages: int
+ allow_ignore_quota: bool
diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py
index 81fc6081c..e8bf6fac9 100644
--- a/src/pretix/control/forms/vouchers.py
+++ b/src/pretix/control/forms/vouchers.py
@@ -32,16 +32,20 @@
# 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.
+import copy
import csv
-from collections import namedtuple
+from collections import Counter, namedtuple
from io import StringIO
from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import EmailValidator
+from django.db.models import Count, F, Max
from django.db.models.functions import Upper
+from django.forms.utils import ErrorDict
from django.urls import reverse
-from django.utils.translation import gettext_lazy as _
+from django.utils.timezone import now
+from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
from pretix.base.email import get_available_placeholders
@@ -50,7 +54,10 @@ from pretix.base.forms import (
)
from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.i18n import language
-from pretix.base.models import Item, Voucher
+from pretix.base.models import Item, ItemVariation, Quota, SubEvent, Voucher
+from pretix.base.models.vouchers import VoucherBulkData
+from pretix.base.services.locking import lock_objects
+from pretix.base.services.quotas import QuotaAvailability
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import voucher_form_validation
@@ -105,20 +112,22 @@ class VoucherForm(I18nModelForm):
except Item.DoesNotExist:
pass
super().__init__(*args, **kwargs)
+ if not self.event and self.instance:
+ self.event = self.instance.event
self.fields['tag'].widget.attrs['data-typeahead-url'] = reverse('control:event.vouchers.tags.typeahead', kwargs={
- 'event': instance.event.slug,
- 'organizer': instance.event.organizer.slug,
+ 'event': self.event.slug,
+ 'organizer': self.event.organizer.slug,
})
- if instance.event.has_subevents:
- self.fields['subevent'].queryset = instance.event.subevents.all()
+ if self.event.has_subevents:
+ self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
- 'event': instance.event.slug,
- 'organizer': instance.event.organizer.slug,
+ 'event': self.event.slug,
+ 'organizer': self.event.organizer.slug,
}),
}
)
@@ -128,18 +137,19 @@ class VoucherForm(I18nModelForm):
del self.fields['subevent']
choices = []
- if 'itemvar' in initial or (self.data and 'itemvar' in self.data):
- iv = self.data.get('itemvar') or initial.get('itemvar', '')
+ prefix = (self.prefix + '-') if self.prefix else ''
+ if 'itemvar' in initial or (self.data and prefix + 'itemvar' in self.data):
+ iv = self.data.get(prefix + 'itemvar', '') or initial.get('itemvar', '') or ''
if iv.startswith('q-'):
- q = self.instance.event.quotas.get(pk=iv[2:])
+ q = self.event.quotas.get(pk=iv[2:])
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
elif '-' in iv:
itemid, varid = iv.split('-')
- i = self.instance.event.items.get(pk=itemid)
+ i = self.event.items.get(pk=itemid)
v = i.variations.get(pk=varid)
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (str(i), v.value)))
elif iv:
- i = self.instance.event.items.get(pk=iv)
+ i = self.event.items.get(pk=iv)
if i.variations.exists():
choices.append((str(i.pk), _('{product} – Any variation').format(product=i)))
else:
@@ -150,8 +160,8 @@ class VoucherForm(I18nModelForm):
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
- 'event': instance.event.slug,
- 'organizer': instance.event.organizer.slug,
+ 'event': self.event.slug,
+ 'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('All products')
}
@@ -159,7 +169,7 @@ class VoucherForm(I18nModelForm):
self.fields['itemvar'].required = False
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
- if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists():
+ if self.event.seating_plan or self.event.subevents.filter(seating_plan__isnull=False).exists():
self.fields['seat'] = forms.CharField(
label=_("Specific seat ID"),
max_length=255,
@@ -169,40 +179,45 @@ class VoucherForm(I18nModelForm):
help_text=str(self.instance.seat) if self.instance.seat else '',
)
+ def parse_itemvar(self, data):
+ try:
+ itemid = quotaid = None
+ iv = data.get('itemvar', '')
+ if iv.startswith('q-'):
+ quotaid = iv[2:]
+ elif '-' in iv:
+ itemid, varid = iv.split('-')
+ elif iv:
+ itemid, varid = iv, None
+ else:
+ itemid, varid = None, None
+
+ if itemid:
+ item = self.event.items.get(pk=itemid)
+ if varid:
+ variation = item.variations.get(pk=varid)
+ else:
+ variation = None
+ quota = None
+ elif quotaid:
+ quota = self.event.quotas.get(pk=quotaid)
+ item = None
+ variation = None
+ else:
+ quota = None
+ item = None
+ variation = None
+
+ return (item, variation, quota)
+
+ except ObjectDoesNotExist:
+ raise ValidationError(_("Invalid product selected."))
+
def clean(self):
data = super().clean()
if not self._errors:
- try:
- itemid = quotaid = None
- iv = self.data.get('itemvar', '')
- if iv.startswith('q-'):
- quotaid = iv[2:]
- elif '-' in iv:
- itemid, varid = iv.split('-')
- elif iv:
- itemid, varid = iv, None
- else:
- itemid, varid = None, None
-
- if itemid:
- self.instance.item = self.instance.event.items.get(pk=itemid)
- if varid:
- self.instance.variation = self.instance.item.variations.get(pk=varid)
- else:
- self.instance.variation = None
- self.instance.quota = None
- elif quotaid:
- self.instance.quota = self.instance.event.quotas.get(pk=quotaid)
- self.instance.item = None
- self.instance.variation = None
- else:
- self.instance.quota = None
- self.instance.item = None
- self.instance.variation = None
-
- except ObjectDoesNotExist:
- raise ValidationError(_("Invalid product selected."))
+ self.instance.item, self.instance.variation, self.instance.quota = self.parse_itemvar(self.data)
if 'codes' in data:
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
@@ -214,7 +229,7 @@ class VoucherForm(I18nModelForm):
try:
Voucher.clean_item_properties(
- data, self.instance.event,
+ data, self.event,
self.instance.quota, self.instance.item, self.instance.variation,
seats_given=data.get('seat') or data.get('seats'),
block_quota=data.get('block_quota')
@@ -234,7 +249,7 @@ class VoucherForm(I18nModelForm):
try:
Voucher.clean_subevent(
- data, self.instance.event
+ data, self.event
)
except ValidationError as e:
raise ValidationError({"subevent": e.message})
@@ -250,19 +265,19 @@ class VoucherForm(I18nModelForm):
if check_quota:
Voucher.clean_quota_check(
data, cnt, self.initial_instance_data,
- self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
+ self.event, self.instance.quota, self.instance.item, self.instance.variation
)
- Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
+ Voucher.clean_voucher_code(data, self.event, self.instance.pk)
if 'seat' in self.fields:
if data.get('seat'):
self.instance.seat = Voucher.clean_seat_id(
- data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
+ data, self.instance.item, self.instance.quota, self.event, self.instance.pk
)
self.instance.item = self.instance.seat.product
else:
self.instance.seat = None
- voucher_form_validation.send(sender=self.instance.event, form=self, data=data)
+ voucher_form_validation.send(sender=self.event, form=self, data=data)
return data
@@ -270,6 +285,215 @@ class VoucherForm(I18nModelForm):
return super().save(commit)
+class VoucherBulkEditForm(VoucherForm):
+ def __init__(self, *args, **kwargs):
+ self.mixed_values = kwargs.pop('mixed_values')
+ self.queryset = kwargs.pop('queryset')
+ super().__init__(**kwargs)
+ del self.fields["code"]
+ self.fields.pop("seat", None)
+
+ def is_bulk_checked(self, fieldname):
+ return self.prefix + fieldname in self.data.getlist('_bulk')
+
+ def clean(self):
+ # We skip the parent class because it's not suited for bulk editing and implement custom validation here.
+ # This does not validate *everything* we validate in VoucherForm. For example, we skip validation that one does
+ # not create a voucher for an add-on product or that the seat matches the product to save on complexity.
+ # This is a UX validation only anyway, since one could first create the voucher and then make the product an
+ # add-on product. However, we need to validate everything that we don't want violated in the database.
+ data = super(VoucherForm, self).clean()
+
+ if self.is_bulk_checked("itemvar"):
+ data["item"], data["variation"], data["quota"] = self.parse_itemvar(data)
+
+ if self.is_bulk_checked("max_usages") and "max_usages" in data:
+ max_redeemed = self.queryset.aggregate(m=Max("redeemed"))["m"]
+ if data["max_usages"] < max_redeemed:
+ raise ValidationError(_(
+ "You cannot reduce the maximum number of redemptions to %(max_usages)s, because at least one "
+ "of the selected vouchers has already been redeemed %(max_redeemed)s times."
+ ) % {"max_usages": data["max_usages"], "max_redeemed": max_redeemed})
+
+ # Check diff on product and quota usage based on old groups of vouchers
+ if any(self.is_bulk_checked(k) for k in ("max_usages", "itemvar", "block_quota", "valid_until", "subevent")):
+ quota_diff = Counter()
+
+ current_vouchers = self.queryset.order_by().values(
+ "item", "variation", "quota", "block_quota", "valid_until", "subevent", "redeemed", "max_usages",
+ "allow_ignore_quota",
+ ).annotate(c=Count("*"))
+ item_cache = {i.pk: i for i in Item.objects.filter(pk__in=[c["item"] for c in current_vouchers])}
+ var_cache = {v.pk: v for v in ItemVariation.objects.filter(pk__in=[c["variation"] for c in current_vouchers])}
+ quota_cache = {q.pk: q for q in Quota.objects.filter(pk__in=[c["quota"] for c in current_vouchers])}
+ subevent_cache = {s.pk: s for s in SubEvent.objects.filter(pk__in=[c["subevent"] for c in current_vouchers])}
+
+ for current in current_vouchers:
+ bulk_count = current.pop('c')
+ current = VoucherBulkData(**current)
+ # Get quotas that are currently used
+ if current.item:
+ current.item = item_cache[current.item]
+ if current.variation:
+ current.variation = var_cache[current.variation]
+ if current.quota:
+ current.quota = quota_cache[current.quota]
+ if current.subevent:
+ current.subevent = subevent_cache[current.subevent]
+
+ old_quotas = Voucher.clean_quota_get_ignored(current)
+ old_amount = max(current.max_usages - current.redeemed, 0) * bulk_count
+
+ # Predict state after change
+ after_change = copy.copy(current)
+ if self.is_bulk_checked("itemvar") and "itemvar" in data:
+ after_change.item = data["item"]
+ after_change.variation = data["variation"]
+ after_change.quota = data["quota"]
+ if self.is_bulk_checked("subevent") and "subevent" in data:
+ after_change.subevent = data["subevent"]
+ if self.is_bulk_checked("max_usages") and "max_usages" in data:
+ after_change.max_usages = data["max_usages"]
+ if self.is_bulk_checked("block_quota") and "block_quota" in data:
+ after_change.block_quota = data["block_quota"]
+ if self.is_bulk_checked("valid_until") and "valid_until" in data:
+ after_change.valid_until = data["valid_until"]
+ if self.is_bulk_checked("allow_ignore_quota") and "allow_ignore_quota" in data:
+ after_change.allow_ignore_quota = data["allow_ignore_quota"]
+
+ if after_change.quota and self.event.has_subevents and not after_change.subevent:
+ raise ValidationError(_("You cannot create a voucher that allows selection of a quota but has no date selected."))
+
+ if after_change.quota and after_change.subevent and after_change.quota.subevent_id != after_change.subevent.pk:
+ raise ValidationError(_("The selected quota does not match the selected subevent."))
+
+ if after_change.block_quota and self.event.has_subevents and not after_change.subevent:
+ raise ValidationError(
+ _('If you want this voucher to block quota, you need to select a specific date.'))
+
+ if after_change.block_quota and not after_change.item and not after_change.quota:
+ raise ValidationError(
+ _('You need to select a specific product or quota if this voucher should reserve '
+ 'tickets.')
+ )
+
+ if after_change.allow_ignore_quota:
+ # todo: is this the most useful way to do this?
+ continue
+
+ new_quotas = Voucher.clean_quota_get_ignored(after_change)
+ new_amount = max(after_change.max_usages - after_change.redeemed, 0) * bulk_count
+
+ if new_quotas != old_quotas or new_amount != old_amount:
+ for q in old_quotas:
+ quota_diff[q] -= old_amount
+ for q in new_quotas:
+ quota_diff[q] += new_amount
+
+ if any(v > 0 for q, v in quota_diff.items()):
+ lock_objects([q for q, v in quota_diff.items() if q.size is not None and v > 0], shared_lock_objects=[self.event])
+ qa = QuotaAvailability(count_waitinglist=False)
+ qa.queue(*(q for q, v in quota_diff.items() if v > 0))
+ qa.compute()
+
+ if any(qa.results[q][0] != Quota.AVAILABILITY_OK or (qa.results[q][1] is not None and qa.results[q][1] < required)
+ for q, required in quota_diff.items() if required > 0):
+ raise ValidationError(_(
+ 'There is no sufficient quota available to perform this change.'
+ ))
+
+ has_seat = self.queryset.filter(seat__isnull=False).exists()
+ if has_seat:
+ if self.is_bulk_checked("max_usages"):
+ raise ValidationError(_(
+ 'Changing the maximum number of usages in bulk is not supported if any of the selected vouchers '
+ 'is assigned a seat.'
+ ))
+ if self.is_bulk_checked("subevent"):
+ raise ValidationError(pgettext_lazy(
+ 'subevent',
+ 'Changing the date in bulk is not supported if any of the selected vouchers '
+ 'is assigned a seat.'
+ ))
+ if self.is_bulk_checked("itemvar") and data["quota"]:
+ raise ValidationError(_(
+ 'Changing the product to a quota is not supported if any of the selected vouchers '
+ 'is assigned a seat.'
+ ))
+
+ if self.is_bulk_checked("valid_until"):
+ if data["valid_until"] is None or data["valid_until"] >= now():
+ currently_not_blocked_seats = self.queryset.filter(
+ seat__isnull=False,
+ max_usages__gt=F("redeemed"),
+ valid_until__lt=now(),
+ )
+ if self.event.has_subevents:
+ subevents = self.event.subevents.filter(pk__in=currently_not_blocked_seats.values_list("subevent"))
+ for se in subevents:
+ conflicts = currently_not_blocked_seats.filter(
+ subevent=se
+ ).exclude(
+ seat_id__in=se.free_seats().values("pk")
+ )
+ if conflicts:
+ raise ValidationError(_(
+ 'This change cannot be completed because not all assigned seats of the vouchers are '
+ 'still available'
+ ))
+ else:
+ conflicts = currently_not_blocked_seats.exclude(
+ seat_id__in=self.event.free_seats().values("pk")
+ )
+ if conflicts:
+ raise ValidationError(_(
+ 'This change cannot be completed because not all assigned seats of the vouchers are '
+ 'still available'
+ ))
+
+ return data
+
+ def save(self, commit=True):
+ objs = list(self.queryset)
+ fields = set()
+
+ check_map = {
+ 'price_mode': '__price',
+ 'value': '__price',
+ }
+ for k in self.fields:
+ if not self.is_bulk_checked(check_map.get(k, k)):
+ continue
+
+ if k == 'itemvar':
+ fields.add("item")
+ fields.add("variation")
+ fields.add("quota")
+ else:
+ fields.add(k)
+ for obj in objs:
+ if k == 'itemvar':
+ obj.item = self.cleaned_data["item"]
+ obj.variation = self.cleaned_data["variation"]
+ obj.quota = self.cleaned_data["quota"]
+ else:
+ setattr(obj, k, self.cleaned_data[k])
+
+ fields = [f for f in fields if f != 'itemvars']
+ if fields:
+ Voucher.objects.bulk_update(objs, fields, 200)
+
+ def full_clean(self):
+ if len(self.data) == 0:
+ # form wasn't submitted
+ self._errors = ErrorDict()
+ return
+ super().full_clean()
+
+ def _post_clean(self):
+ pass # skip model-level clean
+
+
class VoucherBulkForm(VoucherForm):
codes = forms.CharField(
widget=forms.Textarea,
diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/bulk_edit.html b/src/pretix/control/templates/pretixcontrol/vouchers/bulk_edit.html
new file mode 100644
index 000000000..373c139a0
--- /dev/null
+++ b/src/pretix/control/templates/pretixcontrol/vouchers/bulk_edit.html
@@ -0,0 +1,83 @@
+{% extends "pretixcontrol/items/base.html" %}
+{% load i18n %}
+{% load bootstrap3 %}
+{% load eventsignal %}
+{% load eventurl %}
+{% block title %}{% trans "Change multiple vouchers" %}{% endblock %}
+{% block inside %}
+
+ {% trans "Change multiple vouchers" %}
+
+ {% blocktrans trimmed with number=vouchers.count %}
+ {{ number }} selected
+ {% endblocktrans %}
+
+
+
+{% endblock %}
diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/index.html b/src/pretix/control/templates/pretixcontrol/vouchers/index.html
index 3f50ab53f..de6c018f0 100644
--- a/src/pretix/control/templates/pretixcontrol/vouchers/index.html
+++ b/src/pretix/control/templates/pretixcontrol/vouchers/index.html
@@ -99,6 +99,9 @@