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

+
+ {% csrf_token %} + + {% bootstrap_form_errors form %} +
+ {% trans "Voucher details" %} + {% bootstrap_field form.max_usages layout="bulkedit" %} + {% bootstrap_field form.valid_until layout="bulkedit" %} + {% bootstrap_field form.itemvar layout="bulkedit" %} + +
+ +
+
+ +
+ {% bootstrap_field form.price_mode show_label=False form_group_class="" %} +
+
+ {% bootstrap_field form.value show_label=False form_group_class="" %} +
+
+
+
+ +
+
+
+
+ {% blocktrans trimmed %} + If you choose "any product" for a specific quota and choose to reserve quota for this + voucher above, the product can still be unavailable to the voucher holder if another quota + associated with the product is sold out! + {% endblocktrans %} +
+
+
+
+ {% if form.subevent %} + {% bootstrap_field form.subevent layout="bulkedit" %} + {% endif %} +
+
+ {% trans "Advanced settings" %} + {% bootstrap_field form.block_quota layout="bulkedit" %} + {% bootstrap_field form.allow_ignore_quota layout="bulkedit" %} + {% bootstrap_field form.min_usages layout="bulkedit" %} + {% bootstrap_field form.budget addon_after=request.event.currency layout="bulkedit" %} + {% bootstrap_field form.tag layout="bulkedit" %} + {% bootstrap_field form.comment layout="bulkedit" %} + {% bootstrap_field form.show_hidden_items layout="bulkedit" %} + {% bootstrap_field form.all_addons_included layout="bulkedit" %} + {% bootstrap_field form.all_bundles_included layout="bulkedit" %} +
+
+ +
+
+{% 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 @@

{% csrf_token %} + {% for field in filter_form %} + {{ field.as_hidden }} + {% endfor %}
@@ -144,6 +147,18 @@ {% endif %} + {% if "event.vouchers:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %} + + + + + {% endif %} {% for v in vouchers %} @@ -211,6 +226,10 @@ {% trans "Delete selected" %} + {% endif %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index a4a8a5e18..edc7edf82 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -383,6 +383,7 @@ urlpatterns = [ re_path(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'), re_path(r'^vouchers/bulk_add/mail_preview$', vouchers.VoucherBulkMailPreview.as_view(), name='event.vouchers.bulk.mail_preview'), re_path(r'^vouchers/bulk_action$', vouchers.VoucherBulkAction.as_view(), name='event.vouchers.bulkaction'), + re_path(r'^vouchers/bulk_edit$', vouchers.VoucherBulkUpdateView.as_view(), name='event.vouchers.bulkedit'), re_path(r'^vouchers/import/$', modelimport.VoucherImportView.as_view(), name='event.vouchers.import'), re_path(r'^vouchers/import/(?P[^/]+)/$', modelimport.VoucherProcessView.as_view(), name='event.vouchers.import.process'), re_path(r'^orders/(?P[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(), diff --git a/src/pretix/control/views/vouchers.py b/src/pretix/control/views/vouchers.py index c5ae68eb2..269b617d1 100644 --- a/src/pretix/control/views/vouchers.py +++ b/src/pretix/control/views/vouchers.py @@ -40,9 +40,11 @@ import bleach from defusedcsv import csv from django.conf import settings from django.contrib import messages -from django.core.exceptions import PermissionDenied, ValidationError +from django.core.exceptions import ( + BadRequest, PermissionDenied, ValidationError, +) from django.db import connection, transaction -from django.db.models import Exists, OuterRef, Sum +from django.db.models import Count, Exists, OuterRef, Sum from django.http import ( Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect, JsonResponse, @@ -55,7 +57,7 @@ from django.utils.safestring import mark_safe from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ from django.views.generic import ( - CreateView, ListView, TemplateView, UpdateView, View, + CreateView, FormView, ListView, TemplateView, UpdateView, View, ) from django_scopes import scopes_disabled @@ -70,7 +72,9 @@ from pretix.base.services.vouchers import vouchers_send from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.views.tasks import AsyncFormView from pretix.control.forms.filter import VoucherFilterForm, VoucherTagFilterForm -from pretix.control.forms.vouchers import VoucherBulkForm, VoucherForm +from pretix.control.forms.vouchers import ( + VoucherBulkEditForm, VoucherBulkForm, VoucherForm, +) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.signals import voucher_form_class from pretix.control.views import PaginationMixin @@ -80,7 +84,37 @@ from pretix.helpers.models import modelcopy from pretix.multidomain.urlreverse import eventreverse_absolute -class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView): +class VoucherQueryMixin: + + @cached_property + def request_data(self): + if self.request.method == "POST": + return self.request.POST + return self.request.GET + + @scopes_disabled() # we have an event check here, and we can save some performance on subqueries + def get_queryset(self): + qs = self.request.event.vouchers.exclude( + Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk'))) + ) + if 'voucher' in self.request_data and '__ALL' not in self.request_data: + qs = qs.filter( + id__in=self.request_data.getlist('voucher') + ) + elif self.request.method == 'GET' or '__ALL' in self.request_data: + if self.filter_form.is_valid(): + qs = self.filter_form.filter_qs(qs) + else: + raise BadRequest("No vouchers selected") + + return qs + + @cached_property + def filter_form(self): + return VoucherFilterForm(data=self.request_data, prefix='filter', event=self.request.event) + + +class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMixin, ListView): model = Voucher context_object_name = 'vouchers' template_name = 'pretixcontrol/vouchers/index.html' @@ -88,25 +122,15 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView): @scopes_disabled() # we have an event check here, and we can save some performance on subqueries def get_queryset(self): - qs = Voucher.annotate_budget_used(self.request.event.vouchers.exclude( - Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk'))) - ).select_related( + return Voucher.annotate_budget_used(super().get_queryset().select_related( 'item', 'variation', 'seat' )) - if self.filter_form.is_valid(): - qs = self.filter_form.filter_qs(qs) - - return qs def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['filter_form'] = self.filter_form return ctx - @cached_property - def filter_form(self): - return VoucherFilterForm(data=self.request.GET, event=self.request.event) - def get(self, request, *args, **kwargs): if request.GET.get("download", "") == "yes": return self._download_csv() @@ -293,6 +317,12 @@ class VoucherUpdate(EventPermissionRequiredMixin, UpdateView): f.disabled = True return form + def get_form_kwargs(self): + return { + **super().get_form_kwargs(), + "event": self.request.event, + } + def get_object(self, queryset=None) -> VoucherForm: url = resolve(self.request.path_info) try: @@ -603,26 +633,21 @@ class VoucherRNG(EventPermissionRequiredMixin, View): }) -class VoucherBulkAction(EventPermissionRequiredMixin, View): +class VoucherBulkAction(VoucherQueryMixin, EventPermissionRequiredMixin, View): permission = 'event.vouchers:write' - @cached_property - def objects(self): - return self.request.event.vouchers.filter( - id__in=self.request.POST.getlist('voucher') - ) - @transaction.atomic def post(self, request, *args, **kwargs): if request.POST.get('action') == 'delete': return render(request, 'pretixcontrol/vouchers/delete_bulk.html', { - 'allowed': self.objects.filter(redeemed=0), - 'forbidden': self.objects.exclude(redeemed=0), + 'allowed': self.get_queryset().filter(redeemed=0), + 'forbidden': self.get_queryset().exclude(redeemed=0), }) elif request.POST.get('action') == 'delete_confirm': log_entries = [] to_delete = [] - for obj in self.objects: + to_update = [] + for obj in self.get_queryset(): if obj.allow_delete(): log_entries.append(obj.log_action('pretix.voucher.deleted', user=self.request.user, save=False)) to_delete.append(obj.pk) @@ -632,12 +657,14 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View): 'bulk': True }, save=False)) obj.max_usages = min(obj.redeemed, obj.max_usages) - obj.save(update_fields=['max_usages']) + to_update.append(obj) if to_delete: CartPosition.objects.filter(addon_to__voucher_id__in=to_delete).delete() CartPosition.objects.filter(voucher_id__in=to_delete).delete() Voucher.objects.filter(pk__in=to_delete).delete() + if to_update: + Voucher.objects.bulk_update(to_update, ['max_usages']) LogEntry.bulk_create_and_postprocess(log_entries) messages.success(request, _('The selected vouchers have been deleted or disabled.')) @@ -648,3 +675,117 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View): 'organizer': self.request.event.organizer.slug, 'event': self.request.event.slug, }) + + +class VoucherBulkUpdateView(VoucherQueryMixin, EventPermissionRequiredMixin, FormView): + template_name = 'pretixcontrol/vouchers/bulk_edit.html' + permission = 'event.vouchers:write' + context_object_name = 'vouchers' + form_class = VoucherBulkEditForm + + def get_queryset(self): + return super().get_queryset().prefetch_related(None).order_by() + + def get(self, request, *args, **kwargs): + return HttpResponse(status=405) + + @cached_property + def is_submitted(self): + # Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always + # called with POST method, even if just to pass the selection of objects to work on, so we want to modify + # that behavior + return '_bulk' in self.request.POST + + def get_form_kwargs(self): + initial = {} + mixed_values = set() + qs = self.get_queryset().annotate() + + fields = ( + 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment', 'max_usages', + 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'all_addons_included', 'all_bundles_included', + 'budget', + ) + for f in fields: + existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*'))) + if len(existing_values) == 1: + initial[f] = existing_values[0][f] + elif len(existing_values) > 1: + mixed_values.add(f) + if f == "max_usages": + initial[f] = 1 + else: + initial[f] = None + + existing_values = list(qs.order_by("item", "variation", "quota").values("item", "variation", "quota").annotate(c=Count('*'))) + if len(existing_values) == 1: + i = existing_values[0] + if i["quota"]: + initial["itemvar"] = f'q-{i["quota"]}' + elif i["variation"]: + initial["itemvar"] = f'{i["item"]}-{i["variation"]}' + elif i["item"]: + initial["itemvar"] = f'{i["item"]}' + else: + initial["itemvar"] = None + elif len(existing_values) > 1: + mixed_values.add("itemvar") + initial["itemvar"] = None + + kwargs = super().get_form_kwargs() + kwargs['event'] = self.request.event + kwargs['prefix'] = 'bulkedit' + kwargs['initial'] = initial + kwargs['queryset'] = self.get_queryset() + kwargs['mixed_values'] = mixed_values + if not self.is_submitted: + kwargs['data'] = None + kwargs['files'] = None + return kwargs + + def get_success_url(self): + return reverse('control:event.vouchers', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + def form_valid(self, form): + log_entries = [] + + # Main form + form.save() + data = { + k: v + for k, v in form.cleaned_data.items() + if k in form.changed_data + } + data['_raw_bulk_data'] = self.request.POST.dict() + for obj in self.get_queryset(): + log_entries.append( + obj.log_action('pretix.voucher.changed', data=data, user=self.request.user, save=False) + ) + + LogEntry.bulk_create_and_postprocess(log_entries) + + messages.success(self.request, _('Your changes have been saved.')) + return super().form_valid(form) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['vouchers'] = self.get_queryset() + ctx['bulk_selected'] = self.request.POST.getlist("_bulk") + return ctx + + @transaction.atomic + def post(self, request, *args, **kwargs): + form = self.get_form() + is_valid = ( + self.is_submitted and + form.is_valid() + ) + if is_valid: + return self.form_valid(form) + else: + if self.is_submitted: + messages.error(self.request, _('We could not save your changes. See below for details.')) + return self.form_invalid(form) diff --git a/src/tests/api/test_vouchers.py b/src/tests/api/test_vouchers.py index 83cc42fef..c06079b5e 100644 --- a/src/tests/api/test_vouchers.py +++ b/src/tests/api/test_vouchers.py @@ -964,6 +964,8 @@ def test_redeemed_is_not_writable(token_client, organizer, event, item): @pytest.mark.django_db def test_create_multiple_vouchers(token_client, organizer, event, item): + with scopes_disabled(): + event.quotas.create(name="Q", size=100).items.add(item) resp = token_client.post( '/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug), data=[ @@ -1012,6 +1014,8 @@ def test_create_multiple_vouchers(token_client, organizer, event, item): @pytest.mark.django_db def test_create_multiple_vouchers_one_invalid(token_client, organizer, event, item): + with scopes_disabled(): + event.quotas.create(name="Q", size=100).items.add(item) resp = token_client.post( '/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug), data=[ @@ -1055,6 +1059,8 @@ def test_create_multiple_vouchers_one_invalid(token_client, organizer, event, it @pytest.mark.django_db def test_create_multiple_vouchers_duplicate_code(token_client, organizer, event, item): + with scopes_disabled(): + event.quotas.create(name="Q", size=100).items.add(item) resp = token_client.post( '/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug), data=[ @@ -1098,6 +1104,8 @@ def test_create_multiple_vouchers_duplicate_code(token_client, organizer, event, @pytest.mark.django_db def test_create_multiple_vouchers_autogenerate_codes(token_client, organizer, event, item): + with scopes_disabled(): + event.quotas.create(name="Q", size=100).items.add(item) resp = token_client.post( '/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug), data=[ @@ -1157,6 +1165,8 @@ def seat1(item, event): @pytest.mark.django_db def test_create_multiple_vouchers_duplicate_seat(token_client, organizer, event, item, seat1, seatingplan): + with scopes_disabled(): + event.quotas.create(name="Q", size=100).items.add(item) resp = token_client.post( '/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug), data=[ diff --git a/src/tests/control/test_vouchers.py b/src/tests/control/test_vouchers.py index 9d8a2ce88..586448cd1 100644 --- a/src/tests/control/test_vouchers.py +++ b/src/tests/control/test_vouchers.py @@ -35,6 +35,7 @@ import datetime import decimal import json +from decimal import Decimal from django.core import mail as djmail from django.test import TransactionTestCase @@ -43,8 +44,8 @@ from django_scopes import scopes_disabled from tests.base import SoupTestMixin, extract_form_fields from pretix.base.models import ( - Event, Item, ItemVariation, Order, OrderPosition, Organizer, Quota, Team, - User, Voucher, WaitingListEntry, + Event, Item, ItemVariation, Order, OrderPosition, Organizer, Quota, + SeatingPlan, Team, User, Voucher, WaitingListEntry, ) @@ -134,49 +135,49 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase): def test_filter_status_valid(self): with scopes_disabled(): v = self.event.vouchers.create(item=self.ticket) - doc = self.client.get('/control/event/%s/%s/vouchers/?status=v' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=v' % (self.orga.slug, self.event.slug)) assert v.code in doc.content.decode() v.redeemed = 1 v.save() - doc = self.client.get('/control/event/%s/%s/vouchers/?status=v' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=v' % (self.orga.slug, self.event.slug)) assert v.code not in doc.content.decode() def test_filter_status_redeemed(self): with scopes_disabled(): v = self.event.vouchers.create(item=self.ticket, redeemed=1) - doc = self.client.get('/control/event/%s/%s/vouchers/?status=r' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=r' % (self.orga.slug, self.event.slug)) assert v.code in doc.content.decode() v.redeemed = 0 v.save() - doc = self.client.get('/control/event/%s/%s/vouchers/?status=r' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=r' % (self.orga.slug, self.event.slug)) assert v.code not in doc.content.decode() def test_filter_status_expired(self): with scopes_disabled(): v = self.event.vouchers.create(item=self.ticket, valid_until=now() + datetime.timedelta(days=1)) - doc = self.client.get('/control/event/%s/%s/vouchers/?status=e' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=e' % (self.orga.slug, self.event.slug)) assert v.code not in doc.content.decode() v.valid_until = now() - datetime.timedelta(days=1) v.save() - doc = self.client.get('/control/event/%s/%s/vouchers/?status=e' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=e' % (self.orga.slug, self.event.slug)) assert v.code in doc.content.decode() def test_filter_tag(self): with scopes_disabled(): self.event.vouchers.create(item=self.ticket, code='ABCDEFG', comment='Foo', tag='bar') - doc = self.client.get('/control/event/%s/%s/vouchers/?tag=bar' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-tag=bar' % (self.orga.slug, self.event.slug)) assert 'ABCDEFG' in doc.content.decode() - doc = self.client.get('/control/event/%s/%s/vouchers/?tag=baz' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-tag=baz' % (self.orga.slug, self.event.slug)) assert 'ABCDEFG' not in doc.content.decode() def test_search_code(self): with scopes_disabled(): self.event.vouchers.create(item=self.ticket, code='ABCDEFG', comment='Foo') - doc = self.client.get('/control/event/%s/%s/vouchers/?search=ABCDEFG' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-search=ABCDEFG' % (self.orga.slug, self.event.slug)) assert 'ABCDEFG' in doc.content.decode() - doc = self.client.get('/control/event/%s/%s/vouchers/?search=Foo' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-search=Foo' % (self.orga.slug, self.event.slug)) assert 'ABCDEFG' in doc.content.decode() - doc = self.client.get('/control/event/%s/%s/vouchers/?search=12345' % (self.orga.slug, self.event.slug)) + doc = self.client.get('/control/event/%s/%s/vouchers/?filter-search=12345' % (self.orga.slug, self.event.slug)) assert 'ABCDEFG' not in doc.content.decode() def test_bulk_rng(self): @@ -806,3 +807,426 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase): assert 'walk-ins' in names assert 'waiting-list' not in names + + +class VoucherBulkEditFormTest(SoupTestMixin, TransactionTestCase): + @scopes_disabled() + def setUp(self): + super().setUp() + self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + self.orga = Organizer.objects.create(name='CCC', slug='ccc') + self.event = Event.objects.create( + organizer=self.orga, name='30C3', slug='30c3', + date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc), + ) + t = Team.objects.create(organizer=self.orga, all_event_permissions=True) + t.members.add(self.user) + t.limit_events.add(self.event) + self.client.login(email='dummy@dummy.dummy', password='dummy') + + self.quota_shirts = Quota.objects.create(event=self.event, name='Shirts', size=2) + self.shirt = Item.objects.create(event=self.event, name='T-Shirt', default_price=12) + self.quota_shirts.items.add(self.shirt) + self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14, value='Red') + self.shirt_blue = ItemVariation.objects.create(item=self.shirt, value='Blue') + self.quota_shirts.variations.add(self.shirt_red) + self.quota_shirts.variations.add(self.shirt_blue) + + self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=2) + self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', + default_price=23) + self.quota_tickets.items.add(self.ticket) + self.url = f'/control/event/{self.orga.slug}/{self.event.slug}/vouchers/bulk_edit' + + def test_simple_edit(self): + with scopes_disabled(): + self.event.vouchers.create( + quota=self.quota_tickets, + max_usages=10, + price_mode="set", + value=13, + ) + self.event.vouchers.create( + item=self.ticket, + max_usages=10, + price_mode="set", + value=12, + ) + + doc = self.post_doc(self.url, { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + assert fields.get('bulkedit-max_usages') == '10' + assert fields.get('bulkedit-price_mode') == 'set' + assert not fields.get('bulkedit-value') + fields.update({ + '_bulk': ['bulkedit__price', 'bulkeditmin_usages', 'bulkedittag', 'bulkeditshow_hidden_items'], + 'bulkedit-price_mode': 'percent', + 'bulkedit-value': '15', + 'bulkedit-min_usages': '3', + 'bulkedit-tag': 'tagged', + 'bulkedit-comment': 'This is a comment', # will be ignored, as not included in _bulk + 'bulkedit-show_hidden_items': '', + }) + doc = self.post_doc(self.url, fields, follow=True) + assert doc.select(".alert-success") + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.price_mode == "percent" + assert v.value == Decimal("15.00") + assert v.min_usages == 3 + assert v.tag == "tagged" + assert v.comment == "" + assert v.show_hidden_items is False + + def _update_all(self, data: dict, expect_error: str=None): + doc = self.post_doc(self.url, { + '__ALL': 'on', + }, follow=True) + fields = extract_form_fields(doc) + fields.update(data) + doc = self.post_doc(self.url, fields, follow=True) + error_texts = [el.text for el in doc.select(".alert-danger, .has-error")] + if expect_error: + assert doc.select(".alert-danger") + assert any(expect_error in t for t in error_texts), error_texts + else: + assert doc.select(".alert-success"), error_texts + + def test_change_itemvar_to_product(self): + with scopes_disabled(): + self.event.vouchers.create(quota=self.quota_tickets) + self.event.vouchers.create(item=self.ticket) + + self._update_all({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': f'{self.ticket.pk}', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.item == self.ticket + assert not v.variation + assert not v.quota + + def test_change_itemvar_to_variation(self): + with scopes_disabled(): + self.event.vouchers.create(quota=self.quota_tickets) + self.event.vouchers.create(item=self.ticket) + + self._update_all({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.item == self.shirt + assert v.variation == self.shirt_red + assert not v.quota + + def test_change_itemvar_to_quota(self): + with scopes_disabled(): + self.event.vouchers.create(quota=self.quota_tickets) + self.event.vouchers.create(item=self.ticket) + + self._update_all({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': f'q-{self.quota_tickets.pk}', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert not v.item + assert not v.variation + assert v.quota == self.quota_tickets + + def test_change_itemvar_to_all(self): + with scopes_disabled(): + self.event.vouchers.create(quota=self.quota_tickets) + self.event.vouchers.create(item=self.ticket) + + self._update_all({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': '', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert not v.item + assert not v.variation + assert not v.quota + + def test_change_max_usages(self): + with scopes_disabled(): + self.event.vouchers.create(quota=self.quota_tickets, max_usages=15, redeemed=4) + self.event.vouchers.create(item=self.ticket, max_usages=15, redeemed=2) + + self._update_all({ + '_bulk': ['bulkeditmax_usages'], + 'bulkedit-max_usages': '3', + }, expect_error="already been redeemed 4 times") + self._update_all({ + '_bulk': ['bulkeditmax_usages'], + 'bulkedit-max_usages': '4', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.max_usages == 4 + + def _requires_one_more_quota(self, data: dict, quota=None): + self._update_all(data, expect_error="no sufficient quota") + quota = quota or self.quota_tickets + quota.size += 1 + quota.save() + self._update_all(data) + + def test_quota_check_change_item(self): + with scopes_disabled(): + self.event.vouchers.create(item=self.shirt, block_quota=True, max_usages=2, redeemed=1) + self.event.vouchers.create(item=self.shirt, block_quota=True, max_usages=3, redeemed=1) + self._requires_one_more_quota({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': f'{self.ticket.pk}', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.item == self.ticket + + def test_quota_check_change_variation(self): + with scopes_disabled(): + self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2, redeemed=1) + self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=3, redeemed=1) + self._requires_one_more_quota({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}', + }, quota=self.quota_shirts) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.item == self.shirt + assert v.variation == self.shirt_red + + def test_quota_check_change_item_with_variations(self): + with scopes_disabled(): + self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2, redeemed=1) + self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=3, redeemed=1) + self._requires_one_more_quota({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': f'{self.shirt.pk}', + }, quota=self.quota_shirts) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.item == self.shirt + assert not v.variation + + def test_quota_check_change_expired_to_valid(self): + with scopes_disabled(): + self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2) + self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=1, valid_until=now() - datetime.timedelta(days=1)) + self._requires_one_more_quota({ + '_bulk': ['bulkeditvalid_until'], + 'bulkedit-valid_until_0': '', + 'bulkedit-valid_until_1': '', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert not v.valid_until + + def test_quota_check_change_max_usages(self): + with scopes_disabled(): + self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2) + self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=1, redeemed=1) + self._requires_one_more_quota({ + '_bulk': ['bulkeditmax_usages'], + 'bulkedit-max_usages': '2', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.max_usages == 2 + + def test_quota_check_no_change(self): + with scopes_disabled(): + # Technically overbooked, but we don't have a diff in quota + self.event.vouchers.create(item=self.shirt, variation=self.shirt_red, block_quota=True) + self.event.vouchers.create(item=self.shirt, variation=self.shirt_red, block_quota=True) + self.event.vouchers.create(item=self.shirt, variation=self.shirt_red, block_quota=True) + self._update_all({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.variation == self.shirt_blue + + def test_quota_check_change_subevent(self): + with scopes_disabled(): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + self.quota_tickets.subevent = se1 + self.quota_tickets.save() + Quota.objects.create(event=self.event, subevent=se2, name='Tickets', size=3) + self.event.vouchers.create(item=self.ticket, block_quota=True, subevent=se2) + self.event.vouchers.create(item=self.ticket, block_quota=True, subevent=se2) + self.event.vouchers.create(item=self.ticket, block_quota=True, subevent=se2) + self._requires_one_more_quota({ + '_bulk': ['bulkeditsubevent'], + 'bulkedit-subevent': f'{se1.pk}', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.subevent == se1 + + def test_change_subevent_quota_invalid(self): + with scopes_disabled(): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + self.quota_tickets.subevent = se1 + self.quota_tickets.save() + v1 = self.event.vouchers.create(quota=self.quota_tickets, block_quota=True, subevent=se1) + self._update_all({ + '_bulk': ['bulkeditsubevent'], + 'bulkedit-subevent': f'{se2.pk}', + }, expect_error="selected quota does not match the selected subevent") + self._update_all({ + '_bulk': ['bulkeditsubevent'], + 'bulkedit-subevent': '', + }, expect_error="has no date selected") + v1.quota = None + v1.item = self.ticket + v1.save() + self._update_all({ + '_bulk': ['bulkeditsubevent'], + 'bulkedit-subevent': '', + }, expect_error="If you want this voucher to block quota, you need to select a specific date") + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.subevent == se1 + + def test_change_missing_itemvar_with_block_quota(self): + with scopes_disabled(): + self.event.vouchers.create(quota=self.quota_tickets, block_quota=True) + self.event.vouchers.create(quota=self.quota_tickets, block_quota=True) + self._update_all({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': '', + }, expect_error="You need to select a specific product or quota if this voucher should reserve") + self._update_all({ + '_bulk': ['bulkedititemvar', 'bulkeditblock_quota'], + 'bulkedit-itemvar': '', + 'bulkedit-block_quota': '', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert not v.subevent + assert not v.block_quota + + def test_change_subevent_and_quota(self): + with scopes_disabled(): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + se2 = self.event.subevents.create(name="Bar", date_from=now()) + self.quota_tickets.subevent = se1 + self.quota_tickets.save() + q2 = Quota.objects.create(event=self.event, subevent=se2, name='Tickets', size=3) + self.event.vouchers.create(quota=self.quota_tickets, block_quota=True, subevent=se1) + self._update_all({ + '_bulk': ['bulkedititemvar', 'bulkeditsubevent'], + 'bulkedit-subevent': f'{se2.pk}', + 'bulkedit-itemvar': f'q-{q2.pk}', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.subevent == se2 + assert v.quota == q2 + + def test_quota_check_change_block_quota(self): + with scopes_disabled(): + self.event.vouchers.create(item=self.ticket, max_usages=3) + self._requires_one_more_quota({ + '_bulk': ['bulkeditblock_quota'], + 'bulkedit-block_quota': 'on', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.block_quota + + def test_ignore_quota(self): + with scopes_disabled(): + self.event.vouchers.create(item=self.ticket, max_usages=3) + self._update_all({ + '_bulk': ['bulkeditblock_quota', 'bulkeditallow_ignore_quota'], + 'bulkedit-block_quota': 'on', + 'bulkedit-allow_ignore_quota': 'on', + }) + with scopes_disabled(): + for v in self.event.vouchers.all(): + assert v.block_quota + assert v.allow_ignore_quota + + @scopes_disabled() + def _create_seat(self, **kwargs): + plan = SeatingPlan.objects.create( + name="Plan", organizer=self.orga, layout="{}" + ) + self.event.seating_plan = plan + self.event.save() + return self.event.seats.create(seat_number="A1", product=self.ticket, seat_guid="A1", **kwargs) + + def test_seated_unsupported(self): + with scopes_disabled(): + self.event.vouchers.create(item=self.ticket, max_usages=1, seat=self._create_seat()) + self._update_all({ + '_bulk': ['bulkeditmax_usages'], + 'bulkedit-max_usages': '2', + }, expect_error="Changing the maximum number of usages in bulk is not supported") + self._update_all({ + '_bulk': ['bulkeditsubevent'], + 'bulkedit-subevent': '', + }, expect_error="Changing the date in bulk is not supported") + self._update_all({ + '_bulk': ['bulkedititemvar'], + 'bulkedit-itemvar': f'q-{self.quota_tickets.pk}', + }, expect_error="Changing the product to a quota is not supported") + + def test_seat_changed_to_valid_needs_to_be_available(self): + with scopes_disabled(): + seat = self._create_seat(blocked=True) + self.event.vouchers.create(item=self.ticket, max_usages=1, valid_until=now() - datetime.timedelta(days=1), seat=seat) + + self._update_all({ + '_bulk': ['bulkeditvalid_until'], + 'bulkedit-valid_until_0': '', + 'bulkedit-valid_until_1': '', + }, expect_error="not all assigned seats of the vouchers are still available") + + seat.blocked = False + seat.save() + self._update_all({ + '_bulk': ['bulkeditvalid_until'], + 'bulkedit-valid_until_0': '', + 'bulkedit-valid_until_1': '', + }) + + def test_seat_changed_to_valid_needs_to_be_available_subevents(self): + with scopes_disabled(): + self.event.has_subevents = True + self.event.save() + se1 = self.event.subevents.create(name="Foo", date_from=now()) + seat = self._create_seat(subevent=se1, blocked=True) + self.event.vouchers.create(item=self.ticket, max_usages=1, valid_until=now() - datetime.timedelta(days=1), seat=seat, subevent=se1) + + self._update_all({ + '_bulk': ['bulkeditvalid_until'], + 'bulkedit-valid_until_0': '', + 'bulkedit-valid_until_1': '', + }, expect_error="not all assigned seats of the vouchers are still available") + + seat.blocked = False + seat.save() + self._update_all({ + '_bulk': ['bulkeditvalid_until'], + 'bulkedit-valid_until_0': '', + 'bulkedit-valid_until_1': '', + })