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" %}
+