From 9f2ffc3276b96ed1e5f3bfb5382f5b963b5112d4 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 27 Sep 2021 20:48:02 +0200 Subject: [PATCH] Improvements around the waiting list (#2219) * Waiting list: Support for seated events, pre-fill customer email address * Allow people to remove themselves * Update src/pretix/base/settings.py Co-authored-by: Richard Schreiber * Update src/pretix/control/views/waitinglist.py Co-authored-by: Richard Schreiber * Update src/pretix/control/views/waitinglist.py Co-authored-by: Richard Schreiber * Update src/pretix/control/views/waitinglist.py Co-authored-by: Richard Schreiber * Update src/pretix/presale/views/waiting.py Co-authored-by: Richard Schreiber * Resolve a review note * Review notes * Fix linter issues * Fix import Co-authored-by: Richard Schreiber --- src/pretix/base/email.py | 10 ++ src/pretix/base/models/waitinglist.py | 40 +++++++- src/pretix/base/services/waitinglist.py | 27 +++++- src/pretix/base/settings.py | 6 ++ .../pretixcontrol/event/settings.html | 15 ++- src/pretix/control/views/waitinglist.py | 20 +++- src/pretix/presale/forms/waitinglist.py | 51 ++++++++++- .../templates/pretixpresale/event/index.html | 24 +++++ .../pretixpresale/event/waitinglist.html | 7 -- .../event/waitinglist_remove.html | 20 ++++ src/pretix/presale/urls.py | 1 + src/pretix/presale/views/event.py | 29 +++++- src/pretix/presale/views/waiting.py | 91 ++++++++++++------- src/tests/base/test_waitinglist.py | 19 ++++ src/tests/presale/test_event.py | 61 +++++++++++-- 15 files changed, 359 insertions(+), 62 deletions(-) create mode 100644 src/pretix/presale/templates/pretixpresale/event/waitinglist_remove.html 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 @@
{% blocktrans trimmed %} The waiting list currently is not compatible with some advanced features of pretix such as - seating plans, add-on products or product bundles. + add-on products or product bundles. {% endblocktrans %}
+
+ {% blocktrans trimmed %} + The waiting list determines availability mainly based on quotas. If you use a seating plan and your + number of available seats is less than the available quota, you might run into situations where + people are sent an email from the waiting list but still are unable to book a seat. + {% endblocktrans %} + + {% blocktrans trimmed %} + Specifically, this means the waiting list is not safe to use together with the minimum distance + feature of our seating plan module. + {% endblocktrans %} + +
{% bootstrap_field sform.waiting_list_enabled layout="control" %} {% bootstrap_field sform.waiting_list_auto layout="control" %} {% bootstrap_field sform.waiting_list_hours layout="control" %} diff --git a/src/pretix/control/views/waitinglist.py b/src/pretix/control/views/waitinglist.py index f459f9d82d..bb8c88d98a 100644 --- a/src/pretix/control/views/waitinglist.py +++ b/src/pretix/control/views/waitinglist.py @@ -50,7 +50,7 @@ from django.views import View from django.views.generic import ListView from django.views.generic.edit import DeleteView -from pretix.base.models import Item, WaitingListEntry +from pretix.base.models import Item, Quota, WaitingListEntry from pretix.base.models.waitinglist import WaitingListException from pretix.base.services.waitinglist import assign_automatically from pretix.base.views.tasks import AsyncAction @@ -239,8 +239,24 @@ class WaitingListView(EventPermissionRequiredMixin, WaitingListQuerySetMixin, Pa if wle.variation else wle.item.check_quotas(count_waitinglist=False, subevent=wle.subevent, _cache=quota_cache) ) + if wle.availability[0] == Quota.AVAILABILITY_OK and ev.seat_category_mappings.filter(product=wle.item).exists(): + # See comment in WaitingListEntry.send_voucher() for rationale + num_free_seats_for_product = ev.free_seats().filter(product=wle.item).count() + num_valid_vouchers_for_product = self.request.event.vouchers.filter( + Q(valid_until__isnull=True) | Q(valid_until__gte=now()), + block_quota=True, + item_id=wle.item_id, + subevent=wle.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 + wle.availability = ( + Quota.AVAILABILITY_GONE if free_seats == 0 else wle.availability[0], + min(free_seats, wle.availability[1]) + ) + itemvar_cache[(wle.item, wle.variation, wle.subevent)] = wle.availability - if wle.availability[0] == 100: + if wle.availability[0] == Quota.AVAILABILITY_OK: any_avail = True ctx['any_avail'] = any_avail diff --git a/src/pretix/presale/forms/waitinglist.py b/src/pretix/presale/forms/waitinglist.py index 216460aa95..24060cec16 100644 --- a/src/pretix/presale/forms/waitinglist.py +++ b/src/pretix/presale/forms/waitinglist.py @@ -20,6 +20,7 @@ # . # from django import forms +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.translation import gettext_lazy as _ from phonenumber_field.formfields import PhoneNumberField from phonenumbers.data import _COUNTRY_CODE_TO_REGION_CODE @@ -28,7 +29,8 @@ from pretix.base.forms.questions import ( NamePartsFormField, WrappedPhoneNumberPrefixWidget, guess_country, ) from pretix.base.i18n import get_babel_locale, language -from pretix.base.models import WaitingListEntry +from pretix.base.models import Quota, WaitingListEntry +from pretix.presale.views.event import get_grouped_items class WaitingListForm(forms.ModelForm): @@ -40,8 +42,35 @@ class WaitingListForm(forms.ModelForm): def __init__(self, *args, **kwargs): self.event = kwargs.pop('event') + self.channel = kwargs.pop('channel') super().__init__(*args, **kwargs) + choices = [ + ('', '') + ] + items, display_add_to_cart = get_grouped_items( + self.event, self.instance.subevent, require_seat=None + ) + for i in items: + if not i.allow_waitinglist: + continue + + if i.has_variations: + for v in i.available_variations: + if v.cached_availability[0] == Quota.AVAILABILITY_OK: + continue + choices.append((f'{i.pk}-{v.pk}', f'{i.name} – {v.value}')) + + else: + if i.cached_availability[0] == Quota.AVAILABILITY_OK: + continue + choices.append((f'{i.pk}', f'{i.name}')) + + self.fields['itemvar'] = forms.ChoiceField( + label=_('Product'), + choices=choices, + ) + event = self.event if event.settings.waiting_list_names_asked: @@ -73,3 +102,23 @@ class WaitingListForm(forms.ModelForm): ) else: del self.fields['phone'] + + def clean(self): + try: + iv = self.data.get('itemvar', '') + if '-' in iv: + itemid, varid = iv.split('-') + else: + itemid, varid = iv, None + + 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 + + except ObjectDoesNotExist: + raise ValidationError(_("Invalid product selected.")) + + data = super().clean() + return data diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index 3fe652eadd..9afddc2b8e 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -211,6 +211,30 @@ {% eventsignal event "pretix.presale.signals.render_seating_plan" request=request %} {% endif %} {% endif %} + + {% if waitinglist_seated %} + + {% endif %} + {% include "pretixpresale/event/fragment_product_list.html" %} {% if ev.presale_is_running and display_add_to_cart %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/waitinglist.html b/src/pretix/presale/templates/pretixpresale/event/waitinglist.html index c04c7ac7d6..05f2e8b383 100644 --- a/src/pretix/presale/templates/pretixpresale/event/waitinglist.html +++ b/src/pretix/presale/templates/pretixpresale/event/waitinglist.html @@ -6,13 +6,6 @@
{% csrf_token %}
-
- -
- -
-
{% if subevent %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/waitinglist_remove.html b/src/pretix/presale/templates/pretixpresale/event/waitinglist_remove.html new file mode 100644 index 0000000000..97f7412609 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/waitinglist_remove.html @@ -0,0 +1,20 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n eventurl bootstrap3 %} +{% block title %}{% trans "Waiting list" %}{% endblock %} +{% block content %} +

{% trans "Remove me from the waiting list" %}

+ + {% csrf_token %} +

+ {% blocktrans trimmed %} + You have been selected from our waiting list to buy a ticket. If you do not need the ticket any more, please be so kind and remove your ticket from the list + so we can pass it on to the next person waiting as quickly as possible! + {% endblocktrans %} +

+

+ +

+ +{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 3b93a3806d..7d03beae59 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -68,6 +68,7 @@ frame_wrapped_urls = [ re_path(r'^(?P[0-9]+)/seatingframe/$', pretix.presale.views.event.SeatingPlanView.as_view(), name='event.seatingplan'), re_path(r'^(?P[0-9]+)/$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'), + re_path(r'^waitinglist/remove$', pretix.presale.views.waiting.WaitingRemoveView.as_view(), name='event.waitinglist.remove'), re_path(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'), re_path(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'), ] diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 59719ca577..b0c95e838e 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -180,7 +180,7 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require ).order_by('category__position', 'category_id', 'position', 'name') if require_seat: items = items.filter(requires_seat__gt=0) - else: + elif require_seat is not None: items = items.filter(requires_seat=0) if filter_items: @@ -427,14 +427,38 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): self.request.event.cache.set('vouchers_exist', vouchers_exist) context['show_vouchers'] = context['vouchers_exist'] = vouchers_exist + context['ev'] = self.subevent or self.request.event + context['subevent'] = self.subevent + + context['allow_waitinglist'] = self.request.event.settings.waiting_list_enabled and context['ev'].presale_is_running + if not self.request.event.has_subevents or self.subevent: # Fetch all items items, display_add_to_cart = get_grouped_items( self.request.event, self.subevent, filter_items=self.request.GET.getlist('item'), filter_categories=self.request.GET.getlist('category'), + require_seat=None, channel=self.request.sales_channel.identifier ) + + context['waitinglist_seated'] = False + if context['allow_waitinglist']: + for i in items: + if not i.allow_waitinglist or not i.requires_seat: + continue + + if i.has_variations: + for v in i.available_variations: + if v.cached_availability[0] != Quota.AVAILABILITY_OK: + context['waitinglist_seated'] = True + break + else: + if i.cached_availability[0] != Quota.AVAILABILITY_OK: + context['waitinglist_seated'] = True + break + + items = [i for i in items if not i.requires_seat] context['itemnum'] = len(items) context['allfree'] = all( item.display_price.gross == Decimal('0.00') for item in items if not item.has_variations @@ -450,11 +474,8 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView): context['items_by_category'] = item_group_by_category(items) context['display_add_to_cart'] = display_add_to_cart - context['ev'] = self.subevent or self.request.event - context['subevent'] = self.subevent context['cart'] = self.get_cart() context['has_addon_choices'] = any(cp.has_addon_choices for cp in get_cart(self.request)) - context['allow_waitinglist'] = self.request.event.settings.waiting_list_enabled and context['ev'].presale_is_running if self.subevent: context['frontpage_text'] = str(self.subevent.frontpage_text) diff --git a/src/pretix/presale/views/waiting.py b/src/pretix/presale/views/waiting.py index 9d91ce72f3..cae3a938e2 100644 --- a/src/pretix/presale/views/waiting.py +++ b/src/pretix/presale/views/waiting.py @@ -19,21 +19,24 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +from datetime import timedelta + from django.conf import settings from django.contrib import messages +from django.db import transaction from django.shortcuts import get_object_or_404, redirect, render from django.utils.decorators import method_decorator -from django.utils.functional import cached_property +from django.utils.timezone import now from django.utils.translation import gettext_lazy as _, pgettext_lazy -from django.views.generic import FormView +from django.views.generic import FormView, TemplateView -from pretix.base.models.event import SubEvent +from pretix.base.models import Quota, SubEvent from pretix.base.templatetags.urlreplace import url_replace from pretix.multidomain.urlreverse import eventreverse from pretix.presale.views import EventViewMixin from ...base.i18n import get_language_without_region -from ...base.models import Item, ItemVariation, WaitingListEntry +from ...base.models import Voucher, WaitingListEntry from ..forms.waitinglist import WaitingListForm from . import allow_frame_if_namespaced @@ -47,17 +50,23 @@ class WaitingView(EventViewMixin, FormView): kwargs = super().get_form_kwargs() kwargs['event'] = self.request.event kwargs['instance'] = WaitingListEntry( - item=self.item_and_variation[0], variation=self.item_and_variation[1], event=self.request.event, locale=get_language_without_region(), subevent=self.subevent ) + kwargs['channel'] = self.request.sales_channel.identifier + kwargs.setdefault('initial', {}) + if 'var' in self.request.GET: + kwargs['initial']['itemvar'] = f'{self.request.GET.get("item")}-{self.request.GET.get("var")}' + else: + kwargs['initial']['itemvar'] = self.request.GET.get("item") + if getattr(self.request, 'customer', None): + kwargs['initial']['email'] = self.request.customer.email return kwargs def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['event'] = self.request.event ctx['subevent'] = self.subevent - ctx['item'], ctx['variation'] = self.item_and_variation return ctx def get(self, request, *args, **kwargs): @@ -77,20 +86,6 @@ class WaitingView(EventViewMixin, FormView): return super().get(request, *args, **kwargs) - @cached_property - def item_and_variation(self): - try: - item = self.request.event.items.get(pk=self.request.GET.get('item')) - if 'var' in self.request.GET: - var = item.variations.get(pk=self.request.GET['var']) - elif item.has_variations: - return None - else: - var = None - return item, var - except (Item.DoesNotExist, ItemVariation.DoesNotExist, ValueError): - return None - def dispatch(self, request, *args, **kwargs): self.request = request @@ -106,14 +101,6 @@ class WaitingView(EventViewMixin, FormView): messages.error(request, _("The presale for this event has not yet started.")) return redirect(self.get_index_url()) - if not self.item_and_variation: - messages.error(request, _("We could not identify the product you selected.")) - return redirect(self.get_index_url()) - - if not self.item_and_variation[0].allow_waitinglist: - messages.error(request, _("The waiting list is disabled for this product.")) - return redirect(self.get_index_url()) - self.subevent = None if request.event.has_subevents: if 'subevent' in request.GET: @@ -127,11 +114,11 @@ class WaitingView(EventViewMixin, FormView): def form_valid(self, form): availability = ( - self.item_and_variation[1].check_quotas(count_waitinglist=True, subevent=self.subevent) - if self.item_and_variation[1] - else self.item_and_variation[0].check_quotas(count_waitinglist=True, subevent=self.subevent) + form.instance.variation.check_quotas(count_waitinglist=True, subevent=self.subevent) + if form.instance.variation + else form.instance.item.check_quotas(count_waitinglist=True, subevent=self.subevent) ) - if availability[0] == 100: + if availability[0] == Quota.AVAILABILITY_OK: messages.error(self.request, _("You cannot add yourself to the waiting list as this product is currently " "available.")) return redirect(self.get_index_url()) @@ -143,3 +130,43 @@ class WaitingView(EventViewMixin, FormView): def get_success_url(self): return self.get_index_url() + + +@method_decorator(allow_frame_if_namespaced, 'dispatch') +class WaitingRemoveView(EventViewMixin, TemplateView): + template_name = 'pretixpresale/event/waitinglist_remove.html' + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['event'] = self.request.event + ctx['voucher'] = self.voucher + return ctx + + def dispatch(self, request, *args, **kwargs): + self.request = request + + try: + self.voucher = self.request.event.vouchers.get( + code=request.GET.get("voucher", ""), + waitinglistentries__isnull=False, + ) + except Voucher.DoesNotExist: + messages.error(request, _("We could not find you on our waiting list.")) + return redirect(self.get_index_url()) + + if not self.voucher.is_active(): + messages.error(request, _("Your waiting list spot is no longer valid or already used. There's nothing more to do here.")) + return redirect(self.get_index_url()) + + return super().dispatch(request, *args, **kwargs) + + @transaction.atomic() + def post(self, request, *args, **kwargs): + self.voucher.valid_until = now() - timedelta(seconds=1) + self.voucher.save(update_fields=['valid_until']) + self.voucher.log_action('pretix.voucher.expired.waitinglist') + messages.success(request, _("Thank you very much! We will assign your spot on the waiting list to someone else.")) + return redirect(self.get_index_url()) + + def get_success_url(self): + return self.get_index_url() diff --git a/src/tests/base/test_waitinglist.py b/src/tests/base/test_waitinglist.py index 6dbc025baf..4b79fef597 100644 --- a/src/tests/base/test_waitinglist.py +++ b/src/tests/base/test_waitinglist.py @@ -68,6 +68,25 @@ class WaitingListTestCase(TestCase): with self.assertRaises(WaitingListException): wle.send_voucher() + @classscope(attr='o') + def test_send_no_seat(self): + self.quota.items.add(self.item1) + self.quota.size = 10 + self.quota.save() + self.event.seat_category_mappings.create( + layout_category='Stalls', product=self.item1 + ) + self.event.seats.create(seat_number="Foo", product=self.item1, seat_guid="Foo", blocked=True) + self.event.seats.create(seat_number="Bar", product=self.item1, seat_guid="Bar", blocked=True) + self.event.seats.create(seat_number="Baz", product=self.item1, seat_guid="Baz", blocked=True) + wle = WaitingListEntry.objects.create( + event=self.event, item=self.item1, email='foo@bar.com' + ) + with self.assertRaises(WaitingListException): + wle.send_voucher() + self.event.seats.create(seat_number="Baz", product=self.item1, seat_guid="Baz", blocked=False) + wle.send_voucher() + @classscope(attr='o') def test_send_double(self): self.quota.variations.add(self.var1) diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index b8f798b047..a048b65c67 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -960,7 +960,8 @@ class WaitingListTest(EventTestMixin, SoupTest): self.assertIn('waiting list', response.rendered_content) response = self.client.post( '/%s/%s/waitinglist/?item=%d' % (self.orga.slug, self.event.slug, self.item.pk), { - 'email': 'foo@bar.com' + 'email': 'foo@bar.com', + 'itemvar': str(self.item.pk) } ) self.assertEqual(response.status_code, 302) @@ -989,7 +990,8 @@ class WaitingListTest(EventTestMixin, SoupTest): self.assertIn('waiting list', response.rendered_content) response = self.client.post( '/%s/%s/waitinglist/?item=%d&subevent=%d' % (self.orga.slug, self.event.slug, self.item.pk, se1.pk), { - 'email': 'foo@bar.com' + 'email': 'foo@bar.com', + 'itemvar': str(self.item.pk) } ) self.assertEqual(response.status_code, 302) @@ -1000,10 +1002,14 @@ class WaitingListTest(EventTestMixin, SoupTest): assert wle.subevent == se1 def test_invalid_item(self): - response = self.client.get( - '/%s/%s/waitinglist/?item=%d' % (self.orga.slug, self.event.slug, self.item.pk + 1) + response = self.client.post( + '/%s/%s/waitinglist/' % (self.orga.slug, self.event.slug), + { + 'email': 'foo@bar.com', + 'itemvar': str(self.item.pk + 1), + } ) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, 200) def test_invalid_subevent(self): self.event.has_subevents = True @@ -1028,10 +1034,11 @@ class WaitingListTest(EventTestMixin, SoupTest): self.q.save() response = self.client.post( '/%s/%s/waitinglist/?item=%d' % (self.orga.slug, self.event.slug, self.item.pk), { - 'email': 'foo@bar.com' + 'email': 'foo@bar.com', + 'itemvar': str(self.item.pk) } ) - self.assertEqual(response.status_code, 302) + self.assertEqual(response.status_code, 200) with scopes_disabled(): self.assertFalse(WaitingListEntry.objects.filter(email='foo@bar.com').exists()) @@ -1048,12 +1055,48 @@ class WaitingListTest(EventTestMixin, SoupTest): q2.items.add(self.item) response = self.client.post( '/%s/%s/waitinglist/?item=%d&subevent=%d' % (self.orga.slug, self.event.slug, self.item.pk, se1.pk), { - 'email': 'foo@bar.com' + 'email': 'foo@bar.com', + 'itemvar': str(self.item.pk), + } + ) + self.assertEqual(response.status_code, 200) + with scopes_disabled(): + self.assertFalse(WaitingListEntry.objects.filter(email='foo@bar.com').exists()) + + def test_remove_valid(self): + with scopes_disabled(): + v = self.event.vouchers.create(item=self.item, block_quota=True) + WaitingListEntry.objects.create( + event=self.event, item=self.item, email='bar@bar.com', voucher=v + ) + response = self.client.get( + '/%s/%s/waitinglist/remove?voucher=%s' % (self.orga.slug, self.event.slug, v.code), { + } + ) + self.assertEqual(response.status_code, 200) + response = self.client.post( + '/%s/%s/waitinglist/remove?voucher=%s' % (self.orga.slug, self.event.slug, v.code), { } ) self.assertEqual(response.status_code, 302) + v.refresh_from_db() + assert not v.is_active() + + def test_remove_waiting_list_vouchers_only(self): with scopes_disabled(): - self.assertFalse(WaitingListEntry.objects.filter(email='foo@bar.com').exists()) + v = self.event.vouchers.create(item=self.item, block_quota=True) + response = self.client.get( + '/%s/%s/waitinglist/remove?voucher=%s' % (self.orga.slug, self.event.slug, v.code), { + } + ) + self.assertEqual(response.status_code, 302) + response = self.client.post( + '/%s/%s/waitinglist/remove?voucher=%s' % (self.orga.slug, self.event.slug, v.code), { + } + ) + self.assertEqual(response.status_code, 302) + v.refresh_from_db() + assert v.is_active() class DeadlineTest(EventTestMixin, TestCase):