Compare commits

...

7 Commits

Author SHA1 Message Date
sweenu d09572d999 Remove leftovers from contact url in quick setup (#6359) 2026-07-07 19:23:48 +02:00
Lukas Bockstaller 634754aacb repair sendmail rules so we don't send emails to all positions in order independent of checkin status (Z#23235255) (#6340)
* add repro for Z#23235255

* add testcase for fallback to order.email

* only send to orderpositions included in op_qs

* codestyle
2026-07-07 17:46:52 +02:00
Raphael Michel 14c3baa2aa Drop nullability on Order.organizer and OrderPosition.organizer (#4278)
* Drop nullability on Order.organizer and OrderPosition.organizer

* Rebase migration and add autoclean

* Utilize new relationship for scopes

* Declare reverse noop

* Update src/pretix/base/migrations/0302_resolve_duplicate_codes_and_secrets.py

Co-authored-by: Martin Gross <gross@rami.io>

* Update src/pretix/base/migrations/0302_resolve_duplicate_codes_and_secrets.py

Co-authored-by: Martin Gross <gross@rami.io>

---------

Co-authored-by: Martin Gross <gross@rami.io>
2026-07-07 17:44:19 +02:00
Raphael Michel dc1b62fc56 Vouchers: Allow to bulk-update (#6096)
* Vouchers: Allow to bulk-delete larger numbers

* [DRAFT] voucher bulk update

* untested draft

* Minor fixes

* some tests and fixes

* More tests

* Bulk vouchers - refactor (#6353)

* Reuse parse_itemvar from VoucherForm

* Refactor: Method for _bulk update check

* remove unused parameter

* Refactor: Deduplicate get_affected_quotas code

* Remove unused delete button

* Prevent accidental update of *all* vouchers (cf PR #4960)

* Make sure we actually edit the selected items

* Use dataclass for bulk-edit data, deduplicate quota-blocking logic

* Move dataclass to models.py

* Fix linter errors

* Fix tests

* Fix tests

---------

Co-authored-by: luelista <weller@rami.io>
2026-07-07 17:03:25 +02:00
Raphael Michel ea2c81e6dc Improve handling of logout during asynchronous tasks (Z#23238978) (#6341)
* Handle logout during async task requests properly

* Handle fragments safely on webcheckin logout

* Remove unnecessary comment

* Revert hash change
2026-07-07 16:14:00 +02:00
Raphael Michel 2bd5e90a86 Fix isort 2026-07-07 15:43:00 +02:00
Raphael Michel 8095134400 Add query tagging for periodic tasks 2026-07-07 14:55:32 +02:00
21 changed files with 1297 additions and 178 deletions
@@ -40,6 +40,7 @@ from django.core.cache import cache
from django.core.management.base import BaseCommand
from django.db import close_old_connections
from django.dispatch.dispatcher import NO_RECEIVERS
from django_querytagger.tagging import with_tag
from pretix.helpers.periodic import SKIPPED
@@ -82,7 +83,8 @@ class Command(BaseCommand):
try:
# Check if the DB connection is still good, it might be closed if the previous task took too long.
close_old_connections()
r = receiver(signal=periodic_task, sender=self)
with with_tag(f"periodictask={name}"):
r = receiver(signal=periodic_task, sender=self)
except Exception as err:
if isinstance(err, KeyboardInterrupt):
raise err
@@ -0,0 +1,58 @@
# Generated by Django 4.2.8 on 2024-07-01 09:27
import logging
from django.db import migrations
from django.db.models import Count
logger = logging.getLogger(__name__)
def clean_duplicate_secrets(apps, schema_editor):
# This will autofix all possible duplicate Order.code and OrderPosition.secret values,
# unless Order.code is already too long to append something. This would need to be fixed by
# sysadmins manually.
OrderPosition = apps.get_model("pretixbase", "OrderPosition")
Order = apps.get_model("pretixbase", "Order")
qs = OrderPosition.all.values("secret", "order__event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
for row in qs:
affected = OrderPosition.all.filter(
**{k: v for k, v in row.items() if k != "c"}
).order_by("pk")
logger.error(f"Found {row['c']} tickets with with the same secret \"{row['secret']}\" in organizer {row['order__event__organizer_id']}, all except one will be changed")
for i, a in enumerate(affected):
if i > 0:
a.secret = a.secret + "__dupl__" + str(a.pk)
logger.info(
f"Ticket {a.pk} has new secret {a.secret}"
)
a.save(update_fields=["organizer_id", "secret"])
qs = Order.objects.values("code", "event__organizer_id").order_by().annotate(c=Count("*")).filter(c__gt=1)
for row in qs:
affected = Order.objects.filter(
**{k: v for k, v in row.items() if k != "c"}
).order_by("pk")
logger.error(f"Found {row['c']} orders with with the same code \"{row['code']}\" in organizer {row['event__organizer_id']}, all except one will be changed")
for i, a in enumerate(affected):
if i > 0:
if len(a.code) > 16 - len(str(a.pk)):
raise ValueError(f"Cannot auto-fix order with duplicate code {a.code}, order code is too long already")
a.code = a.code + str(a.pk).zfill(16 - len(a.code))
logger.info(
f"Order {a.pk} has new code {a.code}"
)
a.save(update_fields=["organizer_id", "code"])
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0301_reusablemedium_remove_orderposition",
),
]
operations = [
migrations.RunPython(clean_duplicate_secrets, migrations.RunPython.noop),
]
@@ -0,0 +1,46 @@
# Generated by Django 4.2.8 on 2024-07-01 09:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0302_resolve_duplicate_codes_and_secrets",
),
]
operations = [
migrations.RunSQL(
"UPDATE pretixbase_order "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_event e WHERE e.id = pretixbase_order.event_id) "
"WHERE pretixbase_order.organizer_id IS NULL;",
migrations.RunSQL.noop,
),
migrations.RunSQL(
"UPDATE pretixbase_orderposition "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_order o LEFT JOIN pretixbase_event e ON e.id = o.event_id WHERE o.id = pretixbase_orderposition.order_id) "
"WHERE pretixbase_orderposition.organizer_id IS NULL;",
migrations.RunSQL.noop,
),
migrations.AlterField(
model_name="order",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="pretixbase.organizer",
),
),
migrations.AlterField(
model_name="orderposition",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="order_positions",
to="pretixbase.organizer",
),
),
]
+2 -6
View File
@@ -224,8 +224,6 @@ class Order(LockModel, LoggedModel):
"Organizer",
related_name="orders",
on_delete=models.CASCADE,
null=True,
blank=True,
)
event = models.ForeignKey(
Event,
@@ -329,7 +327,7 @@ class Order(LockModel, LoggedModel):
default="line",
)
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='event__organizer')
objects = ScopedManager(OrderQuerySet.as_manager().__class__, organizer='organizer')
class Meta:
verbose_name = _("Order")
@@ -2541,8 +2539,6 @@ class OrderPosition(AbstractPosition):
"Organizer",
related_name="order_positions",
on_delete=models.CASCADE,
null=True,
blank=True,
)
order = models.ForeignKey(
Order,
@@ -2599,7 +2595,7 @@ class OrderPosition(AbstractPosition):
blank=True,
)
all = ScopedManager(organizer='order__event__organizer')
all = ScopedManager(organizer='organizer')
objects = ActivePositionManager()
def __init__(self, *args, **kwargs):
+45 -38
View File
@@ -32,8 +32,10 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import datetime
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from typing import Union
from django.conf import settings
from django.core.exceptions import ValidationError
@@ -421,27 +423,33 @@ class Voucher(LoggedModel):
return False
@staticmethod
def clean_quota_get_ignored(old_instance):
quotas = set()
was_valid = old_instance and (
old_instance.valid_until is None or old_instance.valid_until >= now()
)
if old_instance and old_instance.block_quota and was_valid:
if old_instance.quota:
quotas.add(old_instance.quota)
elif old_instance.variation:
quotas |= set(old_instance.variation.quotas.filter(subevent=old_instance.subevent))
elif old_instance.item:
if old_instance.item.has_variations:
quotas |= set(
Quota.objects.filter(pk__in=Quota.variations.through.objects.filter(
itemvariation__item=old_instance.item,
quota__subevent=old_instance.subevent,
).values('quota_id'))
)
else:
quotas |= set(old_instance.item.quotas.filter(subevent=old_instance.subevent))
return quotas
def get_affected_quotas(quota, item, variation, subevent):
if quota:
return {quota}
elif item and variation:
return set(variation.quotas.filter(subevent=subevent))
elif item and not item.has_variations:
return set(item.quotas.filter(subevent=subevent))
elif item and item.has_variations:
return set(
Quota.objects.filter(
pk__in=Quota.variations.through.objects.filter(
itemvariation__item=item,
quota__subevent=subevent,
).values('quota_id')
)
)
else:
return set()
@staticmethod
def clean_quota_get_ignored(voucher_data: Union["VoucherBulkData", "Voucher"]):
if voucher_data:
valid = voucher_data.valid_until is None or voucher_data.valid_until >= now()
if valid and voucher_data.block_quota and voucher_data.max_usages > voucher_data.redeemed:
return Voucher.get_affected_quotas(voucher_data.quota, voucher_data.item, voucher_data.variation, voucher_data.subevent)
return set()
@staticmethod
def clean_quota_check(data, cnt, old_instance, event, quota, item, variation):
@@ -453,22 +461,8 @@ class Voucher(LoggedModel):
if event.has_subevents and data.get('block_quota') and not data.get('subevent'):
raise ValidationError(_('If you want this voucher to block quota, you need to select a specific date.'))
if quota:
new_quotas = {quota}
elif item and variation:
new_quotas = set(variation.quotas.filter(subevent=data.get('subevent')))
elif item and not item.has_variations:
new_quotas = set(item.quotas.filter(subevent=data.get('subevent')))
elif item and item.has_variations:
new_quotas = set(
Quota.objects.filter(
pk__in=Quota.variations.through.objects.filter(
itemvariation__item=item,
quota__subevent=data.get('subevent'),
).values('quota_id')
)
)
else:
new_quotas = Voucher.get_affected_quotas(quota, item, variation, data.get('subevent'))
if not new_quotas:
raise ValidationError(_('You need to select a specific product or quota if this voucher should reserve '
'tickets.'))
@@ -644,3 +638,16 @@ class Voucher(LoggedModel):
]
).aggregate(s=Sum('voucher_budget_use'))['s'] or Decimal('0.00')
return ops
@dataclass
class VoucherBulkData:
item: object
variation: object
quota: object
block_quota: bool
valid_until: datetime.datetime
subevent: object
redeemed: int
max_usages: int
allow_ignore_quota: bool
-6
View File
@@ -1905,12 +1905,6 @@ class QuickSetupForm(I18nForm):
required=False,
help_text=_("We'll show this publicly to allow attendees to contact you.")
)
contact_url = forms.URLField(
label=_("Contact URL"),
required=False,
help_text=_("If you set this, the footer contact link will point here instead of using the email address above. "
"Please note that you still need to add a contact email address that will be shared with all emails you send.")
)
total_quota = forms.IntegerField(
label=_("Total capacity"),
min_value=0,
+277 -53
View File
@@ -32,16 +32,20 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import copy
import csv
from collections import namedtuple
from collections import Counter, namedtuple
from io import StringIO
from django import forms
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import EmailValidator
from django.db.models import Count, F, Max
from django.db.models.functions import Upper
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField
from pretix.base.email import get_available_placeholders
@@ -50,7 +54,10 @@ from pretix.base.forms import (
)
from pretix.base.forms.widgets import format_placeholders_help_text
from pretix.base.i18n import language
from pretix.base.models import Item, Voucher
from pretix.base.models import Item, ItemVariation, Quota, SubEvent, Voucher
from pretix.base.models.vouchers import VoucherBulkData
from pretix.base.services.locking import lock_objects
from pretix.base.services.quotas import QuotaAvailability
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import voucher_form_validation
@@ -105,20 +112,22 @@ class VoucherForm(I18nModelForm):
except Item.DoesNotExist:
pass
super().__init__(*args, **kwargs)
if not self.event and self.instance:
self.event = self.instance.event
self.fields['tag'].widget.attrs['data-typeahead-url'] = reverse('control:event.vouchers.tags.typeahead', kwargs={
'event': instance.event.slug,
'organizer': instance.event.organizer.slug,
'event': self.event.slug,
'organizer': self.event.organizer.slug,
})
if instance.event.has_subevents:
self.fields['subevent'].queryset = instance.event.subevents.all()
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
self.fields['subevent'].widget = Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:event.subevents.select2', kwargs={
'event': instance.event.slug,
'organizer': instance.event.organizer.slug,
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
}
)
@@ -128,18 +137,19 @@ class VoucherForm(I18nModelForm):
del self.fields['subevent']
choices = []
if 'itemvar' in initial or (self.data and 'itemvar' in self.data):
iv = self.data.get('itemvar') or initial.get('itemvar', '')
prefix = (self.prefix + '-') if self.prefix else ''
if 'itemvar' in initial or (self.data and prefix + 'itemvar' in self.data):
iv = self.data.get(prefix + 'itemvar', '') or initial.get('itemvar', '') or ''
if iv.startswith('q-'):
q = self.instance.event.quotas.get(pk=iv[2:])
q = self.event.quotas.get(pk=iv[2:])
choices.append(('q-%d' % q.pk, _('Any product in quota "{quota}"').format(quota=q)))
elif '-' in iv:
itemid, varid = iv.split('-')
i = self.instance.event.items.get(pk=itemid)
i = self.event.items.get(pk=itemid)
v = i.variations.get(pk=varid)
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (str(i), v.value)))
elif iv:
i = self.instance.event.items.get(pk=iv)
i = self.event.items.get(pk=iv)
if i.variations.exists():
choices.append((str(i.pk), _('{product} Any variation').format(product=i)))
else:
@@ -150,8 +160,8 @@ class VoucherForm(I18nModelForm):
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.vouchers.itemselect2', kwargs={
'event': instance.event.slug,
'organizer': instance.event.organizer.slug,
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('All products')
}
@@ -159,7 +169,7 @@ class VoucherForm(I18nModelForm):
self.fields['itemvar'].required = False
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
if self.instance.event.seating_plan or self.instance.event.subevents.filter(seating_plan__isnull=False).exists():
if self.event.seating_plan or self.event.subevents.filter(seating_plan__isnull=False).exists():
self.fields['seat'] = forms.CharField(
label=_("Specific seat ID"),
max_length=255,
@@ -169,40 +179,45 @@ class VoucherForm(I18nModelForm):
help_text=str(self.instance.seat) if self.instance.seat else '',
)
def parse_itemvar(self, data):
try:
itemid = quotaid = None
iv = data.get('itemvar', '')
if iv.startswith('q-'):
quotaid = iv[2:]
elif '-' in iv:
itemid, varid = iv.split('-')
elif iv:
itemid, varid = iv, None
else:
itemid, varid = None, None
if itemid:
item = self.event.items.get(pk=itemid)
if varid:
variation = item.variations.get(pk=varid)
else:
variation = None
quota = None
elif quotaid:
quota = self.event.quotas.get(pk=quotaid)
item = None
variation = None
else:
quota = None
item = None
variation = None
return (item, variation, quota)
except ObjectDoesNotExist:
raise ValidationError(_("Invalid product selected."))
def clean(self):
data = super().clean()
if not self._errors:
try:
itemid = quotaid = None
iv = self.data.get('itemvar', '')
if iv.startswith('q-'):
quotaid = iv[2:]
elif '-' in iv:
itemid, varid = iv.split('-')
elif iv:
itemid, varid = iv, None
else:
itemid, varid = None, None
if itemid:
self.instance.item = self.instance.event.items.get(pk=itemid)
if varid:
self.instance.variation = self.instance.item.variations.get(pk=varid)
else:
self.instance.variation = None
self.instance.quota = None
elif quotaid:
self.instance.quota = self.instance.event.quotas.get(pk=quotaid)
self.instance.item = None
self.instance.variation = None
else:
self.instance.quota = None
self.instance.item = None
self.instance.variation = None
except ObjectDoesNotExist:
raise ValidationError(_("Invalid product selected."))
self.instance.item, self.instance.variation, self.instance.quota = self.parse_itemvar(self.data)
if 'codes' in data:
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
@@ -214,7 +229,7 @@ class VoucherForm(I18nModelForm):
try:
Voucher.clean_item_properties(
data, self.instance.event,
data, self.event,
self.instance.quota, self.instance.item, self.instance.variation,
seats_given=data.get('seat') or data.get('seats'),
block_quota=data.get('block_quota')
@@ -234,7 +249,7 @@ class VoucherForm(I18nModelForm):
try:
Voucher.clean_subevent(
data, self.instance.event
data, self.event
)
except ValidationError as e:
raise ValidationError({"subevent": e.message})
@@ -250,19 +265,19 @@ class VoucherForm(I18nModelForm):
if check_quota:
Voucher.clean_quota_check(
data, cnt, self.initial_instance_data,
self.instance.event, self.instance.quota, self.instance.item, self.instance.variation
self.event, self.instance.quota, self.instance.item, self.instance.variation
)
Voucher.clean_voucher_code(data, self.instance.event, self.instance.pk)
Voucher.clean_voucher_code(data, self.event, self.instance.pk)
if 'seat' in self.fields:
if data.get('seat'):
self.instance.seat = Voucher.clean_seat_id(
data, self.instance.item, self.instance.quota, self.instance.event, self.instance.pk
data, self.instance.item, self.instance.quota, self.event, self.instance.pk
)
self.instance.item = self.instance.seat.product
else:
self.instance.seat = None
voucher_form_validation.send(sender=self.instance.event, form=self, data=data)
voucher_form_validation.send(sender=self.event, form=self, data=data)
return data
@@ -270,6 +285,215 @@ class VoucherForm(I18nModelForm):
return super().save(commit)
class VoucherBulkEditForm(VoucherForm):
def __init__(self, *args, **kwargs):
self.mixed_values = kwargs.pop('mixed_values')
self.queryset = kwargs.pop('queryset')
super().__init__(**kwargs)
del self.fields["code"]
self.fields.pop("seat", None)
def is_bulk_checked(self, fieldname):
return self.prefix + fieldname in self.data.getlist('_bulk')
def clean(self):
# We skip the parent class because it's not suited for bulk editing and implement custom validation here.
# This does not validate *everything* we validate in VoucherForm. For example, we skip validation that one does
# not create a voucher for an add-on product or that the seat matches the product to save on complexity.
# This is a UX validation only anyway, since one could first create the voucher and then make the product an
# add-on product. However, we need to validate everything that we don't want violated in the database.
data = super(VoucherForm, self).clean()
if self.is_bulk_checked("itemvar"):
data["item"], data["variation"], data["quota"] = self.parse_itemvar(data)
if self.is_bulk_checked("max_usages") and "max_usages" in data:
max_redeemed = self.queryset.aggregate(m=Max("redeemed"))["m"]
if data["max_usages"] < max_redeemed:
raise ValidationError(_(
"You cannot reduce the maximum number of redemptions to %(max_usages)s, because at least one "
"of the selected vouchers has already been redeemed %(max_redeemed)s times."
) % {"max_usages": data["max_usages"], "max_redeemed": max_redeemed})
# Check diff on product and quota usage based on old groups of vouchers
if any(self.is_bulk_checked(k) for k in ("max_usages", "itemvar", "block_quota", "valid_until", "subevent")):
quota_diff = Counter()
current_vouchers = self.queryset.order_by().values(
"item", "variation", "quota", "block_quota", "valid_until", "subevent", "redeemed", "max_usages",
"allow_ignore_quota",
).annotate(c=Count("*"))
item_cache = {i.pk: i for i in Item.objects.filter(pk__in=[c["item"] for c in current_vouchers])}
var_cache = {v.pk: v for v in ItemVariation.objects.filter(pk__in=[c["variation"] for c in current_vouchers])}
quota_cache = {q.pk: q for q in Quota.objects.filter(pk__in=[c["quota"] for c in current_vouchers])}
subevent_cache = {s.pk: s for s in SubEvent.objects.filter(pk__in=[c["subevent"] for c in current_vouchers])}
for current in current_vouchers:
bulk_count = current.pop('c')
current = VoucherBulkData(**current)
# Get quotas that are currently used
if current.item:
current.item = item_cache[current.item]
if current.variation:
current.variation = var_cache[current.variation]
if current.quota:
current.quota = quota_cache[current.quota]
if current.subevent:
current.subevent = subevent_cache[current.subevent]
old_quotas = Voucher.clean_quota_get_ignored(current)
old_amount = max(current.max_usages - current.redeemed, 0) * bulk_count
# Predict state after change
after_change = copy.copy(current)
if self.is_bulk_checked("itemvar") and "itemvar" in data:
after_change.item = data["item"]
after_change.variation = data["variation"]
after_change.quota = data["quota"]
if self.is_bulk_checked("subevent") and "subevent" in data:
after_change.subevent = data["subevent"]
if self.is_bulk_checked("max_usages") and "max_usages" in data:
after_change.max_usages = data["max_usages"]
if self.is_bulk_checked("block_quota") and "block_quota" in data:
after_change.block_quota = data["block_quota"]
if self.is_bulk_checked("valid_until") and "valid_until" in data:
after_change.valid_until = data["valid_until"]
if self.is_bulk_checked("allow_ignore_quota") and "allow_ignore_quota" in data:
after_change.allow_ignore_quota = data["allow_ignore_quota"]
if after_change.quota and self.event.has_subevents and not after_change.subevent:
raise ValidationError(_("You cannot create a voucher that allows selection of a quota but has no date selected."))
if after_change.quota and after_change.subevent and after_change.quota.subevent_id != after_change.subevent.pk:
raise ValidationError(_("The selected quota does not match the selected subevent."))
if after_change.block_quota and self.event.has_subevents and not after_change.subevent:
raise ValidationError(
_('If you want this voucher to block quota, you need to select a specific date.'))
if after_change.block_quota and not after_change.item and not after_change.quota:
raise ValidationError(
_('You need to select a specific product or quota if this voucher should reserve '
'tickets.')
)
if after_change.allow_ignore_quota:
# todo: is this the most useful way to do this?
continue
new_quotas = Voucher.clean_quota_get_ignored(after_change)
new_amount = max(after_change.max_usages - after_change.redeemed, 0) * bulk_count
if new_quotas != old_quotas or new_amount != old_amount:
for q in old_quotas:
quota_diff[q] -= old_amount
for q in new_quotas:
quota_diff[q] += new_amount
if any(v > 0 for q, v in quota_diff.items()):
lock_objects([q for q, v in quota_diff.items() if q.size is not None and v > 0], shared_lock_objects=[self.event])
qa = QuotaAvailability(count_waitinglist=False)
qa.queue(*(q for q, v in quota_diff.items() if v > 0))
qa.compute()
if any(qa.results[q][0] != Quota.AVAILABILITY_OK or (qa.results[q][1] is not None and qa.results[q][1] < required)
for q, required in quota_diff.items() if required > 0):
raise ValidationError(_(
'There is no sufficient quota available to perform this change.'
))
has_seat = self.queryset.filter(seat__isnull=False).exists()
if has_seat:
if self.is_bulk_checked("max_usages"):
raise ValidationError(_(
'Changing the maximum number of usages in bulk is not supported if any of the selected vouchers '
'is assigned a seat.'
))
if self.is_bulk_checked("subevent"):
raise ValidationError(pgettext_lazy(
'subevent',
'Changing the date in bulk is not supported if any of the selected vouchers '
'is assigned a seat.'
))
if self.is_bulk_checked("itemvar") and data["quota"]:
raise ValidationError(_(
'Changing the product to a quota is not supported if any of the selected vouchers '
'is assigned a seat.'
))
if self.is_bulk_checked("valid_until"):
if data["valid_until"] is None or data["valid_until"] >= now():
currently_not_blocked_seats = self.queryset.filter(
seat__isnull=False,
max_usages__gt=F("redeemed"),
valid_until__lt=now(),
)
if self.event.has_subevents:
subevents = self.event.subevents.filter(pk__in=currently_not_blocked_seats.values_list("subevent"))
for se in subevents:
conflicts = currently_not_blocked_seats.filter(
subevent=se
).exclude(
seat_id__in=se.free_seats().values("pk")
)
if conflicts:
raise ValidationError(_(
'This change cannot be completed because not all assigned seats of the vouchers are '
'still available'
))
else:
conflicts = currently_not_blocked_seats.exclude(
seat_id__in=self.event.free_seats().values("pk")
)
if conflicts:
raise ValidationError(_(
'This change cannot be completed because not all assigned seats of the vouchers are '
'still available'
))
return data
def save(self, commit=True):
objs = list(self.queryset)
fields = set()
check_map = {
'price_mode': '__price',
'value': '__price',
}
for k in self.fields:
if not self.is_bulk_checked(check_map.get(k, k)):
continue
if k == 'itemvar':
fields.add("item")
fields.add("variation")
fields.add("quota")
else:
fields.add(k)
for obj in objs:
if k == 'itemvar':
obj.item = self.cleaned_data["item"]
obj.variation = self.cleaned_data["variation"]
obj.quota = self.cleaned_data["quota"]
else:
setattr(obj, k, self.cleaned_data[k])
fields = [f for f in fields if f != 'itemvars']
if fields:
Voucher.objects.bulk_update(objs, fields, 200)
def full_clean(self):
if len(self.data) == 0:
# form wasn't submitted
self._errors = ErrorDict()
return
super().full_clean()
def _post_clean(self):
pass # skip model-level clean
class VoucherBulkForm(VoucherForm):
codes = forms.CharField(
widget=forms.Textarea,
+17 -4
View File
@@ -37,7 +37,7 @@ from urllib.parse import quote, urljoin, urlparse
from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
from django.contrib.auth.views import redirect_to_login
from django.http import Http404
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404, resolve_url
from django.template.response import TemplateResponse
from django.urls import get_script_prefix, resolve, reverse
@@ -98,6 +98,8 @@ class PermissionMiddleware:
super().__init__()
def _login_redirect(self, request):
from django.contrib.auth.views import redirect_to_login
# Taken from django/contrib/auth/decorators.py
path = request.build_absolute_uri()
# urlparse chokes on lazy objects in Python 3, force to str
@@ -110,10 +112,21 @@ class PermissionMiddleware:
if ((not login_scheme or login_scheme == current_scheme) and
(not login_netloc or login_netloc == current_netloc)):
path = request.get_full_path()
from django.contrib.auth.views import redirect_to_login
return redirect_to_login(
path, resolved_login_url, REDIRECT_FIELD_NAME)
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# It's not useful to return a 302 redirect on a XMLHttpRequest request, because
# the XMLHttpRequest is unable to detect redirects.
return HttpResponse(
"Authentication required",
status=401,
headers={
# Appending ?next= is handled by client, because it should be the top-level context url,
# not the URL called in the background
"X-Login-Url": resolved_login_url,
}
)
return redirect_to_login(path, resolved_login_url, REDIRECT_FIELD_NAME)
def __call__(self, request):
url = resolve(request.path_info)
@@ -56,5 +56,4 @@
</form>
<script type="text/plain" id="good_origin">{{ good_origin }}</script>
<script type="text/plain" id="bad_origin_report_url">{{ bad_origin_report_url }}</script>
<!-- pretix-login-marker -->{# marker required for ajax calls to detect that user session is over #}
{% endblock %}
@@ -193,7 +193,6 @@
{% endblocktrans %}
</p>
{% bootstrap_field form.contact_mail layout="control" %}
{% bootstrap_field form.contact_url layout="control" %}
{% bootstrap_field form.imprint_url layout="control" %}
</div>
</fieldset>
@@ -0,0 +1,83 @@
{% extends "pretixcontrol/items/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load eventsignal %}
{% load eventurl %}
{% block title %}{% trans "Change multiple vouchers" %}{% endblock %}
{% block inside %}
<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 %}
<div class="hidden">
{% for v in vouchers %}
<input type="hidden" name="voucher" value="{{ v.pk }}">
{% endfor %}
</div>
{% 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>
</div>
</form>
{% endblock %}
@@ -99,6 +99,9 @@
</p>
<form action="{% url "control:event.vouchers.bulkaction" organizer=request.event.organizer.slug event=request.event.slug %}" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}
{% endfor %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
@@ -144,6 +147,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 +226,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>
+1
View File
@@ -383,6 +383,7 @@ urlpatterns = [
re_path(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
re_path(r'^vouchers/bulk_add/mail_preview$', vouchers.VoucherBulkMailPreview.as_view(), name='event.vouchers.bulk.mail_preview'),
re_path(r'^vouchers/bulk_action$', vouchers.VoucherBulkAction.as_view(), name='event.vouchers.bulkaction'),
re_path(r'^vouchers/bulk_edit$', vouchers.VoucherBulkUpdateView.as_view(), name='event.vouchers.bulkedit'),
re_path(r'^vouchers/import/$', modelimport.VoucherImportView.as_view(), name='event.vouchers.import'),
re_path(r'^vouchers/import/(?P<file>[^/]+)/$', modelimport.VoucherProcessView.as_view(), name='event.vouchers.import.process'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),
+168 -27
View File
@@ -40,9 +40,11 @@ import bleach
from defusedcsv import csv
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.exceptions import (
BadRequest, PermissionDenied, ValidationError,
)
from django.db import connection, transaction
from django.db.models import Exists, OuterRef, Sum
from django.db.models import Count, Exists, OuterRef, Sum
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
JsonResponse,
@@ -55,7 +57,7 @@ from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _
from django.views.generic import (
CreateView, ListView, TemplateView, UpdateView, View,
CreateView, FormView, ListView, TemplateView, UpdateView, View,
)
from django_scopes import scopes_disabled
@@ -70,7 +72,9 @@ from pretix.base.services.vouchers import vouchers_send
from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncFormView
from pretix.control.forms.filter import VoucherFilterForm, VoucherTagFilterForm
from pretix.control.forms.vouchers import VoucherBulkForm, VoucherForm
from pretix.control.forms.vouchers import (
VoucherBulkEditForm, VoucherBulkForm, VoucherForm,
)
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.signals import voucher_form_class
from pretix.control.views import PaginationMixin
@@ -80,7 +84,37 @@ from pretix.helpers.models import modelcopy
from pretix.multidomain.urlreverse import eventreverse_absolute
class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
class VoucherQueryMixin:
@cached_property
def request_data(self):
if self.request.method == "POST":
return self.request.POST
return self.request.GET
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
def get_queryset(self):
qs = self.request.event.vouchers.exclude(
Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk')))
)
if 'voucher' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('voucher')
)
elif self.request.method == 'GET' or '__ALL' in self.request_data:
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
else:
raise BadRequest("No vouchers selected")
return qs
@cached_property
def filter_form(self):
return VoucherFilterForm(data=self.request_data, prefix='filter', event=self.request.event)
class VoucherList(VoucherQueryMixin, PaginationMixin, EventPermissionRequiredMixin, ListView):
model = Voucher
context_object_name = 'vouchers'
template_name = 'pretixcontrol/vouchers/index.html'
@@ -88,25 +122,15 @@ class VoucherList(PaginationMixin, EventPermissionRequiredMixin, ListView):
@scopes_disabled() # we have an event check here, and we can save some performance on subqueries
def get_queryset(self):
qs = Voucher.annotate_budget_used(self.request.event.vouchers.exclude(
Exists(WaitingListEntry.objects.filter(voucher_id=OuterRef('pk')))
).select_related(
return Voucher.annotate_budget_used(super().get_queryset().select_related(
'item', 'variation', 'seat'
))
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
return ctx
@cached_property
def filter_form(self):
return VoucherFilterForm(data=self.request.GET, event=self.request.event)
def get(self, request, *args, **kwargs):
if request.GET.get("download", "") == "yes":
return self._download_csv()
@@ -293,6 +317,12 @@ class VoucherUpdate(EventPermissionRequiredMixin, UpdateView):
f.disabled = True
return form
def get_form_kwargs(self):
return {
**super().get_form_kwargs(),
"event": self.request.event,
}
def get_object(self, queryset=None) -> VoucherForm:
url = resolve(self.request.path_info)
try:
@@ -603,26 +633,21 @@ class VoucherRNG(EventPermissionRequiredMixin, View):
})
class VoucherBulkAction(EventPermissionRequiredMixin, View):
class VoucherBulkAction(VoucherQueryMixin, EventPermissionRequiredMixin, View):
permission = 'event.vouchers:write'
@cached_property
def objects(self):
return self.request.event.vouchers.filter(
id__in=self.request.POST.getlist('voucher')
)
@transaction.atomic
def post(self, request, *args, **kwargs):
if request.POST.get('action') == 'delete':
return render(request, 'pretixcontrol/vouchers/delete_bulk.html', {
'allowed': self.objects.filter(redeemed=0),
'forbidden': self.objects.exclude(redeemed=0),
'allowed': self.get_queryset().filter(redeemed=0),
'forbidden': self.get_queryset().exclude(redeemed=0),
})
elif request.POST.get('action') == 'delete_confirm':
log_entries = []
to_delete = []
for obj in self.objects:
to_update = []
for obj in self.get_queryset():
if obj.allow_delete():
log_entries.append(obj.log_action('pretix.voucher.deleted', user=self.request.user, save=False))
to_delete.append(obj.pk)
@@ -632,12 +657,14 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View):
'bulk': True
}, save=False))
obj.max_usages = min(obj.redeemed, obj.max_usages)
obj.save(update_fields=['max_usages'])
to_update.append(obj)
if to_delete:
CartPosition.objects.filter(addon_to__voucher_id__in=to_delete).delete()
CartPosition.objects.filter(voucher_id__in=to_delete).delete()
Voucher.objects.filter(pk__in=to_delete).delete()
if to_update:
Voucher.objects.bulk_update(to_update, ['max_usages'])
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(request, _('The selected vouchers have been deleted or disabled.'))
@@ -648,3 +675,117 @@ class VoucherBulkAction(EventPermissionRequiredMixin, View):
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
class VoucherBulkUpdateView(VoucherQueryMixin, EventPermissionRequiredMixin, FormView):
template_name = 'pretixcontrol/vouchers/bulk_edit.html'
permission = 'event.vouchers:write'
context_object_name = 'vouchers'
form_class = VoucherBulkEditForm
def get_queryset(self):
return super().get_queryset().prefetch_related(None).order_by()
def get(self, request, *args, **kwargs):
return HttpResponse(status=405)
@cached_property
def is_submitted(self):
# Usually, django considers a form "bound" / "submitted" on every POST request. However, this view is always
# called with POST method, even if just to pass the selection of objects to work on, so we want to modify
# that behavior
return '_bulk' in self.request.POST
def get_form_kwargs(self):
initial = {}
mixed_values = set()
qs = self.get_queryset().annotate()
fields = (
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment', 'max_usages',
'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'all_addons_included', 'all_bundles_included',
'budget',
)
for f in fields:
existing_values = list(qs.order_by(f).values(f).annotate(c=Count('*')))
if len(existing_values) == 1:
initial[f] = existing_values[0][f]
elif len(existing_values) > 1:
mixed_values.add(f)
if f == "max_usages":
initial[f] = 1
else:
initial[f] = None
existing_values = list(qs.order_by("item", "variation", "quota").values("item", "variation", "quota").annotate(c=Count('*')))
if len(existing_values) == 1:
i = existing_values[0]
if i["quota"]:
initial["itemvar"] = f'q-{i["quota"]}'
elif i["variation"]:
initial["itemvar"] = f'{i["item"]}-{i["variation"]}'
elif i["item"]:
initial["itemvar"] = f'{i["item"]}'
else:
initial["itemvar"] = None
elif len(existing_values) > 1:
mixed_values.add("itemvar")
initial["itemvar"] = None
kwargs = super().get_form_kwargs()
kwargs['event'] = self.request.event
kwargs['prefix'] = 'bulkedit'
kwargs['initial'] = initial
kwargs['queryset'] = self.get_queryset()
kwargs['mixed_values'] = mixed_values
if not self.is_submitted:
kwargs['data'] = None
kwargs['files'] = None
return kwargs
def get_success_url(self):
return reverse('control:event.vouchers', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
def form_valid(self, form):
log_entries = []
# Main form
form.save()
data = {
k: v
for k, v in form.cleaned_data.items()
if k in form.changed_data
}
data['_raw_bulk_data'] = self.request.POST.dict()
for obj in self.get_queryset():
log_entries.append(
obj.log_action('pretix.voucher.changed', data=data, user=self.request.user, save=False)
)
LogEntry.bulk_create_and_postprocess(log_entries)
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['vouchers'] = self.get_queryset()
ctx['bulk_selected'] = self.request.POST.getlist("_bulk")
return ctx
@transaction.atomic
def post(self, request, *args, **kwargs):
form = self.get_form()
is_valid = (
self.is_submitted and
form.is_valid()
)
if is_valid:
return self.form_valid(form)
else:
if self.is_submitted:
messages.error(self.request, _('We could not save your changes. See below for details.'))
return self.form_invalid(form)
+26 -23
View File
@@ -162,6 +162,8 @@ class ScheduledMail(models.Model):
send_to_orders = self.rule.send_to in (Rule.CUSTOMERS, Rule.BOTH)
send_to_attendees = self.rule.send_to in (Rule.ATTENDEES, Rule.BOTH)
position_ids = op_qs.values_list('id', flat=True)
for o in orders:
with language(o.locale, e.settings.region):
positions = list(o.positions.all())
@@ -191,28 +193,29 @@ class ScheduledMail(models.Model):
positions = [p for p in positions if p.subevent_id == self.subevent_id]
for p in positions:
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
position=p,
event_or_subevent=self.subevent or e,
)
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
elif not o_sent and o.email:
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
event_or_subevent=self.subevent or e,
)
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
o_sent = True
if p.id in position_ids:
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
position=p,
event_or_subevent=self.subevent or e,
)
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
elif not o_sent and o.email:
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
event_or_subevent=self.subevent or e,
)
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
o_sent = True
self.last_successful_order_id = o.pk
@@ -270,7 +273,7 @@ class Rule(models.Model, LoggingMixin):
date_is_absolute = models.BooleanField(default=True, blank=True)
offset_to_event_end = models.BooleanField(default=False, blank=True) # no verbose name because not actually
offset_is_after = models.BooleanField(default=False, blank=True) # displayed in any forms
offset_is_after = models.BooleanField(default=False, blank=True) # displayed in any forms
send_to = models.CharField(max_length=10, choices=SEND_TO_CHOICES, default=CUSTOMERS, verbose_name=_('Send email to'))
@@ -205,8 +205,8 @@ const CSRF_TOKEN = document.querySelector<HTMLInputElement>('input[name=csrfmidd
function handleAuthError (response: Response): void {
if ([401, 403].includes(response.status)) {
window.location.href = '/control/login?next=' + encodeURIComponent(
window.location.pathname + window.location.search + window.location.hash
)
window.location.pathname + window.location.search
) + window.location.hash
}
}
@@ -113,6 +113,10 @@ function async_task_check_error(jqXHR, textStatus, errorThrown) {
"use strict";
var respdom = $(jqXHR.responseText);
var c = respdom.filter('.container');
if (jqXHR.status === 401 && jqXHR.getResponseHeader("X-Login-Url")) {
window.location = jqXHR.getResponseHeader("X-Login-Url") + "?next=" + encodeURIComponent(location.pathname + location.search + location.hash);
return;
}
if (respdom.filter('form') && (respdom.filter('.has-error') || respdom.filter('.alert-danger'))) {
// This is a failed form validation, let's just use it
$("body").data('ajaxing', false);
@@ -167,6 +171,10 @@ function async_task_callback(data, jqXHR, status) {
function async_task_error(jqXHR, textStatus, errorThrown) {
"use strict";
$("body").data('ajaxing', false);
if (jqXHR.status === 401 && jqXHR.getResponseHeader("X-Login-Url")) {
window.location = jqXHR.getResponseHeader("X-Login-Url") + "?next=" + encodeURIComponent(location.pathname + location.search + location.hash);
return;
}
waitingDialog.hide();
if (textStatus === "timeout") {
alert(gettext("The request took too long. Please try again."));
@@ -58,13 +58,14 @@ var i18nToString = function (i18nstring) {
};
$(document).ajaxError(function (event, jqXHR, settings, thrownError) {
waitingDialog.hide();
var c = $(jqXHR.responseText).filter('.container');
if (jqXHR.responseText && jqXHR.responseText.indexOf("<!-- pretix-login-marker -->") !== -1) {
location.href = '/control/login?next=' + encodeURIComponent(location.pathname + location.search + location.hash)
if (jqXHR.status === 401 && jqXHR.getResponseHeader("X-Login-Url")) {
window.location = jqXHR.getResponseHeader("X-Login-Url") + "?next=" + encodeURIComponent(location.pathname + location.search + location.hash);
} else if (c.length > 0) {
waitingDialog.hide();
ajaxErrDialog.show(c.first().html());
} else if (thrownError !== "abort" && thrownError !== "") {
waitingDialog.hide();
console.error(event, jqXHR, settings, thrownError);
alert(gettext('Unknown error.'));
}
+10
View File
@@ -964,6 +964,8 @@ def test_redeemed_is_not_writable(token_client, organizer, event, item):
@pytest.mark.django_db
def test_create_multiple_vouchers(token_client, organizer, event, item):
with scopes_disabled():
event.quotas.create(name="Q", size=100).items.add(item)
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
data=[
@@ -1012,6 +1014,8 @@ def test_create_multiple_vouchers(token_client, organizer, event, item):
@pytest.mark.django_db
def test_create_multiple_vouchers_one_invalid(token_client, organizer, event, item):
with scopes_disabled():
event.quotas.create(name="Q", size=100).items.add(item)
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
data=[
@@ -1055,6 +1059,8 @@ def test_create_multiple_vouchers_one_invalid(token_client, organizer, event, it
@pytest.mark.django_db
def test_create_multiple_vouchers_duplicate_code(token_client, organizer, event, item):
with scopes_disabled():
event.quotas.create(name="Q", size=100).items.add(item)
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
data=[
@@ -1098,6 +1104,8 @@ def test_create_multiple_vouchers_duplicate_code(token_client, organizer, event,
@pytest.mark.django_db
def test_create_multiple_vouchers_autogenerate_codes(token_client, organizer, event, item):
with scopes_disabled():
event.quotas.create(name="Q", size=100).items.add(item)
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
data=[
@@ -1157,6 +1165,8 @@ def seat1(item, event):
@pytest.mark.django_db
def test_create_multiple_vouchers_duplicate_seat(token_client, organizer, event, item, seat1, seatingplan):
with scopes_disabled():
event.quotas.create(name="Q", size=100).items.add(item)
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/vouchers/batch_create/'.format(organizer.slug, event.slug),
data=[
+437 -13
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, WaitingListEntry,
Event, Item, ItemVariation, Order, OrderPosition, Organizer, Quota,
SeatingPlan, Team, User, Voucher, WaitingListEntry,
)
@@ -134,49 +135,49 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase):
def test_filter_status_valid(self):
with scopes_disabled():
v = self.event.vouchers.create(item=self.ticket)
doc = self.client.get('/control/event/%s/%s/vouchers/?status=v' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=v' % (self.orga.slug, self.event.slug))
assert v.code in doc.content.decode()
v.redeemed = 1
v.save()
doc = self.client.get('/control/event/%s/%s/vouchers/?status=v' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=v' % (self.orga.slug, self.event.slug))
assert v.code not in doc.content.decode()
def test_filter_status_redeemed(self):
with scopes_disabled():
v = self.event.vouchers.create(item=self.ticket, redeemed=1)
doc = self.client.get('/control/event/%s/%s/vouchers/?status=r' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=r' % (self.orga.slug, self.event.slug))
assert v.code in doc.content.decode()
v.redeemed = 0
v.save()
doc = self.client.get('/control/event/%s/%s/vouchers/?status=r' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=r' % (self.orga.slug, self.event.slug))
assert v.code not in doc.content.decode()
def test_filter_status_expired(self):
with scopes_disabled():
v = self.event.vouchers.create(item=self.ticket, valid_until=now() + datetime.timedelta(days=1))
doc = self.client.get('/control/event/%s/%s/vouchers/?status=e' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=e' % (self.orga.slug, self.event.slug))
assert v.code not in doc.content.decode()
v.valid_until = now() - datetime.timedelta(days=1)
v.save()
doc = self.client.get('/control/event/%s/%s/vouchers/?status=e' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-status=e' % (self.orga.slug, self.event.slug))
assert v.code in doc.content.decode()
def test_filter_tag(self):
with scopes_disabled():
self.event.vouchers.create(item=self.ticket, code='ABCDEFG', comment='Foo', tag='bar')
doc = self.client.get('/control/event/%s/%s/vouchers/?tag=bar' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-tag=bar' % (self.orga.slug, self.event.slug))
assert 'ABCDEFG' in doc.content.decode()
doc = self.client.get('/control/event/%s/%s/vouchers/?tag=baz' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-tag=baz' % (self.orga.slug, self.event.slug))
assert 'ABCDEFG' not in doc.content.decode()
def test_search_code(self):
with scopes_disabled():
self.event.vouchers.create(item=self.ticket, code='ABCDEFG', comment='Foo')
doc = self.client.get('/control/event/%s/%s/vouchers/?search=ABCDEFG' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-search=ABCDEFG' % (self.orga.slug, self.event.slug))
assert 'ABCDEFG' in doc.content.decode()
doc = self.client.get('/control/event/%s/%s/vouchers/?search=Foo' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-search=Foo' % (self.orga.slug, self.event.slug))
assert 'ABCDEFG' in doc.content.decode()
doc = self.client.get('/control/event/%s/%s/vouchers/?search=12345' % (self.orga.slug, self.event.slug))
doc = self.client.get('/control/event/%s/%s/vouchers/?filter-search=12345' % (self.orga.slug, self.event.slug))
assert 'ABCDEFG' not in doc.content.decode()
def test_bulk_rng(self):
@@ -806,3 +807,426 @@ class VoucherFormTest(SoupTestMixin, TransactionTestCase):
assert 'walk-ins' in names
assert 'waiting-list' not in names
class VoucherBulkEditFormTest(SoupTestMixin, TransactionTestCase):
@scopes_disabled()
def setUp(self):
super().setUp()
self.user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
self.orga = Organizer.objects.create(name='CCC', slug='ccc')
self.event = Event.objects.create(
organizer=self.orga, name='30C3', slug='30c3',
date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc),
)
t = Team.objects.create(organizer=self.orga, all_event_permissions=True)
t.members.add(self.user)
t.limit_events.add(self.event)
self.client.login(email='dummy@dummy.dummy', password='dummy')
self.quota_shirts = Quota.objects.create(event=self.event, name='Shirts', size=2)
self.shirt = Item.objects.create(event=self.event, name='T-Shirt', default_price=12)
self.quota_shirts.items.add(self.shirt)
self.shirt_red = ItemVariation.objects.create(item=self.shirt, default_price=14, value='Red')
self.shirt_blue = ItemVariation.objects.create(item=self.shirt, value='Blue')
self.quota_shirts.variations.add(self.shirt_red)
self.quota_shirts.variations.add(self.shirt_blue)
self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=2)
self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket',
default_price=23)
self.quota_tickets.items.add(self.ticket)
self.url = f'/control/event/{self.orga.slug}/{self.event.slug}/vouchers/bulk_edit'
def test_simple_edit(self):
with scopes_disabled():
self.event.vouchers.create(
quota=self.quota_tickets,
max_usages=10,
price_mode="set",
value=13,
)
self.event.vouchers.create(
item=self.ticket,
max_usages=10,
price_mode="set",
value=12,
)
doc = self.post_doc(self.url, {
'__ALL': 'on',
}, follow=True)
fields = extract_form_fields(doc)
assert fields.get('bulkedit-max_usages') == '10'
assert fields.get('bulkedit-price_mode') == 'set'
assert not fields.get('bulkedit-value')
fields.update({
'_bulk': ['bulkedit__price', 'bulkeditmin_usages', 'bulkedittag', 'bulkeditshow_hidden_items'],
'bulkedit-price_mode': 'percent',
'bulkedit-value': '15',
'bulkedit-min_usages': '3',
'bulkedit-tag': 'tagged',
'bulkedit-comment': 'This is a comment', # will be ignored, as not included in _bulk
'bulkedit-show_hidden_items': '',
})
doc = self.post_doc(self.url, fields, follow=True)
assert doc.select(".alert-success")
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.price_mode == "percent"
assert v.value == Decimal("15.00")
assert v.min_usages == 3
assert v.tag == "tagged"
assert v.comment == ""
assert v.show_hidden_items is False
def _update_all(self, data: dict, expect_error: str=None):
doc = self.post_doc(self.url, {
'__ALL': 'on',
}, follow=True)
fields = extract_form_fields(doc)
fields.update(data)
doc = self.post_doc(self.url, fields, follow=True)
error_texts = [el.text for el in doc.select(".alert-danger, .has-error")]
if expect_error:
assert doc.select(".alert-danger")
assert any(expect_error in t for t in error_texts), error_texts
else:
assert doc.select(".alert-success"), error_texts
def test_change_itemvar_to_product(self):
with scopes_disabled():
self.event.vouchers.create(quota=self.quota_tickets)
self.event.vouchers.create(item=self.ticket)
self._update_all({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': f'{self.ticket.pk}',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.item == self.ticket
assert not v.variation
assert not v.quota
def test_change_itemvar_to_variation(self):
with scopes_disabled():
self.event.vouchers.create(quota=self.quota_tickets)
self.event.vouchers.create(item=self.ticket)
self._update_all({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.item == self.shirt
assert v.variation == self.shirt_red
assert not v.quota
def test_change_itemvar_to_quota(self):
with scopes_disabled():
self.event.vouchers.create(quota=self.quota_tickets)
self.event.vouchers.create(item=self.ticket)
self._update_all({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': f'q-{self.quota_tickets.pk}',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert not v.item
assert not v.variation
assert v.quota == self.quota_tickets
def test_change_itemvar_to_all(self):
with scopes_disabled():
self.event.vouchers.create(quota=self.quota_tickets)
self.event.vouchers.create(item=self.ticket)
self._update_all({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': '',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert not v.item
assert not v.variation
assert not v.quota
def test_change_max_usages(self):
with scopes_disabled():
self.event.vouchers.create(quota=self.quota_tickets, max_usages=15, redeemed=4)
self.event.vouchers.create(item=self.ticket, max_usages=15, redeemed=2)
self._update_all({
'_bulk': ['bulkeditmax_usages'],
'bulkedit-max_usages': '3',
}, expect_error="already been redeemed 4 times")
self._update_all({
'_bulk': ['bulkeditmax_usages'],
'bulkedit-max_usages': '4',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.max_usages == 4
def _requires_one_more_quota(self, data: dict, quota=None):
self._update_all(data, expect_error="no sufficient quota")
quota = quota or self.quota_tickets
quota.size += 1
quota.save()
self._update_all(data)
def test_quota_check_change_item(self):
with scopes_disabled():
self.event.vouchers.create(item=self.shirt, block_quota=True, max_usages=2, redeemed=1)
self.event.vouchers.create(item=self.shirt, block_quota=True, max_usages=3, redeemed=1)
self._requires_one_more_quota({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': f'{self.ticket.pk}',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.item == self.ticket
def test_quota_check_change_variation(self):
with scopes_disabled():
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2, redeemed=1)
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=3, redeemed=1)
self._requires_one_more_quota({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': f'{self.shirt.pk}-{self.shirt_red.pk}',
}, quota=self.quota_shirts)
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.item == self.shirt
assert v.variation == self.shirt_red
def test_quota_check_change_item_with_variations(self):
with scopes_disabled():
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2, redeemed=1)
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=3, redeemed=1)
self._requires_one_more_quota({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': f'{self.shirt.pk}',
}, quota=self.quota_shirts)
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.item == self.shirt
assert not v.variation
def test_quota_check_change_expired_to_valid(self):
with scopes_disabled():
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2)
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=1, valid_until=now() - datetime.timedelta(days=1))
self._requires_one_more_quota({
'_bulk': ['bulkeditvalid_until'],
'bulkedit-valid_until_0': '',
'bulkedit-valid_until_1': '',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert not v.valid_until
def test_quota_check_change_max_usages(self):
with scopes_disabled():
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=2)
self.event.vouchers.create(item=self.ticket, block_quota=True, max_usages=1, redeemed=1)
self._requires_one_more_quota({
'_bulk': ['bulkeditmax_usages'],
'bulkedit-max_usages': '2',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.max_usages == 2
def test_quota_check_no_change(self):
with scopes_disabled():
# Technically overbooked, but we don't have a diff in quota
self.event.vouchers.create(item=self.shirt, variation=self.shirt_red, block_quota=True)
self.event.vouchers.create(item=self.shirt, variation=self.shirt_red, block_quota=True)
self.event.vouchers.create(item=self.shirt, variation=self.shirt_red, block_quota=True)
self._update_all({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': f'{self.shirt.pk}-{self.shirt_blue.pk}',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.variation == self.shirt_blue
def test_quota_check_change_subevent(self):
with scopes_disabled():
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.quota_tickets.subevent = se1
self.quota_tickets.save()
Quota.objects.create(event=self.event, subevent=se2, name='Tickets', size=3)
self.event.vouchers.create(item=self.ticket, block_quota=True, subevent=se2)
self.event.vouchers.create(item=self.ticket, block_quota=True, subevent=se2)
self.event.vouchers.create(item=self.ticket, block_quota=True, subevent=se2)
self._requires_one_more_quota({
'_bulk': ['bulkeditsubevent'],
'bulkedit-subevent': f'{se1.pk}',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.subevent == se1
def test_change_subevent_quota_invalid(self):
with scopes_disabled():
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.quota_tickets.subevent = se1
self.quota_tickets.save()
v1 = self.event.vouchers.create(quota=self.quota_tickets, block_quota=True, subevent=se1)
self._update_all({
'_bulk': ['bulkeditsubevent'],
'bulkedit-subevent': f'{se2.pk}',
}, expect_error="selected quota does not match the selected subevent")
self._update_all({
'_bulk': ['bulkeditsubevent'],
'bulkedit-subevent': '',
}, expect_error="has no date selected")
v1.quota = None
v1.item = self.ticket
v1.save()
self._update_all({
'_bulk': ['bulkeditsubevent'],
'bulkedit-subevent': '',
}, expect_error="If you want this voucher to block quota, you need to select a specific date")
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.subevent == se1
def test_change_missing_itemvar_with_block_quota(self):
with scopes_disabled():
self.event.vouchers.create(quota=self.quota_tickets, block_quota=True)
self.event.vouchers.create(quota=self.quota_tickets, block_quota=True)
self._update_all({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': '',
}, expect_error="You need to select a specific product or quota if this voucher should reserve")
self._update_all({
'_bulk': ['bulkedititemvar', 'bulkeditblock_quota'],
'bulkedit-itemvar': '',
'bulkedit-block_quota': '',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert not v.subevent
assert not v.block_quota
def test_change_subevent_and_quota(self):
with scopes_disabled():
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
se2 = self.event.subevents.create(name="Bar", date_from=now())
self.quota_tickets.subevent = se1
self.quota_tickets.save()
q2 = Quota.objects.create(event=self.event, subevent=se2, name='Tickets', size=3)
self.event.vouchers.create(quota=self.quota_tickets, block_quota=True, subevent=se1)
self._update_all({
'_bulk': ['bulkedititemvar', 'bulkeditsubevent'],
'bulkedit-subevent': f'{se2.pk}',
'bulkedit-itemvar': f'q-{q2.pk}',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.subevent == se2
assert v.quota == q2
def test_quota_check_change_block_quota(self):
with scopes_disabled():
self.event.vouchers.create(item=self.ticket, max_usages=3)
self._requires_one_more_quota({
'_bulk': ['bulkeditblock_quota'],
'bulkedit-block_quota': 'on',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.block_quota
def test_ignore_quota(self):
with scopes_disabled():
self.event.vouchers.create(item=self.ticket, max_usages=3)
self._update_all({
'_bulk': ['bulkeditblock_quota', 'bulkeditallow_ignore_quota'],
'bulkedit-block_quota': 'on',
'bulkedit-allow_ignore_quota': 'on',
})
with scopes_disabled():
for v in self.event.vouchers.all():
assert v.block_quota
assert v.allow_ignore_quota
@scopes_disabled()
def _create_seat(self, **kwargs):
plan = SeatingPlan.objects.create(
name="Plan", organizer=self.orga, layout="{}"
)
self.event.seating_plan = plan
self.event.save()
return self.event.seats.create(seat_number="A1", product=self.ticket, seat_guid="A1", **kwargs)
def test_seated_unsupported(self):
with scopes_disabled():
self.event.vouchers.create(item=self.ticket, max_usages=1, seat=self._create_seat())
self._update_all({
'_bulk': ['bulkeditmax_usages'],
'bulkedit-max_usages': '2',
}, expect_error="Changing the maximum number of usages in bulk is not supported")
self._update_all({
'_bulk': ['bulkeditsubevent'],
'bulkedit-subevent': '',
}, expect_error="Changing the date in bulk is not supported")
self._update_all({
'_bulk': ['bulkedititemvar'],
'bulkedit-itemvar': f'q-{self.quota_tickets.pk}',
}, expect_error="Changing the product to a quota is not supported")
def test_seat_changed_to_valid_needs_to_be_available(self):
with scopes_disabled():
seat = self._create_seat(blocked=True)
self.event.vouchers.create(item=self.ticket, max_usages=1, valid_until=now() - datetime.timedelta(days=1), seat=seat)
self._update_all({
'_bulk': ['bulkeditvalid_until'],
'bulkedit-valid_until_0': '',
'bulkedit-valid_until_1': '',
}, expect_error="not all assigned seats of the vouchers are still available")
seat.blocked = False
seat.save()
self._update_all({
'_bulk': ['bulkeditvalid_until'],
'bulkedit-valid_until_0': '',
'bulkedit-valid_until_1': '',
})
def test_seat_changed_to_valid_needs_to_be_available_subevents(self):
with scopes_disabled():
self.event.has_subevents = True
self.event.save()
se1 = self.event.subevents.create(name="Foo", date_from=now())
seat = self._create_seat(subevent=se1, blocked=True)
self.event.vouchers.create(item=self.ticket, max_usages=1, valid_until=now() - datetime.timedelta(days=1), seat=seat, subevent=se1)
self._update_all({
'_bulk': ['bulkeditvalid_until'],
'bulkedit-valid_until_0': '',
'bulkedit-valid_until_1': '',
}, expect_error="not all assigned seats of the vouchers are still available")
seat.blocked = False
seat.save()
self._update_all({
'_bulk': ['bulkeditvalid_until'],
'bulkedit-valid_until_0': '',
'bulkedit-valid_until_1': '',
})
+91
View File
@@ -426,6 +426,97 @@ def test_sendmail_rule_checked_in_get_mail(event, order, item):
assert len(djmail.outbox) == 1, "email not sent"
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_checked_in_mixed_order(event, order, item):
order.status = Order.STATUS_PAID
order.save()
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test') # p2
clist = event.checkin_lists.create(name="Default", all_products=True)
# receives no mail when checked in
djmail.outbox = []
perform_checkin(p1, clist, {})
assert clist.checkin_count == 1
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="checked_in",
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_not_checked_in_mixed_order(event, order, item):
order.status = Order.STATUS_PAID
order.save()
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test') # p2
clist = event.checkin_lists.create(name="Default", all_products=True)
# receives no mail when checked in
djmail.outbox = []
perform_checkin(p1, clist, {})
assert clist.checkin_count == 1
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin",
subject='meow', template='meow meow meow')
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_not_checked_in_mixed_order_position_without_email_not_matching_status(event, order, item, item2):
order.status = Order.STATUS_PAID
order.save()
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
p2 = order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test')
clist = event.checkin_lists.create(name="Default", all_products=True)
# receives no mail when checked in
djmail.outbox = []
perform_checkin(p1, clist, {})
# we have no email and we are checked in
# we shouldn't trigger a fallback to order.email
p3 = order.all_positions.create(item=item, price=13)
perform_checkin(p3, clist, {})
assert clist.checkin_count == 2
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin",
subject='meow', template='meow meow meow', send_to=Rule.ATTENDEES)
sendmail_run_rules(None)
assert len(djmail.outbox) == 1
recipients = [m.to for m in djmail.outbox]
assert [p2.attendee_email] in recipients # for p2
@pytest.mark.django_db
@scopes_disabled()
def test_sendmail_rule_not_checked_in_mixed_order_position_without_email(event, order, item, item2):
order.status = Order.STATUS_PAID
order.save()
p1 = order.all_positions.create(item=item, price=13, attendee_email='item1@dummy.test')
p2 = order.all_positions.create(item=item, price=13, attendee_email='item2@dummy.test')
order.all_positions.create(item=item, price=13) # p3
order.all_positions.create(item=item, price=13) # p4
clist = event.checkin_lists.create(name="Default", all_products=True)
# receives no mail when checked in
djmail.outbox = []
perform_checkin(p1, clist, {})
assert clist.checkin_count == 1
event.sendmail_rules.create(send_date=dt_now - datetime.timedelta(hours=1), checked_in_status="no_checkin",
subject='meow', template='meow meow meow', send_to=Rule.ATTENDEES)
sendmail_run_rules(None)
assert len(djmail.outbox) == 2
recipients = [m.to for m in djmail.outbox]
assert [order.email] in recipients # for p3 and p4
assert [p2.attendee_email] in recipients # for p2
@pytest.mark.django_db
@scopes_disabled()
def run_restriction_test(event, order, restrictions_pass=[], restrictions_fail=[]):