Compare commits

...

6 Commits

Author SHA1 Message Date
Raphael Michel
20da00d4fb More tests 2026-04-20 23:52:19 +02:00
Raphael Michel
178a5525d5 some tests and fixes 2026-04-19 22:36:06 +02:00
Raphael Michel
1da1393a86 Minor fixes 2026-04-17 02:23:45 +02:00
Raphael Michel
adf167e611 untested draft 2026-04-17 02:23:45 +02:00
Raphael Michel
4867afc503 [DRAFT] voucher bulk update 2026-04-17 02:23:45 +02:00
Raphael Michel
9ba5497287 Vouchers: Allow to bulk-delete larger numbers 2026-04-17 02:23:45 +02:00
6 changed files with 1001 additions and 62 deletions

View File

@@ -33,15 +33,18 @@
# License for the specific language governing permissions and limitations under the License.
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 +53,9 @@ 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.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,15 +110,17 @@ class VoucherForm(I18nModelForm):
except Item.DoesNotExist:
pass
super().__init__(*args, **kwargs)
if not self.event and self.instance:
self.event = self.instance.event
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,
}),
}
)
@@ -123,18 +130,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:
@@ -145,8 +153,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')
}
@@ -154,7 +162,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,
@@ -181,14 +189,14 @@ class VoucherForm(I18nModelForm):
itemid, varid = None, None
if itemid:
self.instance.item = self.instance.event.items.get(pk=itemid)
self.instance.item = self.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.quota = self.event.quotas.get(pk=quotaid)
self.instance.item = None
self.instance.variation = None
else:
@@ -209,7 +217,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')
@@ -229,7 +237,7 @@ class VoucherForm(I18nModelForm):
try:
Voucher.clean_subevent(
data, self.instance.event
data, self.event
)
except ValidationError as e:
raise ValidationError({"subevent": e.message})
@@ -245,19 +253,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
@@ -265,6 +273,273 @@ 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 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.prefix + "itemvar" in self.data.getlist('_bulk'):
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:
data["item"] = self.event.items.get(pk=itemid)
if varid:
data["variation"] = data["item"].variations.get(pk=varid)
else:
data["variation"] = None
data["quota"] = None
elif quotaid:
data["quota"] = self.event.quotas.get(pk=quotaid)
data["item"] = None
data["variation"] = None
else:
data["quota"] = None
data["item"] = None
data["variation"] = None
except ObjectDoesNotExist:
raise ValidationError(_("Invalid product selected."))
if self.prefix + "max_usages" in self.data.getlist('_bulk') 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.prefix + k in self.data.getlist('_bulk') 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:
was_valid = current["valid_until"] is None or current["valid_until"] >= now()
# 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 = set()
if was_valid and current["block_quota"] and current["max_usages"] > current["redeemed"]:
if current["quota"]:
old_quotas.add(current["quota"])
elif current["variation"]:
old_quotas |= set(current["variation"].quotas.filter(subevent=current["subevent"]))
elif current["item"]:
if current["item"].has_variations:
old_quotas |= set(
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
itemvariation__item=current["item"],
quota__subevent=current["subevent"],
).values('quota_id'))
)
else:
old_quotas |= set(current["item"].quotas.filter(subevent=current["subevent"]))
old_amount = max(current["max_usages"] - current["redeemed"], 0) * current["c"]
# Predict state after change
after_change = dict(current)
if self.prefix + "itemvar" in self.data.getlist('_bulk') and "itemvar" in data:
after_change["item"] = data["item"]
after_change["variation"] = data["variation"]
after_change["quota"] = data["quota"]
if self.prefix + "subevent" in self.data.getlist('_bulk') and "subevent" in data:
after_change["subevent"] = data["subevent"]
if self.prefix + "max_usages" in self.data.getlist('_bulk') and "max_usages" in data:
after_change["max_usages"] = data["max_usages"]
if self.prefix + "block_quota" in self.data.getlist('_bulk') and "block_quota" in data:
after_change["block_quota"] = data["block_quota"]
if self.prefix + "valid_until" in self.data.getlist('_bulk') and "valid_until" in data:
after_change["valid_until"] = data["valid_until"]
if self.prefix + "allow_ignore_quota" in self.data.getlist('_bulk') 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
will_be_valid = after_change["valid_until"] is None or after_change["valid_until"] >= now()
new_quotas = set()
if will_be_valid and after_change["block_quota"] and after_change["max_usages"] > current["redeemed"]:
if after_change["quota"]:
new_quotas.add(after_change["quota"])
elif after_change["variation"]:
new_quotas |= set(after_change["variation"].quotas.filter(subevent=after_change["subevent"]))
elif after_change["item"]:
if after_change["item"].has_variations:
new_quotas |= set(
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
itemvariation__item=after_change["item"],
quota__subevent=after_change["subevent"],
).values('quota_id'))
)
else:
new_quotas |= set(after_change["item"].quotas.filter(subevent=after_change["subevent"]))
new_amount = max(after_change["max_usages"] - after_change["redeemed"], 0) * current["c"]
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.prefix + "max_usages" in self.data.getlist('_bulk'):
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.prefix + "subevent" in self.data.getlist('_bulk'):
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.prefix + "itemvar" in self.data.getlist('_bulk') 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.prefix + "valid_until" in self.data.getlist('_bulk'):
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:
cb_val = self.prefix + check_map.get(k, k)
if cb_val not in self.data.getlist('_bulk'):
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,

