mirror of
https://github.com/pretix/pretix.git
synced 2026-05-14 16:44:06 +00:00
Compare commits
6 Commits
dependabot
...
bulk-vouch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20da00d4fb | ||
|
|
178a5525d5 | ||
|
|
1da1393a86 | ||
|
|
adf167e611 | ||
|
|
4867afc503 | ||
|
|
9ba5497287 |
@@ -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,
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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': '',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user