diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 105debd8d4..1814fdc3af 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -462,6 +462,16 @@ def base_placeholders(sender, **kwargs): lambda waiting_list_entry, event: (waiting_list_entry.subevent or event).get_date_from_display(), lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display() ), + SimpleFunctionalMailTextPlaceholder( + 'url_remove', ['waiting_list_entry', 'event'], + lambda waiting_list_entry, event: build_absolute_uri( + event, 'presale:event.waitinglist.remove' + ) + '?voucher=' + waiting_list_entry.voucher.code, + lambda event: build_absolute_uri( + event, + 'presale:event.waitinglist.remove', + ) + '?voucher=68CYU2H6ZTP3WLK5', + ), SimpleFunctionalMailTextPlaceholder( 'url', ['waiting_list_entry', 'event'], lambda waiting_list_entry, event: build_absolute_uri( diff --git a/src/pretix/base/models/waitinglist.py b/src/pretix/base/models/waitinglist.py index b63eead57d..0208e1c4ff 100644 --- a/src/pretix/base/models/waitinglist.py +++ b/src/pretix/base/models/waitinglist.py @@ -21,8 +21,9 @@ # from datetime import timedelta -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models, transaction +from django.db.models import F, Q, Sum from django.utils.timezone import now from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_scopes import ScopedManager @@ -114,9 +115,12 @@ class WaitingListEntry(LoggedModel): return '%s waits for %s' % (str(self.email), str(self.item)) def clean(self): - WaitingListEntry.clean_duplicate(self.email, self.item, self.variation, self.subevent, self.pk) - WaitingListEntry.clean_itemvar(self.event, self.item, self.variation) - WaitingListEntry.clean_subevent(self.event, self.subevent) + try: + WaitingListEntry.clean_duplicate(self.email, self.item, self.variation, self.subevent, self.pk) + WaitingListEntry.clean_itemvar(self.event, self.item, self.variation) + WaitingListEntry.clean_subevent(self.event, self.subevent) + except ObjectDoesNotExist: + raise ValidationError('Invalid input') def save(self, *args, **kwargs): update_fields = kwargs.get('update_fields', []) @@ -147,6 +151,34 @@ class WaitingListEntry(LoggedModel): ) if availability[1] is None or availability[1] < 1: raise WaitingListException(_('This product is currently not available.')) + + ev = self.subevent or self.event + if ev.seat_category_mappings.filter(product=self.item).exists(): + # Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous + # to use in combination with seating plans. If your event has 50 seats and a quota of 50 and + # default settings, everything is fine and the waiting list will work as usual. However, as soon + # as those two numbers diverge, either due to misconfiguration or due to intentional features such + # as our COVID-19 minimum distance feature, things get ugly. Theoretically, there could be + # significant quota available but not a single seat! The waiting list would happily send out vouchers + # which do not work at all. Generally, we consider this a "known bug" and not fixable with the current + # design of the waiting list and seating features. + # However, we've put in a simple safeguard that makes sure the waiting list on its own does not screw + # everything up. Specifically, we will not send out vouchers if the number of available seats is less + # than the number of valid vouchers *issued through the waiting list*. Things can still go wrong due to + # manually created vouchers, manually blocked seats or the minimum distance feature, but this reduces + # the possible damage a bit. + num_free_seats_for_product = ev.free_seats().filter(product=self.item).count() + num_valid_vouchers_for_product = self.event.vouchers.filter( + Q(valid_until__isnull=True) | Q(valid_until__gte=now()), + block_quota=True, + item_id=self.item_id, + subevent_id=self.subevent_id, + waitinglistentries__isnull=False + ).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0 + free_seats = num_free_seats_for_product - num_valid_vouchers_for_product + if not free_seats: + raise WaitingListException(_('No seat with this product is currently available.')) + if self.voucher: raise WaitingListException(_('A voucher has already been sent to this person.')) if '@' not in self.email: diff --git a/src/pretix/base/services/waitinglist.py b/src/pretix/base/services/waitinglist.py index 601ef2e56f..5b2f37f613 100644 --- a/src/pretix/base/services/waitinglist.py +++ b/src/pretix/base/services/waitinglist.py @@ -22,12 +22,14 @@ import sys from datetime import timedelta -from django.db.models import Exists, OuterRef, Q +from django.db.models import Exists, F, OuterRef, Q, Sum from django.dispatch import receiver from django.utils.timezone import now from django_scopes import scopes_disabled -from pretix.base.models import Event, User, WaitingListEntry +from pretix.base.models import ( + Event, SeatCategoryMapping, User, WaitingListEntry, +) from pretix.base.models.waitinglist import WaitingListException from pretix.base.services.tasks import EventTask from pretix.base.signals import periodic_task @@ -43,6 +45,19 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None) quota_cache = {} gone = set() + seats_available = {} + + for m in SeatCategoryMapping.objects.filter(event=event).select_related('subevent'): + # See comment in WaitingListEntry.send_voucher() for rationale + num_free_seets_for_product = (m.subevent or event).free_seats().filter(product_id=m.product_id).count() + num_valid_vouchers_for_product = event.vouchers.filter( + Q(valid_until__isnull=True) | Q(valid_until__gte=now()), + block_quota=True, + item_id=m.product_id, + subevent_id=m.subevent_id, + waitinglistentries__isnull=False + ).aggregate(free=Sum(F('max_usages') - F('redeemed')))['free'] or 0 + seats_available[(m.product_id, m.subevent_id)] = num_free_seets_for_product - num_valid_vouchers_for_product qs = WaitingListEntry.objects.filter( event=event, voucher__isnull=True @@ -70,6 +85,11 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None) gone.add((wle.item, wle.variation, wle.subevent)) continue + if (wle.item_id, wle.subevent_id) in seats_available: + if seats_available[wle.item_id, wle.subevent_id] < 1: + gone.add((wle.item, wle.variation, wle.subevent)) + continue + quotas = (wle.variation.quotas.filter(subevent=wle.subevent) if wle.variation else wle.item.quotas.filter(subevent=wle.subevent)) @@ -91,6 +111,9 @@ def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None) quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0, quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize ) + + if (wle.item_id, wle.subevent_id) in seats_available: + seats_available[wle.item_id, wle.subevent_id] -= 1 else: gone.add((wle.item, wle.variation, wle.subevent)) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 17d1491962..9665fd3359 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1746,6 +1746,12 @@ Please note that this link is only valid within the next {hours} hours! We will reassign the ticket to the next person on the list if you do not redeem the voucher within that timeframe. +If you do NOT need a ticket any more, we kindly ask you to click the +following link to let us know. This way, we can send the ticket as quickly +as possible to the next person on the waiting list: + +{url_remove} + Best regards, Your {event} team""")) }, diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 2185e33a5a..bb653cc841 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -256,9 +256,22 @@