View File

@@ -0,0 +1,87 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load eventsignal %}
{% load eventurl %}
{% block title %}{% trans "Change multiple vouchers" %}{% endblock %}
{% block inside %}
<h1>
{% trans "Change multiple vouchers" %}
<small>
{% blocktrans trimmed with number=vouchers.count %}
{{ number }} selected
{% endblocktrans %}
</small>
</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
<legend>{% trans "Voucher details" %}</legend>
{% bootstrap_field form.max_usages layout="bulkedit" %}
{% bootstrap_field form.valid_until layout="bulkedit" %}
{% bootstrap_field form.itemvar layout="bulkedit" %}
<div class="bulk-edit-field-group">
<label class="field-toggle">
<input type="checkbox" name="_bulk" value="{{ form.prefix }}__price" {% if form.prefix|add:"__price" in bulk_selected %}checked{% endif %}>
{% trans "change" context "form_bulk" %}
</label>
<div class="field-content">
<div class="form-group">
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
<div class="col-md-5">
{% bootstrap_field form.price_mode show_label=False form_group_class="" %}
</div>
<div class="col-md-4">
{% bootstrap_field form.value show_label=False form_group_class="" %}
</div>
</div>
</div>
</div>
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
<div class="controls">
<div class="alert alert-info">
{% 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 %}
</div>
</div>
</div>
</div>
{% if form.subevent %}
{% bootstrap_field form.subevent layout="bulkedit" %}
{% endif %}
</fieldset>
<fieldset>
<legend>{% trans "Advanced settings" %}</legend>
{% 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" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
{% if voucher.pk %}
<div class="pull-left">
<a href="{% url "control:event.voucher.delete" organizer=request.organizer.slug event=request.event.slug voucher=voucher.pk %}"
class="btn btn-danger btn-lg">
<span class="fa fa-trash"></span>
{% trans "Delete voucher" %}
</a>
</div>
{% endif %}
</div>
</form>
{% endblock %}

View File

@@ -144,6 +144,18 @@
{% endif %}
<th></th>
</tr>
{% if "event.vouchers:write" in request.eventpermset and page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all" data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="5">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for v in vouchers %}
@@ -211,6 +223,10 @@
<i class="fa fa-trash" aria-hidden="true"></i>
{% trans "Delete selected" %}
</button>
<button type="submit" class="btn btn-primary btn-save" name="action" value="edit"
formaction="{% url "control:event.vouchers.bulkedit" organizer=request.event.organizer.slug event=request.event.slug %}">
<i class="fa fa-edit"></i>{% trans "Edit selected" %}
</button>
</div>
{% endif %}
</form>

View File

@@ -379,6 +379,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<file>[^/]+)/$', modelimport.VoucherProcessView.as_view(), name='event.vouchers.import.process'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),

View File

@@ -42,7 +42,7 @@ from django.conf import settings
from django.contrib import messages
from django.core.exceptions import 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 +55,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 +70,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 +82,35 @@ from pretix.helpers.models import modelcopy
from pretix.multidomain.urlreverse import build_absolute_uri
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 self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
if 'voucher' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('voucher')
)
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 +118,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 +313,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 +629,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 +653,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 +671,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)

View File

@@ -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,
Event, Item, ItemVariation, Order, OrderPosition, Organizer, Quota,
SeatingPlan, Team, User, Voucher,
)
@@ -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):
@@ -771,3 +772,425 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase):
assert len(doc.select('.alert-warning ul li')) == 1 # Check that there's exactly 1 item in the warning list
assert doc.text.count('Order DEDUP') == 1 # Check that the order is listed exactly once
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, expect_error: str=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': '',
})