diff --git a/src/pretix/base/migrations/0051_auto_20170206_2027.py b/src/pretix/base/migrations/0051_auto_20170206_2027.py new file mode 100644 index 000000000..9d1b69b3f --- /dev/null +++ b/src/pretix/base/migrations/0051_auto_20170206_2027.py @@ -0,0 +1,42 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2017-02-06 20:27 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0050_orderposition_positionid_squashed_0061_event_location'), + ] + + operations = [ + migrations.CreateModel( + name='WaitingListEntry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='On waiting list since')), + ('email', models.EmailField(max_length=254, verbose_name='E-mail address')), + ('locale', models.CharField(default='en', max_length=190)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Event', verbose_name='Event')), + ('item', models.ForeignKey(help_text='The product the user waits for.', on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Item', verbose_name='Product')), + ('variation', models.ForeignKey(blank=True, help_text='The variation of the product selected above.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.ItemVariation', verbose_name='Product variation')), + ('voucher', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Voucher', verbose_name='Assigned voucher')), + ], + options={ + 'ordering': ['created'], + 'verbose_name': 'Waiting list entry', + 'verbose_name_plural': 'Waiting list entries', + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.AlterField( + model_name='cachedcombinedticket', + name='created', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 41055df9f..8fc549534 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -19,3 +19,4 @@ from .orders import ( ) from .organizer import Organizer, OrganizerPermission, OrganizerSetting from .vouchers import Voucher +from .waitinglist import WaitingListEntry diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index b0322271f..06f81f6af 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -231,7 +231,7 @@ class Item(LoggedModel): return False return True - def check_quotas(self, ignored_quotas=None, _cache=None): + def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None): """ This method is used to determine whether this Item is currently available for sale. @@ -253,7 +253,7 @@ class Item(LoggedModel): if self.variations.count() > 0: # NOQA raise ValueError('Do not call this directly on items which have variations ' 'but call this on their ItemVariation objects') - return min([q.availability(_cache=_cache) for q in check_quotas], + return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas], key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize)) @cached_property @@ -315,7 +315,7 @@ class ItemVariation(models.Model): if self.item: self.item.event.get_cache().clear() - def check_quotas(self, ignored_quotas=None, _cache=None) -> Tuple[int, int]: + def check_quotas(self, ignored_quotas=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]: """ This method is used to determine whether this ItemVariation is currently available for sale in terms of quotas. @@ -324,6 +324,7 @@ class ItemVariation(models.Model): quotas will be ignored in the calculation. If this leads to no quotas being checked at all, this method will return unlimited availability. + :param count_waitinglist: If ``False``, waiting list entries will be ignored for quota calculation. :returns: any of the return codes of :py:meth:`Quota.availability()`. """ check_quotas = set(self.quotas.all()) @@ -331,7 +332,7 @@ class ItemVariation(models.Model): check_quotas -= set(ignored_quotas) if not check_quotas: return Quota.AVAILABILITY_OK, sys.maxsize - return min([q.availability(_cache=_cache) for q in check_quotas], + return min([q.availability(count_waitinglist=count_waitinglist, _cache=_cache) for q in check_quotas], key=lambda s: (s[0], s[1] if s[1] is not None else sys.maxsize)) def __lt__(self, other): @@ -534,7 +535,7 @@ class Quota(LoggedModel): if self.event: self.event.get_cache().clear() - def availability(self, now_dt: datetime=None, _cache=None) -> Tuple[int, int]: + def availability(self, now_dt: datetime=None, count_waitinglist=True, _cache=None) -> Tuple[int, int]: """ This method is used to determine whether Items or ItemVariations belonging to this quota should currently be available for sale. @@ -542,14 +543,18 @@ class Quota(LoggedModel): :returns: a tuple where the first entry is one of the ``Quota.AVAILABILITY_`` constants and the second is the number of available tickets. """ + if _cache and count_waitinglist is not _cache.get('_count_waitinglist', True): + _cache.clear() + if _cache is not None and self.pk in _cache: return _cache[self.pk] - res = self._availability(now_dt) + res = self._availability(now_dt, count_waitinglist) if _cache is not None: _cache[self.pk] = res + _cache['_count_waitinglist'] = count_waitinglist return res - def _availability(self, now_dt: datetime=None): + def _availability(self, now_dt: datetime=None, count_waitinglist=True): now_dt = now_dt or now() size_left = self.size if size_left is None: @@ -572,6 +577,11 @@ class Quota(LoggedModel): if size_left <= 0: return Quota.AVAILABILITY_RESERVED, 0 + if count_waitinglist: + size_left -= self.count_waiting_list_pending() + if size_left <= 0: + return Quota.AVAILABILITY_RESERVED, 0 + return Quota.AVAILABILITY_OK, size_left def count_blocking_vouchers(self, now_dt: datetime=None) -> int: @@ -591,6 +601,13 @@ class Quota(LoggedModel): free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func)) )['free'] or 0 + def count_waiting_list_pending(self) -> int: + from pretix.base.models import WaitingListEntry + return WaitingListEntry.objects.filter( + Q(voucher__isnull=True) & + self._position_lookup + ).distinct().count() + def count_in_cart(self, now_dt: datetime=None) -> int: from pretix.base.models import CartPosition diff --git a/src/pretix/base/models/waitinglist.py b/src/pretix/base/models/waitinglist.py new file mode 100644 index 000000000..2df510cf4 --- /dev/null +++ b/src/pretix/base/models/waitinglist.py @@ -0,0 +1,130 @@ +from datetime import timedelta + +from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.i18n import language +from pretix.base.models import Voucher +from pretix.base.services.mail import mail +from pretix.multidomain.urlreverse import build_absolute_uri + +from .base import LoggedModel +from .event import Event +from .items import Item, ItemVariation + + +class WaitingListException(Exception): + pass + + +class WaitingListEntry(LoggedModel): + event = models.ForeignKey( + Event, + on_delete=models.CASCADE, + related_name="waitinglistentries", + verbose_name=_("Event"), + ) + created = models.DateTimeField( + verbose_name=_("On waiting list since"), + auto_now_add=True + ) + email = models.EmailField( + verbose_name=_("E-mail address") + ) + voucher = models.ForeignKey( + 'Voucher', + verbose_name=_("Assigned voucher"), + null=True, blank=True + ) + item = models.ForeignKey( + Item, related_name='waitinglistentries', + verbose_name=_("Product"), + help_text=_( + "The product the user waits for." + ) + ) + variation = models.ForeignKey( + ItemVariation, related_name='waitinglistentries', + null=True, blank=True, + verbose_name=_("Product variation"), + help_text=_( + "The variation of the product selected above." + ) + ) + locale = models.CharField( + max_length=190, + default='en' + ) + + class Meta: + verbose_name = _("Waiting list entry") + verbose_name_plural = _("Waiting list entries") + ordering = ['created'] + + def __str__(self): + return '%s waits for %s' % (str(self.email), str(self.item)) + + def clean(self): + if WaitingListEntry.objects.filter( + item=self.item, variation=self.variation, email=self.email, voucher__isnull=True + ).exclude(pk=self.pk).exists(): + raise ValidationError(_('You are already on this waiting list! We will notify ' + 'you as soon as we have a ticket available for you.')) + if not self.variation and self.item.has_variations: + raise ValidationError(_('Please select a specific variation of this product.')) + + def send_voucher(self, quota_cache=None): + availability = ( + self.variation.check_quotas(count_waitinglist=False, _cache=quota_cache) + if self.variation + else self.item.check_quotas(count_waitinglist=False, _cache=quota_cache) + ) + if availability[1] < 1: + raise WaitingListException(_('This product is currently not available.')) + if self.voucher: + raise WaitingListException(_('A voucher has already been sent to this person.')) + + with transaction.atomic(): + v = Voucher.objects.create( + event=self.event, + max_usages=1, + valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours), + item=self.item, + variation=self.variation, + tag='waiting-list', + comment=_('Automatically created from waiting list entry for {email}').format( + email=self.email + ), + block_quota=True, + ) + v.log_action('pretix.voucher.added.waitinglist', { + 'item': self.item.pk, + 'variation': self.variation.pk if self.variation else None, + 'tag': 'waiting-list', + 'block_quota': True, + 'valid_until': v.valid_until.isoformat(), + 'max_usages': 1, + 'email': self.email, + 'waitinglistentry': self.pk + }) + self.log_action('pretix.waitinglist.voucher') + self.voucher = v + self.save() + + with language(self.locale): + mail( + self.email, + _('You have been selected from the waitinglist for {event}').format(event=str(self.event)), + self.event.settings.mail_text_waiting_list, + { + 'event': self.event.name, + 'url': build_absolute_uri(self.event, 'presale:event.redeem') + '?voucher=' + self.voucher.code, + 'code': self.voucher.code, + 'product': str(self.item) + (' - ' + str(self.variation) if self.variation else ''), + 'hours': self.event.settings.waiting_list_hours, + }, + self.event, + locale=self.locale + ) diff --git a/src/pretix/base/services/waitinglist.py b/src/pretix/base/services/waitinglist.py new file mode 100644 index 000000000..ee532c61c --- /dev/null +++ b/src/pretix/base/services/waitinglist.py @@ -0,0 +1,56 @@ +from django.dispatch import receiver + +from pretix.base.models import Event, WaitingListEntry +from pretix.base.models.waitinglist import WaitingListException +from pretix.base.services.async import ProfiledTask +from pretix.base.signals import periodic_task +from pretix.celery_app import app + + +@app.task(base=ProfiledTask) +def assign_automatically(event_id: int): + event = Event.objects.get(id=event_id) + + quota_cache = {} + gone = set() + + qs = WaitingListEntry.objects.filter( + event=event, voucher__isnull=True + ).select_related('item', 'variation').prefetch_related('item__quotas', 'variation__quotas').order_by('created') + sent = 0 + + for wle in qs: + if (wle.item, wle.variation) in gone: + continue + + quotas = wle.variation.quotas.all() if wle.variation else wle.item.quotas.all() + availability = ( + wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache) + if wle.variation + else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache) + ) + if availability[1] > 0: + try: + wle.send_voucher(quota_cache) + sent += 1 + except WaitingListException: # noqa + continue + + # Reduce affected quotas in cache + for q in quotas: + quota_cache[q.pk] = ( + quota_cache[q.pk][0] if quota_cache[q.pk][0] > 1 else 0, + quota_cache[q.pk][1] - 1 + ) + else: + gone.add((wle.item, wle.variation)) + + return sent + + +@receiver(signal=periodic_task) +def process_waitinglist(sender, **kwargs): + qs = Event.objects.prefetch_related('setting_objects', 'organizer__setting_objects').select_related('organizer') + for e in qs: + if e.settings.waiting_list_enabled and e.settings.waiting_list_auto: + assign_automatically.apply_async(args=(e.pk,)) diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index cd416eeab..1d8756739 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -132,6 +132,18 @@ DEFAULTS = { 'default': 'False', 'type': bool }, + 'waiting_list_enabled': { + 'default': 'False', + 'type': bool + }, + 'waiting_list_auto': { + 'default': 'True', + 'type': bool + }, + 'waiting_list_hours': { + 'default': '48', + 'type': int + }, 'ticket_download': { 'default': 'False', 'type': bool @@ -258,6 +270,29 @@ your payment before {expire_date}. You can view the payment information and the status of your order at {url} +Best regards, +Your {event} team""")) + }, + 'mail_text_waiting_list': { + 'type': LazyI18nString, + 'default': LazyI18nString.from_gettext(ugettext_noop("""Hello, + +you submitted yourself to the waiting list for {event}, +for the product {product}. + +We now have a ticket ready for you! You can redeem it in our ticket shop +within the next {hours} hours by entering the following voucher code: + +{code} + +Alternatively, you can just click on the following link: + +{url} + +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. + Best regards, Your {event} team""")) }, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 9455a1cdc..a521d5d75 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -187,6 +187,27 @@ class EventSettingsForm(SettingsForm): help_text=_("Publicly show how many tickets of a certain type are still available."), required=False ) + waiting_list_enabled = forms.BooleanField( + label=_("Enable waiting list"), + help_text=_("Once a ticket is sold out, people can add themselves to a waiting list. As soon as a ticket " + "becomes available again, it will be reserved for the first person on the waiting list and this " + "person will receive an email notification with a voucher that can be used to buy a ticket."), + required=False + ) + waiting_list_hours = forms.IntegerField( + label=_("Waiting list response time"), + min_value=6, + help_text=_("If a ticket voucher is sent to a person on the waiting list, it has to be redeemed within this " + "number of hours until it expires and can be re-assigned to the next person on the list."), + required=False + ) + waiting_list_auto = forms.BooleanField( + label=_("Automatic waiting list assignments"), + help_text=_("If ticket capacity becomes free, automatically create a voucher and send it to the first person " + "on the waiting list for that product. If this is not active, mails will not be send automatically " + "but you can send them manually via the control panel."), + required=False + ) attendee_names_asked = forms.BooleanField( label=_("Ask for attendee names"), help_text=_("Ask for a name for all tickets which include admission to the event."), @@ -433,6 +454,12 @@ class MailSettingsForm(SettingsForm): widget=I18nTextarea, help_text=_("Available placeholders: {event}, {url}, {expire_date}, {invoice_name}, {invoice_company}") ) + mail_text_waiting_list = I18nFormField( + label=_("Text"), + required=False, + widget=I18nTextarea, + help_text=_("Available placeholders: {event}, {url}, {product}, {hours}, {code}") + ) smtp_use_custom = forms.BooleanField( label=_("Use custom SMTP server"), help_text=_("All mail related to your event will be sent over the smtp server specified by you."), diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 34867a339..383b225c7 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -85,6 +85,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.control.auth.user.forgot_password.mail_sent': _('Password reset mail sent.'), 'pretix.control.auth.user.forgot_password.recovered': _('The password has been reset.'), 'pretix.voucher.added': _('The voucher has been created.'), + 'pretix.voucher.added.waitinglist': _('The voucher has been created and sent to a person on the waiting list.'), 'pretix.voucher.changed': _('The voucher has been modified.'), 'pretix.voucher.deleted': _('The voucher has been deleted.'), 'pretix.voucher.redeemed': _('The voucher has been redeemed in order {order_code}.'), @@ -117,6 +118,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.permissions.invited': _('A user has been invited to the event team.'), 'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'), 'pretix.event.permissions.deleted': _('A user has been removed from the event team.'), + 'pretix.waitinglist.voucher': _('A voucher has been sent to a person on the waiting list.') } data = json.loads(logentry.data) diff --git a/src/pretix/control/templates/pretixcontrol/event/base.html b/src/pretix/control/templates/pretixcontrol/event/base.html index 36e228930..d1961f97c 100644 --- a/src/pretix/control/templates/pretixcontrol/event/base.html +++ b/src/pretix/control/templates/pretixcontrol/event/base.html @@ -84,6 +84,12 @@ {% trans "Export" %} +
  • + + {% trans "Waiting list" %} + +
  • {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/event/mail.html b/src/pretix/control/templates/pretixcontrol/event/mail.html index d435c4f64..c6ad0f474 100644 --- a/src/pretix/control/templates/pretixcontrol/event/mail.html +++ b/src/pretix/control/templates/pretixcontrol/event/mail.html @@ -99,6 +99,20 @@ +
    +
    +

    + + {% trans "Waiting list notification" %} + +

    +
    +
    +
    + {% bootstrap_field form.mail_text_waiting_list layout="horizontal" %} +
    +
    +
    diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index d8c4a6b65..6e4cdc760 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -42,6 +42,12 @@ {% bootstrap_field sform.attendee_names_required layout="horizontal" %} {% bootstrap_field sform.cancel_allow_user layout="horizontal" %}
    +
    + {% trans "Waiting list" %} + {% bootstrap_field sform.waiting_list_enabled layout="horizontal" %} + {% bootstrap_field sform.waiting_list_auto layout="horizontal" %} + {% bootstrap_field sform.waiting_list_hours layout="horizontal" %} +
    +
    + + + {% endif %} +
    +
    +
    + {% trans "Sales estimate" %} +
    +
    + {% blocktrans trimmed with amount=estimate|floatformat:2 currency=request.event.currency %} + If you can make enough room at your event to fit all the persons on the waiting list in, you + could sell tickets worth an additional {{ amount }} {{ currency }}. + {% endblocktrans %} +
    +
    +
    + + +

    +

    + + + +
    +

    +
    + {% csrf_token %} +
    + + + + + + + + + + + + {% for e in entries %} + + + + + + + + + {% endfor %} + +
    {% trans "User" %}{% trans "Product" %}{% trans "On the list since" %}{% trans "Status" %}{% trans "Voucher" %}
    {{ e.email }} + {{ e.item }} + {% if e.variation %} + – {{ e.variation }} + {% endif %} + {{ e.created|date:"SHORT_DATETIME_FORMAT" }} + {% if e.voucher %} + {% trans "Voucher assigned" %} + {% elif e.availability.0 == 100 %} + + {% blocktrans with num=e.availability.1 %} + Waiting, product {{ num }}x available + {% endblocktrans %} + + {% else %} + {% trans "Waiting, product unavailable" %} + {% endif %} + + {% if e.voucher %} + + {{ e.voucher }} + + {% elif not e.voucher and e.availability.0 == 100 %} + + {% endif %} + +
    +
    +
    + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 81ffff052..b184c5b2e 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import include, url from pretix.control.views import ( auth, dashboards, event, global_settings, help, item, main, orders, - organizer, user, vouchers, + organizer, user, vouchers, waitinglist, ) urlpatterns = [ @@ -121,6 +121,8 @@ urlpatterns = [ url(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'), url(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'), url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'), + url(r'^waitinglist/$', waitinglist.WaitingListView.as_view(), name='event.orders.waitinglist'), + url(r'^waitinglist/auto_assign$', waitinglist.AutoAssign.as_view(), name='event.orders.waitinglist.auto'), ])), url(r'^help/(?P[a-zA-Z0-9_/]+)$', help.HelpView.as_view(), name='help'), ] diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index f59a84f32..5b0485644 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -12,7 +12,9 @@ from django.utils import formats from django.utils.formats import date_format from django.utils.translation import ugettext_lazy as _ -from pretix.base.models import Event, Item, Order, OrderPosition, Voucher +from pretix.base.models import ( + Event, Item, Order, OrderPosition, Voucher, WaitingListEntry, +) from pretix.control.signals import ( event_dashboard_widgets, user_dashboard_widgets, ) @@ -85,9 +87,53 @@ def base_widgets(sender, **kwargs): ] +@receiver(signal=event_dashboard_widgets) +def waitinglist_widgets(sender, **kwargs): + widgets = [] + + wles = WaitingListEntry.objects.filter(event=sender) + if wles.count(): + quota_cache = {} + itemvar_cache = {} + happy = 0 + + for wle in wles: + if (wle.item, wle.variation) not in itemvar_cache: + itemvar_cache[(wle.item, wle.variation)] = ( + wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache) + if wle.variation + else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache) + ) + row = itemvar_cache.get((wle.item, wle.variation)) + if row[1] > 0: + itemvar_cache[(wle.item, wle.variation)] = (row[0], row[1] - 1) + happy += 1 + + widgets.append({ + 'content': NUM_WIDGET.format(num=str(happy), text=_('available to give to people on waiting list')), + 'priority': 50, + 'url': reverse('control:event.orders.waitinglist', kwargs={ + 'event': sender.slug, + 'organizer': sender.organizer.slug, + }) + }) + widgets.append({ + 'content': NUM_WIDGET.format(num=str(wles.count()), text=_('total waiting list length')), + 'display_size': 'small', + 'priority': 50, + 'url': reverse('control:event.orders.waitinglist', kwargs={ + 'event': sender.slug, + 'organizer': sender.organizer.slug, + }) + }) + + return widgets + + @receiver(signal=event_dashboard_widgets) def quota_widgets(sender, **kwargs): widgets = [] + for q in sender.quotas.all(): status, left = q.availability() widgets.append({ diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 4a3ce985d..765991649 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -607,24 +607,33 @@ class QuotaView(ChartContainingView, DetailView): data = [ { 'label': ugettext('Paid orders'), - 'value': self.object.count_paid_orders() + 'value': self.object.count_paid_orders(), + 'sum': True, }, { 'label': ugettext('Pending orders'), - 'value': self.object.count_pending_orders() + 'value': self.object.count_pending_orders(), + 'sum': True, }, { 'label': ugettext('Vouchers'), - 'value': self.object.count_blocking_vouchers() + 'value': self.object.count_blocking_vouchers(), + 'sum': True, }, { 'label': ugettext('Current user\'s carts'), - 'value': self.object.count_in_cart() - } + 'value': self.object.count_in_cart(), + 'sum': True, + }, + { + 'label': ugettext('Waiting list'), + 'value': self.object.count_waiting_list_pending(), + 'sum': False, + }, ] ctx['quota_table_rows'] = list(data) - sum_values = sum([d['value'] for d in data]) + sum_values = sum([d['value'] for d in data if d['sum']]) if self.object.size is not None: data.append({ diff --git a/src/pretix/control/views/waitinglist.py b/src/pretix/control/views/waitinglist.py new file mode 100644 index 000000000..a004301de --- /dev/null +++ b/src/pretix/control/views/waitinglist.py @@ -0,0 +1,127 @@ +from django.contrib import messages +from django.db.models import Sum +from django.db.models.functions import Coalesce +from django.shortcuts import redirect +from django.urls import reverse +from django.utils.translation import ugettext_lazy as _ +from django.views import View +from django.views.generic import ListView + +from pretix.base.models import Item, WaitingListEntry +from pretix.base.models.waitinglist import WaitingListException +from pretix.base.services.waitinglist import assign_automatically +from pretix.base.views.async import AsyncAction +from pretix.control.permissions import EventPermissionRequiredMixin + + +class AutoAssign(EventPermissionRequiredMixin, AsyncAction, View): + task = assign_automatically + known_errortypes = ['WaitingListError'] + permission = 'can_change_orders' + + def get_success_message(self, value): + return _('{num} vouchers have been created and sent out via email.').format(num=value) + + def get_success_url(self, value): + return self.get_error_url() + + def get_error_url(self): + return reverse('control:event.orders.waitinglist', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug + }) + + def post(self, request, *args, **kwargs): + return self.do(self.request.event.id) + + +class WaitingListView(EventPermissionRequiredMixin, ListView): + model = WaitingListEntry + context_object_name = 'entries' + paginate_by = 30 + template_name = 'pretixcontrol/waitinglist/index.html' + permission = 'can_view_orders' + + def post(self, request, *args, **kwargs): + if 'assign' in request.POST: + if not request.eventperm.can_change_orders: + messages.error(request, _('You do not have permission to do this')) + return redirect(reverse('control:event.orders.waitinglist', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug + })) + try: + wle = WaitingListEntry.objects.get( + pk=request.POST.get('assign'), event=self.request.event, + ) + try: + wle.send_voucher() + except WaitingListException as e: + messages.error(request, str(e)) + else: + messages.success(request, _('An email containing a voucher code has been sent to the ' + 'specified address.')) + return redirect(reverse('control:event.orders.waitinglist', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug + })) + except WaitingListEntry.DoesNotExist: + messages.error(request, _('Waiting list entry not found.')) + return redirect(reverse('control:event.orders.waitinglist', kwargs={ + 'event': request.event.slug, + 'organizer': request.event.organizer.slug + })) + + def get_queryset(self): + qs = WaitingListEntry.objects.filter( + event=self.request.event + ).select_related('item', 'variation', 'voucher').prefetch_related('item__quotas', 'variation__quotas') + + s = self.request.GET.get("status", "") + if s == 's': + qs = qs.filter(voucher__isnull=False) + elif s == 'a': + pass + else: + qs = qs.filter(voucher__isnull=True) + + if self.request.GET.get("item", "") != "": + i = self.request.GET.get("item", "") + qs = qs.filter(item_id__in=(i,)) + + return qs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['items'] = Item.objects.filter(event=self.request.event) + ctx['filtered'] = ("status" in self.request.GET or "item" in self.request.GET) + + itemvar_cache = {} + quota_cache = {} + any_avail = False + for wle in ctx[self.context_object_name]: + if (wle.item, wle.variation) in itemvar_cache: + wle.availability = itemvar_cache.get((wle.item, wle.variation)) + else: + wle.availability = ( + wle.variation.check_quotas(count_waitinglist=False, _cache=quota_cache) + if wle.variation + else wle.item.check_quotas(count_waitinglist=False, _cache=quota_cache) + ) + itemvar_cache[(wle.item, wle.variation)] = wle.availability + if wle.availability[0] == 100: + any_avail = True + + ctx['any_avail'] = any_avail + ctx['estimate'] = self.get_sales_estimate() + return ctx + + def get_sales_estimate(self): + qs = WaitingListEntry.objects.filter( + event=self.request.event, voucher__isnull=True + ).aggregate( + s=Sum( + Coalesce('variation__default_price', 'item__default_price') + ) + ) + return qs['s'] diff --git a/src/pretix/presale/forms/waitinglist.py b/src/pretix/presale/forms/waitinglist.py new file mode 100644 index 000000000..6ab9086c0 --- /dev/null +++ b/src/pretix/presale/forms/waitinglist.py @@ -0,0 +1,13 @@ +from django import forms + +from pretix.base.models import WaitingListEntry + + +class WaitingListForm(forms.ModelForm): + class Meta: + model = WaitingListEntry + fields = ('email',) + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event') + super().__init__(*args, **kwargs) diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html b/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html index a32f2133b..a19d0f40b 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_availability.html @@ -1,13 +1,28 @@ {% load i18n %} +{% load eventurl %} {% if avail == 0 %}
    {% trans "SOLD OUT" %} + {% if event.settings.waiting_list_enabled %} +
    + + + {% trans "Waiting list" %} + + {% endif %}
    {% elif avail < 100 %}
    - {% trans "Reserved" %}
    - - {% trans "All remaining products are reserved but might become available again." %} - + {% trans "Reserved" %} + + + {% if event.settings.waiting_list_enabled %} +
    + + + {% trans "Waiting list" %} + + {% endif %}
    {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index 4559c9064..c2c1f8204 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -156,7 +156,7 @@ name="variation_{{ item.id }}_{{ var.id }}"> {% else %} - {% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 %} + {% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 event=event item=item var=var %} {% endif %}
    @@ -211,7 +211,7 @@ max="{{ item.order_max }}" name="item_{{ item.id }}"> {% else %} - {% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 %} + {% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 event=event item=item var=0 %} {% endif %}
    diff --git a/src/pretix/presale/templates/pretixpresale/event/waitinglist.html b/src/pretix/presale/templates/pretixpresale/event/waitinglist.html new file mode 100644 index 000000000..c91158217 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/waitinglist.html @@ -0,0 +1,31 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n eventurl thumbnail bootstrap3 %} +{% block title %}{% trans "Waiting list" %}{% endblock %} +{% block content %} +

    {% trans "Add me to the waiting list" %}

    +
    + {% csrf_token %} +
    +
    + +
    + +
    +
    + {% bootstrap_form form layout='horizontal' %} +
    +
    +
    + {% blocktrans trimmed %} + If tickets become available again, we will inform the first persons on the waiting list. If we notify you, you'll have 48 hours time to buy a ticket until we assign it to the next person on the list. + {% endblocktrans %} +
    + +
    +
    +
    +
    +{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 02518f0ea..183c293d2 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -7,6 +7,7 @@ import pretix.presale.views.locale import pretix.presale.views.order import pretix.presale.views.organizer import pretix.presale.views.user +import pretix.presale.views.waiting # This is not a valid Django URL configuration, as the final # configuration is done by the pretix.multidomain package. @@ -14,6 +15,7 @@ import pretix.presale.views.user event_patterns = [ url(r'^cart/add$', pretix.presale.views.cart.CartAdd.as_view(), name='event.cart.add'), url(r'^cart/remove$', pretix.presale.views.cart.CartRemove.as_view(), name='event.cart.remove'), + url(r'^waitinglist', pretix.presale.views.waiting.WaitingView.as_view(), name='event.waitinglist'), url(r'^checkout/start$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout.start'), url(r'^redeem$', pretix.presale.views.cart.RedeemView.as_view(), name='event.redeem'), diff --git a/src/pretix/presale/views/waiting.py b/src/pretix/presale/views/waiting.py new file mode 100644 index 000000000..9c18aff93 --- /dev/null +++ b/src/pretix/presale/views/waiting.py @@ -0,0 +1,76 @@ +from django.contrib import messages +from django.shortcuts import redirect +from django.utils import translation +from django.utils.functional import cached_property +from django.utils.translation import ugettext_lazy as _ +from django.views.generic import FormView + +from ...base.models import Item, ItemVariation, WaitingListEntry +from ...multidomain.urlreverse import eventreverse +from ..forms.waitinglist import WaitingListForm + + +class WaitingView(FormView): + template_name = 'pretixpresale/event/waitinglist.html' + form_class = WaitingListForm + + def get_form_kwargs(self): + 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=translation.get_language() + ) + return kwargs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data() + ctx['event'] = self.request.event + ctx['item'], ctx['variation'] = self.item_and_variation + return ctx + + @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): + return None + + def dispatch(self, request, *args, **kwargs): + self.request = request + + if not self.request.event.settings.waiting_list_enabled: + messages.error(request, _("Waiting lists are disabled for this event.")) + return redirect(eventreverse(self.request.event, 'presale:event.index')) + + if not self.item_and_variation: + messages.error(request, _("We could not identify the product you selected.")) + return redirect(eventreverse(self.request.event, 'presale:event.index')) + + return super().dispatch(request, *args, **kwargs) + + def form_valid(self, form): + availability = ( + self.item_and_variation[1].check_quotas(count_waitinglist=False) + if self.item_and_variation[1] + else self.item_and_variation[0].check_quotas(count_waitinglist=False) + ) + if availability[0] == 100: + messages.error(self.request, _("You cannot add yourself to the waiting list as this product ist currently " + "available.")) + return redirect(eventreverse(self.request.event, 'presale:event.index')) + + form.save() + messages.success(self.request, _("We've added you to the waiting list. You will receive " + "an email as soon as tickets get available again.")) + return super().form_valid(form) + + def get_success_url(self): + return eventreverse(self.request.event, 'presale:event.index') diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index 671db2037..5a433820c 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -33,6 +33,8 @@ $(function () { $("#voucher-box").slideDown(); $("#voucher-toggle").slideUp(); }); + + $('[data-toggle="tooltip"]').tooltip(); $("#ajaxerr").on("click", ".ajaxerr-close", ajaxErrDialog.hide); diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 0fddedc3a..b08731509 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -13,7 +13,7 @@ from django.utils.timezone import now from pretix.base.models import ( CachedFile, CartPosition, Event, Item, ItemCategory, ItemVariation, Order, - OrderPosition, Organizer, Question, Quota, User, Voucher, + OrderPosition, Organizer, Question, Quota, User, Voucher, WaitingListEntry, ) from pretix.base.services.orders import ( OrderError, cancel_order, mark_order_paid, perform_order, @@ -316,6 +316,104 @@ class QuotaTestCase(BaseQuotaTestCase): self.assertEqual(self.quota.count_in_cart(), 1) self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 1)) + def test_waitinglist_item_active(self): + self.quota.items.add(self.item1) + self.quota.size = 1 + self.quota.save() + WaitingListEntry.objects.create( + event=self.event, item=self.item1, email='foo@bar.com' + ) + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_RESERVED, 0)) + self.assertEqual(self.item1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1)) + + def test_waitinglist_variation_active(self): + self.quota.variations.add(self.var1) + self.quota.size = 1 + self.quota.save() + WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com' + ) + self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_RESERVED, 0)) + self.assertEqual(self.var1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1)) + + def test_waitinglist_variation_fulfilled(self): + self.quota.variations.add(self.var1) + self.quota.size = 1 + self.quota.save() + v = Voucher.objects.create(quota=self.quota, event=self.event, block_quota=True, redeemed=1) + WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com', voucher=v + ) + self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_OK, 1)) + self.assertEqual(self.var1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1)) + + def test_waitinglist_variation_other(self): + self.quota.variations.add(self.var1) + self.quota.size = 1 + self.quota.save() + WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var2, email='foo@bar.com' + ) + self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_OK, 1)) + self.assertEqual(self.var1.check_quotas(count_waitinglist=False), (Quota.AVAILABILITY_OK, 1)) + + def test_quota_cache(self): + self.quota.variations.add(self.var1) + self.quota.size = 1 + self.quota.save() + WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com' + ) + + cache = {} + + self.assertEqual(self.var1.check_quotas(_cache=cache), (Quota.AVAILABILITY_RESERVED, 0)) + + with self.assertNumQueries(1): + self.assertEqual(self.var1.check_quotas(_cache=cache), (Quota.AVAILABILITY_RESERVED, 0)) + + # Do not reuse cache for count_waitinglist=False + self.assertEqual(self.var1.check_quotas(_cache=cache, count_waitinglist=False), (Quota.AVAILABILITY_OK, 1)) + + with self.assertNumQueries(1): + self.assertEqual(self.var1.check_quotas(_cache=cache, count_waitinglist=False), (Quota.AVAILABILITY_OK, 1)) + + +class WaitingListTestCase(BaseQuotaTestCase): + + def test_duplicate(self): + w1 = WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com' + ) + w1.clean() + w2 = WaitingListEntry( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com' + ) + with self.assertRaises(ValidationError): + w2.clean() + + def test_duplicate_of_successful(self): + v = Voucher.objects.create(quota=self.quota, event=self.event, block_quota=True, redeemed=1) + w1 = WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com', + voucher=v + ) + w1.clean() + w2 = WaitingListEntry( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com' + ) + w2.clean() + + def test_missing_variation(self): + w2 = WaitingListEntry( + event=self.event, item=self.item2, email='foo@bar.com' + ) + with self.assertRaises(ValidationError): + w2.clean() + + +class VoucherTestCase(BaseQuotaTestCase): + def test_voucher_reuse(self): self.quota.items.add(self.item1) v = Voucher.objects.create(quota=self.quota, event=self.event, valid_until=now() + timedelta(days=5)) @@ -396,9 +494,6 @@ class QuotaTestCase(BaseQuotaTestCase): v = Voucher(variation=self.var1, event=self.event) v.clean() - -class VoucherTestCase(BaseQuotaTestCase): - def test_calculate_price_none(self): v = Voucher.objects.create(event=self.event, price_mode='none', value=Decimal('10.00')) v.calculate_price(Decimal('23.42')) == Decimal('23.42') diff --git a/src/tests/base/test_waitinglist.py b/src/tests/base/test_waitinglist.py new file mode 100644 index 000000000..af8eb84cf --- /dev/null +++ b/src/tests/base/test_waitinglist.py @@ -0,0 +1,132 @@ +from django.core import mail as djmail +from django.test import TestCase +from django.utils.timezone import now + +from pretix.base.models import ( + Event, Item, ItemVariation, Organizer, Quota, Voucher, WaitingListEntry, +) +from pretix.base.models.waitinglist import WaitingListException +from pretix.base.services.waitinglist import ( + assign_automatically, process_waitinglist, +) + + +class WaitingListTestCase(TestCase): + @classmethod + def setUpTestData(cls): + o = Organizer.objects.create(name='Dummy', slug='dummy') + cls.event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now(), + ) + + def setUp(self): + djmail.outbox = [] + self.quota = Quota.objects.create(name="Test", size=2, event=self.event) + self.item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23, + admission=True) + self.item2 = Item.objects.create(event=self.event, name="T-Shirt", default_price=23) + self.item3 = Item.objects.create(event=self.event, name="Goodie", default_price=23) + self.var1 = ItemVariation.objects.create(item=self.item2, value='S') + self.var2 = ItemVariation.objects.create(item=self.item2, value='M') + self.var3 = ItemVariation.objects.create(item=self.item3, value='Fancy') + + def test_send_unavailable(self): + self.quota.items.add(self.item1) + self.quota.size = 0 + self.quota.save() + wle = WaitingListEntry.objects.create( + event=self.event, item=self.item1, email='foo@bar.com' + ) + with self.assertRaises(WaitingListException): + wle.send_voucher() + + def test_send_double(self): + self.quota.variations.add(self.var1) + self.quota.size = 1 + self.quota.save() + v = Voucher.objects.create(quota=self.quota, event=self.event, block_quota=True, redeemed=1) + wle = WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com', voucher=v + ) + with self.assertRaises(WaitingListException): + wle.send_voucher() + + def test_send_variation(self): + wle = WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com' + ) + wle.send_voucher() + wle.refresh_from_db() + + assert wle.voucher + assert wle.voucher.item == wle.item + assert wle.voucher.variation == wle.variation + assert wle.email in wle.voucher.comment + assert wle.voucher.block_quota + assert wle.voucher.max_usages == 1 + assert wle.voucher.event == self.event + + assert len(djmail.outbox) == 1 + assert djmail.outbox[0].to == [wle.email] + + def test_send_custom_validity(self): + self.event.settings.set('waiting_list_hours', 24) + wle = WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo@bar.com' + ) + wle.send_voucher() + wle.refresh_from_db() + + assert 3600 * 23 < (wle.voucher.valid_until - now()).seconds < 3600 * 24 + + def test_send_auto(self): + self.quota.variations.add(self.var1) + self.quota.size = 7 + self.quota.save() + for i in range(10): + WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo{}@bar.com'.format(i) + ) + WaitingListEntry.objects.create( + event=self.event, item=self.item1, email='bar{}@bar.com'.format(i) + ) + + assign_automatically.apply(args=(self.event.pk,)) + assert WaitingListEntry.objects.filter(voucher__isnull=True).count() == 3 + assert Voucher.objects.count() == 17 + assert sorted(list(WaitingListEntry.objects.filter(voucher__isnull=True).values_list('email', flat=True))) == [ + 'foo7@bar.com', 'foo8@bar.com', 'foo9@bar.com' + ] + + def test_send_periodic(self): + self.event.settings.set('waiting_list_enabled', True) + self.event.settings.set('waiting_list_auto', True) + for i in range(5): + WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo{}@bar.com'.format(i) + ) + process_waitinglist(None) + assert Voucher.objects.count() == 5 + + def test_send_periodic_disabled(self): + self.event.settings.set('waiting_list_enabled', True) + self.event.settings.set('waiting_list_auto', False) + for i in range(5): + WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo{}@bar.com'.format(i) + ) + process_waitinglist(None) + assert WaitingListEntry.objects.filter(voucher__isnull=True).count() == 5 + assert Voucher.objects.count() == 0 + + def test_send_periodic_disabled2(self): + self.event.settings.set('waiting_list_enabled', False) + self.event.settings.set('waiting_list_auto', True) + for i in range(5): + WaitingListEntry.objects.create( + event=self.event, item=self.item2, variation=self.var1, email='foo{}@bar.com'.format(i) + ) + process_waitinglist(None) + assert WaitingListEntry.objects.filter(voucher__isnull=True).count() == 5 + assert Voucher.objects.count() == 0 diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index 60a7e8645..95758e1ea 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -67,6 +67,8 @@ event_urls = [ "orders/ABC/contact", "orders/ABC/", "orders/", + "waitinglist/", + "waitinglist/auto_assign", "invoice/1", ] @@ -154,6 +156,8 @@ event_permission_urls = [ ("can_view_vouchers", "vouchers/tags/", 200), ("can_change_vouchers", "vouchers/1234/", 404), ("can_change_vouchers", "vouchers/1234/delete", 404), + ("can_view_orders", "waitinglist/", 200), + ("can_change_orders", "waitinglist/auto_assign", 405), ] diff --git a/src/tests/control/test_views.py b/src/tests/control/test_views.py index 3cf107e36..7494bc8b9 100644 --- a/src/tests/control/test_views.py +++ b/src/tests/control/test_views.py @@ -123,6 +123,8 @@ def logged_in_client(client, event): ('/control/event/{orga}/{event}/orders/export/', 200), ('/control/event/{orga}/{event}/orders/go', 302), ('/control/event/{orga}/{event}/orders/', 200), + ('/control/event/{orga}/{event}/waitinglist/', 200), + ('/control/event/{orga}/{event}/waitinglist/auto_assign', 405), ]) @pytest.mark.django_db def test_one_view(logged_in_client, url, expected, event, item, item_category, order, question, quota, voucher): diff --git a/src/tests/control/test_waitinglist.py b/src/tests/control/test_waitinglist.py new file mode 100644 index 000000000..e7fae3ba0 --- /dev/null +++ b/src/tests/control/test_waitinglist.py @@ -0,0 +1,87 @@ +import pytest +from django.utils.timezone import now + +from pretix.base.models import ( + Event, EventPermission, Item, Organizer, Quota, User, Voucher, + WaitingListEntry, +) +from pretix.control.views.dashboards import waitinglist_widgets + + +@pytest.fixture +def env(): + o = Organizer.objects.create(name='Dummy', slug='dummy') + event = Event.objects.create( + organizer=o, name='Dummy', slug='dummy', + date_from=now(), plugins='pretix.plugins.banktransfer,tests.testdummy' + ) + event.settings.set('ticketoutput_testdummy__enabled', True) + user = User.objects.create_user('dummy@dummy.dummy', 'dummy') + item1 = Item.objects.create(event=event, name="Ticket", default_price=23, + admission=True) + item2 = Item.objects.create(event=event, name="Ticket", default_price=23, + admission=True) + + for i in range(5): + WaitingListEntry.objects.create( + event=event, item=item1, email='foo{}@bar.com'.format(i) + ) + v = Voucher.objects.create(item=item1, event=event, block_quota=True, redeemed=1) + WaitingListEntry.objects.create( + event=event, item=item1, email='success@example.org', voucher=v + ) + WaitingListEntry.objects.create( + event=event, item=item2, email='item2@example.org' + ) + + EventPermission.objects.create( + event=event, + user=user, + can_view_orders=True, + can_change_orders=True + ) + return event, user, o, item1 + + +@pytest.mark.django_db +def test_list(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + + response = client.get('/control/event/dummy/dummy/waitinglist/') + assert 'success@example.org' not in response.rendered_content + assert 'item2@example.org' in response.rendered_content + assert 'foo0@bar.com' in response.rendered_content + assert response.context['estimate'] == 23 * 6 + + response = client.get('/control/event/dummy/dummy/waitinglist/?status=a') + assert 'success@example.org' in response.rendered_content + assert 'foo0@bar.com' in response.rendered_content + + response = client.get('/control/event/dummy/dummy/waitinglist/?status=s') + assert 'success@example.org' in response.rendered_content + assert 'foo0@bar.com' not in response.rendered_content + + response = client.get('/control/event/dummy/dummy/waitinglist/?item=%d' % env[3].pk) + assert 'item2@example.org' not in response.rendered_content + assert 'foo0@bar.com' in response.rendered_content + + +@pytest.mark.django_db +def test_assign_single(client, env): + client.login(email='dummy@dummy.dummy', password='dummy') + wle = WaitingListEntry.objects.filter(voucher__isnull=True).last() + + client.post('/control/event/dummy/dummy/waitinglist/', { + 'assign': wle.pk + }) + wle.refresh_from_db() + assert wle.voucher + + +@pytest.mark.django_db +def test_dashboard(client, env): + quota = Quota.objects.create(name="Test", size=2, event=env[0]) + quota.items.add(env[3]) + w = waitinglist_widgets(env[0]) + assert '3' in w[0]['content'] + assert '7' in w[1]['content'] diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index b51334481..b578a4fdc 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -9,7 +9,7 @@ from tests.base import SoupTest from pretix.base.models import ( Event, EventPermission, Item, ItemCategory, ItemVariation, Order, - Organizer, Quota, User, + Organizer, Quota, User, WaitingListEntry, ) @@ -343,6 +343,72 @@ class VoucherRedeemItemDisplayTest(EventTestMixin, SoupTest): assert "alert-danger" in html.rendered_content +class WaitingListTest(EventTestMixin, SoupTest): + def setUp(self): + super().setUp() + self.q = Quota.objects.create(event=self.event, name='Quota', size=0) + self.v = self.event.vouchers.create(quota=self.q) + self.item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=Decimal('12.00'), + active=True) + self.q.items.add(self.item) + self.event.settings.set('waiting_list_enabled', True) + + def test_disabled(self): + self.event.settings.set('waiting_list_enabled', False) + response = self.client.get( + '/%s/%s/' % (self.orga.slug, self.event.slug) + ) + self.assertEqual(response.status_code, 200) + self.assertNotIn('waitinglist', response.rendered_content) + response = self.client.get( + '/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk + 1) + ) + self.assertEqual(response.status_code, 302) + + def test_display_link(self): + response = self.client.get( + '/%s/%s/' % (self.orga.slug, self.event.slug) + ) + self.assertEqual(response.status_code, 200) + self.assertIn('waitinglist', response.rendered_content) + + def test_submit_form(self): + response = self.client.get( + '/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk) + ) + self.assertEqual(response.status_code, 200) + 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' + } + ) + self.assertEqual(response.status_code, 302) + wle = WaitingListEntry.objects.get(email='foo@bar.com') + assert wle.event == self.event + assert wle.item == self.item + assert wle.variation is None + assert wle.voucher is None + assert wle.locale == 'en' + + def test_invalid_item(self): + response = self.client.get( + '/%s/%s/waitinglist?item=%d' % (self.orga.slug, self.event.slug, self.item.pk + 1) + ) + self.assertEqual(response.status_code, 302) + + def test_available(self): + self.q.size = 1 + 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' + } + ) + self.assertEqual(response.status_code, 302) + self.assertFalse(WaitingListEntry.objects.filter(email='foo@bar.com').exists()) + + class DeadlineTest(EventTestMixin, TestCase): def setUp(self): super().setUp()